From bf2cea4a091480269d3e6662d7fe27e9b6e075af Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 26 Jun 2023 15:25:43 +0800 Subject: [PATCH 01/53] Add image processing --- README.md | 94 +++++++++------ config.lua.example | 73 +++++++++--- nginx-lua-image.lua | 247 +++++++++++++++++++++++++++++++++++++++ nginx-lua-mp4.lua | 273 ++++++++++++++++++++++++++++++-------------- utils.lua | 34 ++++++ 5 files changed, 584 insertions(+), 137 deletions(-) create mode 100644 nginx-lua-image.lua create mode 100644 utils.lua diff --git a/README.md b/README.md index 76a5d2f..a8d9813 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ https://user-images.githubusercontent.com/3368441/161866581-ee1c745c-f119-430c-8 - ✅ Keep original aspect ratio - ✅ mp4 support (output) - 🚧 webm support (output) -## Requirements +## Requirements - OpenResty or nginx with ngx_http_lua_module enabled - [ffmpeg 5](https://launchpad.net/~savoury1/+archive/ubuntu/ffmpeg5) installed - [time](https://en.wikipedia.org/wiki/Time_(Unix)) utility if you have `config.logTime` enabled ## Installation -#### 1. Clone the repo +#### 1. Clone the repo #### 2. nginx config changes @@ -52,9 +52,9 @@ Add to the `http` section of the nginx config: ``` http { ... - + lua_package_path "/absolute/path/to/nginx-lua-mp4/?.lua;;"; - + ... ``` @@ -63,9 +63,9 @@ And here's minimal viable config for 4 locations you need to set up. These locat # video location location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.mp4)$ { # these two are required to be set regardless - set $luamp_original_video ""; - set $luamp_transcoded_video ""; - + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + # these are needed to be set if you did not use them in regex matching location set $luamp_prefix ""; set $luamp_postfix ""; @@ -79,21 +79,40 @@ location @luamp_process { content_by_lua_file "/absolute/path/to/nginx-lua-mp4/nginx-lua-mp4.lua"; } +# image location +location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + # these two are required to be set regardless + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + + # these are needed to be set if you did not use them in regex matching location + set $luamp_prefix ""; + set $luamp_postfix ""; + + #pass to transcoder location + try_files $uri @luamp_image_process; +} + +# image process/transcode location +location @luamp_image_process { + content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-image.lua"; +} + # cache location location =/luamp-cache { internal; root /; index off; - - set_unescape_uri $luamp_transcoded_video $arg_luamp_cached_video_path; - - try_files $luamp_transcoded_video =404; + + set_unescape_uri $luamp_transcoded_file $arg_luamp_cached_file_path; + + try_files $luamp_transcoded_file =404; } # upstream location location =/luamp-upstream { internal; - rewrite ^(.+)$ $luamp_original_video break; + rewrite ^(.+)$ $luamp_original_file break; proxy_pass https://old-cdn.example.com; } @@ -102,7 +121,7 @@ location =/luamp-upstream { #### 2.1. Video location This location used as an entry point and to set initial variables. This is usually a location with a `.mp4` at the end. -There are two variables you need to `set`/initialise: `$luamp_original_video` and `$luamp_transcoded_video`. +There are two variables you need to `set`/initialise: `$luamp_original_file` and `$luamp_transcoded_file`. There are four variables that may be used as a named capture group in location regex: `luamp_prefix`, `luamp_flags`, `luamp_postfix`, `luamp_filename`. @@ -121,9 +140,9 @@ If you do not need prefix and postfix, you can omit them from the regexp, but do ``` location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.mp4)$ { # these two are required to be set regardless - set $luamp_original_video ""; - set $luamp_transcoded_video ""; - + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + # these are needed to be set if you did not use them in regex matching location set $luamp_prefix ""; set $luamp_postfix ""; @@ -135,7 +154,8 @@ location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_ ``` #### Security considerations -`prefix`, `postfix` and `filename` are passed to the `os.execute()` with following sanitisation: +`prefix`, `postfix` and `filename` are passed to the `os.execute()` with following sanitisation: + - alphanumeric symbols, underscores, dots and slashes are allowed. - all other symbols are stripped. - then, double dots are stripped. @@ -165,10 +185,10 @@ location =/luamp-cache { internal; root /; index off; - - set_unescape_uri $luamp_transcoded_video $arg_luamp_cached_video_path; - - try_files $luamp_transcoded_video =404; + + set_unescape_uri $luamp_transcoded_file $arg_luamp_cached_video_path; + + try_files $luamp_transcoded_file =404; } ``` @@ -179,12 +199,12 @@ If `luamp` finds no original file to transcode, it will attempt to download it f ``` location =/luamp-upstream { internal; - rewrite ^(.+)$ $luamp_original_video break; + rewrite ^(.+)$ $luamp_original_file break; proxy_pass https://old-cdn.example.com; } ``` -`$luamp_original_video` is set within `config.getOriginalsUpstreamPath` function that can be configured in `luamp` config.lua. You can apply whatever logic you may need there to dynamically generate path for the upstream. +`$luamp_original_file` is set within `config.getOriginalsUpstreamPath` function that can be configured in `luamp` config.lua. You can apply whatever logic you may need there to dynamically generate path for the upstream. #### 3. nginx-lua-mp4 config @@ -226,7 +246,7 @@ $ which ffmpeg Where to redirect `ffmpeg` output if `config.logFfmpegOutput` is set to false. -For *nix (default value): +For \*nix (default value): ``` config.ffmpegDevNull = '2> /dev/null' -- nix ``` @@ -243,7 +263,7 @@ Use this table to customize how flags are called in your URLs. Defaults are one One letter flags (except for DPR) if you want to use flags like `w_200,h_180,c_pad`: ``` - ['c'] = 'crop', + ['c'] = 'crop', ['b'] = 'background', ['dpr'] = 'dpr', ['h'] = 'height', @@ -253,7 +273,7 @@ One letter flags (except for DPR) if you want to use flags like `w_200,h_180,c_p Full flags if you want to use flags like `width_200,height_180,crop_pad`: ``` - ['crop'] = 'crop', + ['crop'] = 'crop', ['background'] = 'background', ['dpr'] = 'dpr', ['height'] = 'height', @@ -267,7 +287,7 @@ Customize this function to preprocess flags or their values. Return values shoul #### `config.flagsDelimiter` Character that is used to separate different flags in URL, e.g. commas in `/w_1280,h_960,c_pad/`. - + #### `config.flagValueDelimiter` Character that is used to separate flag name from the value, e.g. underscores in `/w_1280,h_960,c_pad/`. @@ -305,7 +325,7 @@ Whether to log whole `luamp` process. Useful for initial setup and for debug. #### `config.logFfmpegOutput` -Whether to log `ffmpeg` output. Note that `ffmpeg` outputs to `stderr`, and if `logFfmpegOutput` is enabled, it will log to nginx's `error.log`. +Whether to log `ffmpeg` output. Note that `ffmpeg` outputs to `stderr`, and if `logFfmpegOutput` is enabled, it will log to nginx's `error.log`. #### `config.logLevel = ngx.ERR` @@ -321,13 +341,13 @@ Limit the output video's maximum height or width. If the resulting height or wid #### `config.mediaBaseFilepath` -Where videos (both originals and transcoded ones) should be stored. Usually, a directory where assets are stored. Should be readable/writable for nginx. +Where videos (both originals and transcoded ones) should be stored. Usually, a directory where assets are stored. Should be readable/writable for nginx. #### `config.minimumTranscodedFileSize` Minimum required size (in bytes) for the transcoded file to not be considered broken and deleted (default is 1KB). -During the transcoding, errors may occur and ffmpeg sometimes leaves corrupt files on the FS. Those are usually either 0B or just a few bytes of header. Luamp will delete those that are less than `minimumTranscodedFileSize` bytes. +During the transcoding, errors may occur and ffmpeg sometimes leaves corrupt files on the FS. Those are usually either 0B or just a few bytes of header. Luamp will delete those that are less than `minimumTranscodedFileSize` bytes. #### `config.serveOriginalOnTranscodeFailure` @@ -353,10 +373,10 @@ nginx-lua-mp4 $ diff config.lua config.lua.example < config.logTime = true --- > config.logTime = false -> +> > -- top limit for output video height (default 4k UHD) > config.maxHeight = 2160 -> +> > -- top limit for output video width (default 4k UHD) > config.maxWidth = 3840 @@ -379,13 +399,13 @@ You can now just copy and paste these lines above the `return config` in `config ### `b` — Background Available values: - - `blurred` — when padding is enabled (with `c_pad` or `c_lpad`), the padding box will contain an upscaled blurred video. +- `blurred` — when padding is enabled (with `c_pad` or `c_lpad`), the padding box will contain an upscaled blurred video. ### `c` — Crop Available values: - - `pad` — when resizing a video, aspect ratio will be preserved and padding box will be added to keep the aspect ratio. - - `lpad` — same as `pad` but original video will **not** be scaled up. +- `pad` — when resizing a video, aspect ratio will be preserved and padding box will be added to keep the aspect ratio. +- `lpad` — same as `pad` but original video will **not** be scaled up. ### `dpr` — Device Pixel Ratio @@ -401,12 +421,12 @@ Available values: integer number. ### `x` — X coordinate for overlay with `[limited_]padding` crop -Available values: +Available values: - integer number for pixels - decimal number in range `(0, 1)` for percentage: 0.25 is 25% of resulting width (after DPR is applied) ### `y` — Y coordinate for overlay with `[limited_]padding` crop -Available values: +Available values: - integer number for pixels - decimal number in range `(0, 1)` for percentage: 0.25 is 25% of resulting height (after DPR is applied) diff --git a/config.lua.example b/config.lua.example index 962e861..6560791 100644 --- a/config.lua.example +++ b/config.lua.example @@ -6,6 +6,9 @@ config = {} -- `which ffmpeg` config.ffmpeg = '/usr/local/bin/ffmpeg' +-- `which magick` +config.magick = '/usr/bin/magick' + -- where to save original and transcoded files (trailing slash required) config.mediaBaseFilepath = '/tmp/nginx/' @@ -30,14 +33,15 @@ config.flagValueDelimiter = '_' -- some flags in use on the front end. Customize the left part of the table. -- eg `['cropping'] = 'crop'` to use `cropping` instead of the default `c` config.flagMap = { - ['c'] = 'crop', -- crop / scale - ['b'] = 'background', - ['dpr'] = 'dpr', -- DPR, https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio - -- ['f'] = 'format', - ['h'] = 'height', - ['w'] = 'width', - ['x'] = 'x', - ['y'] = 'y', + c = 'crop', -- crop / scale + b = 'background', + dpr = 'dpr', -- DPR, https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio + -- f = 'format', + g = 'gravity', + h = 'height', + w = 'width', + x = 'x', + y = 'y', } -- override URL flag values. Useful when you migrate from another transcoding solution and already have @@ -45,9 +49,31 @@ config.flagMap = { -- eg `['padded'] = 'pad'` to use `padded` instead of the default `pad` -- Also, all flag values not present in this table will be considered (and cast to) a number config.flagValueMap = { - ['pad'] = 'padding', - ['lpad'] = 'limited_padding', - ['blurred'] = 'blur', + blurred = 'blur', + -- crop params + pad = 'padding', + lpad = 'limited_padding', + fill = 'fill', + -- background params + black = 'black', + white = 'white', + red = 'red', + green = 'green', + blue = 'blue', + yellow = 'yellow', + cyan = 'cyan', + magenta = 'magenta', + gray = 'gray', + -- gravity params + northwest = 'northwest', + north = 'north', + northeast = 'northeast', + west = 'west', + center = 'center', + east = 'east', + southwest = 'southwest', + south = 'south', + southeast = 'southeast', } -- log transcoding process. Useful when doing initial setup or debugging issues @@ -68,10 +94,16 @@ config.ffmpegDevNull = '2> /dev/null' -- nix config.logTime = false -- top limit for output video height (default 4k UHD) -config.maxHeight = 2160 +config.maxVideoHeight = 2160 -- top limit for output video width (default 4k UHD) -config.maxWidth = 3840 +config.maxVideoWidth = 3840 + +-- top limit for output image height +config.maxImageHeight = 2160 + +-- top limit for output image width +config.maxImageWidth = 3840 -- customize this function to preprocess flags or their values -- return values should contain values that are present in `config.flagMap` and `config.flagValueMap` @@ -88,10 +120,25 @@ config.serveOriginalOnTranscodeFailure = true -- least required size (in bytes) for the transcoded file to not be considered broken and deleted (default is 1KB) config.minimumTranscodedFileSize = 1024 +-- least required size (in bytes) for the transcoded file to not be considered broken and deleted (default is 1B) +config.minimumTranscodedImageSize = 1 + -- encoding preset to use https://trac.ffmpeg.org/wiki/Encode/H.264 config.ffmpegPreset = '' -- config.ffmpegPreset = 'ultrafast' -- config.ffmpegPreset = 'superfast' -- config.ffmpegPreset = 'veryfast' +-- Set missing config options to the defaults +---@generic T: table, K, V +---@param defaults T +function config.setDefaults(defaults) + for o, v in pairs(defaults) do + if config[o] == nil then + log('setting default config for ' .. o) + config[o] = v + end + end +end + return config diff --git a/nginx-lua-image.lua b/nginx-lua-image.lua new file mode 100644 index 0000000..1fce183 --- /dev/null +++ b/nginx-lua-image.lua @@ -0,0 +1,247 @@ +local config = require('config') +local utils = require('utils') +local log = utils.log + +log('luamp started') + +-- Set missing config options to the defaults +config.setDefaults({ + minimumTranscodedImageSize = 1024, + serveOriginalOnTranscodeFailure = true, +}) + +-- Get URL params +local prefix = utils.cleanupPath(ngx.var.luamp_prefix) +local luamp_flags = ngx.var.luamp_flags +local postfix = utils.cleanupPath(ngx.var.luamp_postfix) +local filename = utils.cleanupPath(ngx.var.luamp_filename) + +log('prefix: ' .. prefix) +log('flags: ' .. luamp_flags) +log('postfix: ' .. postfix) +log('filename: ' .. filename) + +-- Enabled flags with defaults +local flags = { + background = { + enabled = true, + value = 'white' + }, + crop = { + enabled = true, + value = nil + }, + dpr = { + enabled = true, + value = 1 + }, + gravity = { + enabled = true, + value = 'center' + }, + height = { + enabled = true, + value = nil + }, + width = { + enabled = true, + value = nil + }, + x = { + enabled = true, + value = 0 + }, + y = { + enabled = true, + value = 0 + } +} +local flagsOrdered = {} + +-- Add the flag name to the ordered list +for flag, _ in pairs(flags) do + table.insert(flagsOrdered, flag) +end +-- Sort flags so path will be the same for `w_1280,h_960` and `h_960,w_1280` +table.sort(flagsOrdered) + +-- Parse flags into a table +for flag, value in string.gmatch(luamp_flags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do + local flagMapped = config.flagMap[flag] + -- Check if the flag is enabled + if value and flags[flagMapped] and flags[flagMapped].enabled then + -- Preprocess the flag and value if necessary + if config.flagPreprocessHook then + flag, value = config.flagPreprocessHook(flag, value) + end + + -- Check if it is an allowed text flag or cast to a number + flags[flagMapped].value = config.flagValueMap[value] or tonumber(value) + end +end + +-- Apply limits to height and width if specified +local maxImageHeight = config.maxImageHeight +local maxImageWidth = config.maxImageWidth + +if flags.height.value and maxImageHeight and flags.height.value > maxImageHeight then + log('Resulting height exceeds configured limit, capping it at ' .. maxImageHeight) + flags.height.value = maxImageHeight +end + +if flags.width.value and maxImageWidth and flags.width.value > maxImageWidth then + log('Resulting width exceeds configured limit, capping it at ' .. maxImageWidth) + flags.width.value = maxImageWidth +end + +-- Coalesce flag values. All flag values are set at this moment +local function coalesceFlag(option) + local flag = flags[option] + if flag and flag.value and flag.value ~= '' then + return option .. '_' .. flag.value + end + return '' +end + +-- Generate the options path +local optionsPath = '' + +for _, option in ipairs(flagsOrdered) do + local pathFragment = coalesceFlag(option) + if pathFragment ~= '' then + optionsPath = optionsPath .. pathFragment .. '/' + end +end + +-- Check if we already have a cached version of the file +local cacheDir = config.mediaBaseFilepath .. prefix .. optionsPath .. postfix +local cachedFilepath = cacheDir .. filename + +-- Serve the cached file if it exists +if utils.fileExists(cachedFilepath) then + log('Serving cached file: ' .. cachedFilepath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath }) + return +end + +log('Cached file not found: ' .. cachedFilepath) + +-- If the cached file doesn't exist, process the original file +local originalDir = config.mediaBaseFilepath .. prefix .. postfix +local originalFilepath = originalDir .. filename + +-- Check if the original file exists +if not utils.fileExists(originalFilepath) then + log('Original file not found: ' .. originalFilepath) + + if config.downloadOriginals then + -- Download original if upstream download is enabled + local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, filename) + log('Downloading original from ' .. originalsUpstreamPath) + ngx.req.discard_body() -- Clear body + log('Fetching') + local originalReq = ngx.location.capture('/luamp-upstream', + { vars = { luamp_original_file = originalsUpstreamPath } }) + log('Upstream status: ' .. originalReq.status) + + if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then + log('Downloaded original, saving') + os.execute('mkdir -p ' .. originalDir) + local originalFile = io.open(originalFilepath, 'w') + originalFile:write(originalReq.body) + originalFile:close() + log('Saved to ' .. originalFilepath) + else + ngx.exit(ngx.HTTP_NOT_FOUND) + end + else + ngx.exit(ngx.HTTP_NOT_FOUND) + end +end + +log('Original is present on local FS. Transcoding to ' .. cachedFilepath) + +-- Create cached transcoded file +os.execute('mkdir -p ' .. cacheDir) + +-- Build the convert command +local background = flags.background.value +local crop = flags.crop.value +local gravity = flags.gravity.value +local height = flags.height.value and math.ceil(flags.height.value * flags.dpr.value) +local width = flags.width.value and math.ceil(flags.width.value * flags.dpr.value) +local x = flags.x.value +local y = flags.y.value +local convertCommand = config.magick .. + ' ' .. originalFilepath .. + ' -background ' .. background .. + ' -gravity ' .. gravity + +if crop and width and height then + if crop == 'fill' then + convertCommand = convertCommand .. + ' -resize ' .. width .. 'x' .. height .. '^' .. + ' -crop ' .. width .. 'x' .. height .. '+' .. x .. '+' .. y + end + + if crop == 'limited_padding' then + convertCommand = convertCommand .. + ' -resize ' .. '"' .. width .. 'x' .. height .. '>"' .. + ' -extent ' .. width .. 'x' .. height + end + + if crop == 'padding' then + convertCommand = convertCommand .. + ' -resize ' .. width .. 'x' .. height .. + ' -extent ' .. width .. 'x' .. height + end +elseif width and height then + convertCommand = convertCommand .. ' -resize ' .. width .. 'x' .. height .. '!' +elseif width or height then + convertCommand = convertCommand .. ' -resize ' .. (width or '') .. 'x' .. (height or '') +end + +-- Append the output filepath to the convert command +convertCommand = convertCommand .. ' ' .. cachedFilepath + +local executeSuccess + +if convertCommand ~= nil then + if config.logTime then + convertCommand = 'time ' .. convertCommand + end + + log('Command: ' .. convertCommand) + executeSuccess = os.execute(convertCommand) +end + +if executeSuccess == nil then + log('Transcode failed') + + if config.serveOriginalOnTranscodeFailure == true then + log('Serving original from: ' .. originalFilepath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath }) + end +else + -- Check if transcoded file is > minimumTranscodedImageSize + -- We do this inside the transcoding `if` block to not mess with other threads + local transcodedFile = io.open(cachedFilepath, 'rb') + local transcodedFileSize = transcodedFile:seek('end') + transcodedFile:close() + + if transcodedFileSize > config.minimumTranscodedImageSize then + log('Transcoded version is good, serving it') + -- Serve it + ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath }) + else + log('Transcoded version is corrupt') + -- Delete corrupt one + os.remove(cachedFilepath) + + -- Serve original + if config.serveOriginalOnTranscodeFailure == true then + log('Serving original') + ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath }) + end + end +end diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 1cb5b97..6d0275b 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -1,88 +1,66 @@ -config = require('config') - -function log(data) - if (config.logEnabled == true) then - ngx.log(config.logLevel, data) - end -end - -function setDefaultConfig(option, value) - if config[option] == nil then - log('setting default config for ' .. option) - config[option] = value - end -end - -function cleanupPath(path) - -- allow only alphanumeric + underscore + dash + slash + dot - local retVal = path:gsub('[^%w_%-/.=]', '') - -- strip double+ dot - return retVal:gsub('([\\.][\\.]+)', '') -end - -local configDefaults = { - ['minimumTranscodedFileSize'] = 1024, - ['serveOriginalOnTranscodeFailure'] = true, - ['ffmpegPreset'] = '', -} +local config = require('config') +local utils = require('utils') +local log = utils.log log('luamp started') --- set missing config options to the defaults -for o, v in pairs(configDefaults) do - setDefaultConfig(o, v) -end +-- Set missing config options to the defaults +config.setDefaults({ + minimumTranscodedVideoSize = 1024, + serveOriginalOnTranscodeFailure = true, + ffmpegPreset = '' +}) --- get url params -local prefix, flags, postfix, filename = ngx.var.luamp_prefix, ngx.var.luamp_flags, ngx.var.luamp_postfix, ngx.var.luamp_filename +-- Get URL params +local prefix = utils.cleanupPath(ngx.var.luamp_prefix) +local flags = ngx.var.luamp_flags +local postfix = utils.cleanupPath(ngx.var.luamp_postfix) +local filename = utils.cleanupPath(ngx.var.luamp_filename) log('prefix: ' .. prefix) log('flags: ' .. flags) log('postfix: ' .. postfix) log('filename: ' .. filename) -prefix = cleanupPath(prefix) -postfix = cleanupPath(postfix) -filename = cleanupPath(filename) - +-- Initialize flag-related variables local flagValues = {} local flagOrdered = {} local enabledFlags = { - ['crop'] = true, - ['background'] = true, - ['dpr'] = true, - -- ['format'] = true, - ['height'] = true, - ['width'] = true, - ['x'] = true, - ['y'] = true, + crop = true, + background = true, + dpr = true, + -- format = true, + height = true, + width = true, + x = true, + y = true, } --- parse flags into table +-- Parse flags into a table for flag, value in string.gmatch(flags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do - -- if the flag is enabled + -- Check if the flag is enabled if value ~= nil and enabledFlags[config.flagMap[flag]] ~= nil then + -- Preprocess the flag and value if necessary if config.flagPreprocessHook ~= nil then flag, value = config.flagPreprocessHook(flag, value) end log(config.flagMap[flag] .. ' ' .. value) - -- add it + -- Add the flag to the ordered list table.insert(flagOrdered, config.flagMap[flag]) - -- if it is an allowed text flag + -- Check if it is an allowed text flag or cast to a number if config.flagValueMap[value] ~= nil then - -- add allowed text flag - flagValues[config.flagMap[flag]] = config.flagValueMap[value] + flagValues[config.flagMap[flag]] = config.flagValueMap[value] -- Add allowed text flag else - -- otherwise cast to number - flagValues[config.flagMap[flag]] = tonumber(value) + flagValues[config.flagMap[flag]] = tonumber(value) -- Cast to number end end end --- sort flags so path will be the same for `w_1280,h_960` and `h_960,w_1280` +-- Sort flags so path will be the same for `w_1280,h_960` and `h_960,w_1280` table.sort(flagOrdered) -function coalesceFlag(option) +-- Coalesce flag values +local function coalesceFlag(option) if flagValues[option] ~= nil then return option .. '_' .. flagValues[option] else @@ -90,7 +68,7 @@ function coalesceFlag(option) end end --- make path +-- Generate the options path local options = {} local optionsPath = '' @@ -127,7 +105,8 @@ if cachedFile == nil then ngx.req.discard_body() log('fetching') -- fetch - local originalReq = ngx.location.capture('/luamp-upstream', { vars = { luamp_original_video = config.getOriginalsUpstreamPath(prefix, postfix, filename) } }) + local originalReq = ngx.location.capture('/luamp-upstream', + { vars = { luamp_original_file = config.getOriginalsUpstreamPath(prefix, postfix, filename) } }) log('upstream status: ' .. originalReq.status) if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then log('downloaded original, saving') @@ -149,7 +128,11 @@ if cachedFile == nil then -- process DPR if (flagValues['dpr'] ~= nil) then - log('before DPR calculation, w: ' .. (flagValues['width'] or 'nil') .. ', h: ' .. (flagValues['height'] or 'nil') .. ', x: ' .. (flagValues['x'] or 'nil') .. ', y: ' .. (flagValues['y'] or 'nil')) + log('before DPR calculation, w: ' .. + (flagValues['width'] or 'nil') .. + ', h: ' .. + (flagValues['height'] or 'nil') .. + ', x: ' .. (flagValues['x'] or 'nil') .. ', y: ' .. (flagValues['y'] or 'nil')) -- width and height if flagValues['height'] ~= nil then flagValues['height'] = math.ceil(flagValues['height'] * flagValues['dpr']) @@ -165,20 +148,24 @@ if cachedFile == nil then if flagValues['y'] ~= nil and flagValues['y'] >= 1 then flagValues['y'] = flagValues['y'] * flagValues['dpr'] end - log('after DPR calculation, w: ' .. (flagValues['width'] or 'nil') .. ', h: ' .. (flagValues['height'] or 'nil') .. ', x: ' .. (flagValues['x'] or 'nil') .. ', y: ' .. (flagValues['y'] or 'nil')) + log('after DPR calculation, w: ' .. + (flagValues['width'] or 'nil') .. + ', h: ' .. + (flagValues['height'] or 'nil') .. + ', x: ' .. (flagValues['x'] or 'nil') .. ', y: ' .. (flagValues['y'] or 'nil')) end - if config.maxHeight ~= nil and flagValues['height'] ~= nil then - if flagValues['height'] > config.maxHeight then - log('resulting height exceeds configured limit, capping it at ' .. config.maxHeight) - flagValues['height'] = config.maxHeight + if config.maxVideoHeight ~= nil and flagValues['height'] ~= nil then + if flagValues['height'] > config.maxVideoHeight then + log('resulting height exceeds configured limit, capping it at ' .. config.maxVideoHeight) + flagValues['height'] = config.maxVideoHeight end end - if config.maxWidth ~= nil and flagValues['width'] ~= nil then - if flagValues['width'] > config.maxWidth then - log('resulting width exceeds configured limit, capping it at ' .. config.maxWidth) - flagValues['width'] = config.maxWidth + if config.maxVideoWidth ~= nil and flagValues['width'] ~= nil then + if flagValues['width'] > config.maxVideoWidth then + log('resulting width exceeds configured limit, capping it at ' .. config.maxVideoWidth) + flagValues['width'] = config.maxVideoWidth end end @@ -209,32 +196,144 @@ if cachedFile == nil then if (flagValues['background'] ~= nil and flagValues['background'] == 'blur' and flagValues['crop'] ~= nil and flagValues['crop'] == 'limited_padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- scale + padded (no upscale) + blurred bg - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. flagValues['width'] .. '\\,iw*(max(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):max(' .. flagValues['height'] .. '\\,ih*(max(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. flagValues['width'] .. ':' .. flagValues['height'] .. ', setsar=1[background];[second]scale=min(' .. flagValues['width'] .. '\\,iw):min(' .. flagValues['height'] .. '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. (flagValues['y'] or '(H-h)/2') .. ':x=' .. (flagValues['x'] or '(W-w)/2') .. '" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. + flagValues['width'] .. + '\\,iw*(max(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):max(' .. + flagValues['height'] .. + '\\,ih*(max(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. + flagValues['width'] .. + ':' .. + flagValues['height'] .. + ', setsar=1[background];[second]scale=min(' .. + flagValues['width'] .. + '\\,iw):min(' .. + flagValues['height'] .. + '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. + (flagValues['y'] or '(H-h)/2') .. + ':x=' .. (flagValues['x'] or '(W-w)/2') .. '" -c:a copy ' .. preset .. cachedFilepath .. filename elseif (flagValues['background'] ~= nil and flagValues['background'] == 'blur' and flagValues['crop'] ~= nil and flagValues['crop'] == 'padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- scale + padded (with upscale) + blurred bg - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. flagValues['width'] .. '\\,iw*(max(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):max(' .. flagValues['height'] .. '\\,ih*(max(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. flagValues['width'] .. ':' .. flagValues['height'] .. ', setsar=1[background];[second]scale=min(' .. flagValues['width'] .. '\\,iw*(min(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):min(' .. flagValues['height'] .. '\\,ih*(min(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. (flagValues['y'] or '(H-h)/2') .. ':x=' .. (flagValues['x'] or '(W-w)/2') .. '" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. + flagValues['width'] .. + '\\,iw*(max(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):max(' .. + flagValues['height'] .. + '\\,ih*(max(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. + flagValues['width'] .. + ':' .. + flagValues['height'] .. + ', setsar=1[background];[second]scale=min(' .. + flagValues['width'] .. + '\\,iw*(min(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):min(' .. + flagValues['height'] .. + '\\,ih*(min(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. + (flagValues['y'] or '(H-h)/2') .. + ':x=' .. (flagValues['x'] or '(W-w)/2') .. '" -c:a copy ' .. preset .. cachedFilepath .. filename elseif (flagValues['crop'] ~= nil and flagValues['crop'] == 'limited_padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- scale (no upscale) with padding (blackbox) - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "scale=min(' .. flagValues['width'] .. '\\,iw):min(' .. flagValues['height'] .. '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1,pad=' .. flagValues['width'] .. ':' .. flagValues['height'] .. ':y=' .. (flagValues['y'] or '-1') .. ':x=' .. (flagValues['x'] or '-1') .. ':color=black" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "scale=min(' .. + flagValues['width'] .. + '\\,iw):min(' .. + flagValues['height'] .. + '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1,pad=' .. + flagValues['width'] .. + ':' .. + flagValues['height'] .. + ':y=' .. + (flagValues['y'] or '-1') .. + ':x=' .. (flagValues['x'] or '-1') .. ':color=black" -c:a copy ' .. preset .. cachedFilepath .. filename elseif (flagValues['crop'] ~= nil and flagValues['crop'] == 'padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- scale (with upscale) with padding (blackbox) - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "scale=min(' .. flagValues['width'] .. '\\,iw*(min(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):min(' .. flagValues['height'] .. '\\,ih*(min(' .. flagValues['width'] .. '/iw\\,' .. flagValues['height'] .. '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1,pad=' .. flagValues['width'] .. ':' .. flagValues['height'] .. ':y=' .. (flagValues['y'] or '-1') .. ':x=' .. (flagValues['x'] or '-1') .. ':color=black" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "scale=min(' .. + flagValues['width'] .. + '\\,iw*(min(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):min(' .. + flagValues['height'] .. + '\\,ih*(min(' .. + flagValues['width'] .. + '/iw\\,' .. + flagValues['height'] .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1,pad=' .. + flagValues['width'] .. + ':' .. + flagValues['height'] .. + ':y=' .. + (flagValues['y'] or '-1') .. + ':x=' .. (flagValues['x'] or '-1') .. ':color=black" -c:a copy ' .. preset .. cachedFilepath .. filename elseif (flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- simple scale (no aspect ratio) - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "scale=' .. flagValues['width'] .. ':' .. flagValues['height'] .. ':force_divisible_by=2:force_original_aspect_ratio=disable,setsar=1" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "scale=' .. + flagValues['width'] .. + ':' .. + flagValues['height'] .. + ':force_divisible_by=2:force_original_aspect_ratio=disable,setsar=1" -c:a copy ' .. + preset .. cachedFilepath .. filename elseif (flagValues['height'] ~= nil) then -- simple one-side scale (h) - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "scale=-1:' .. flagValues['height'] .. ':force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "scale=-1:' .. + flagValues['height'] .. + ':force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. + preset .. cachedFilepath .. filename elseif (flagValues['width'] ~= nil) then -- simple one-side scale (w) - command = config.ffmpeg .. ' -i ' .. originalFilepath .. filename .. ' -filter_complex "scale=' .. flagValues['width'] .. ':-1:force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. preset .. cachedFilepath .. filename - + command = config.ffmpeg .. + ' -i ' .. + originalFilepath .. + filename .. + ' -filter_complex "scale=' .. + flagValues['width'] .. + ':-1:force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. + preset .. cachedFilepath .. filename end local executeSuccess @@ -255,19 +354,19 @@ if cachedFile == nil then if config.serveOriginalOnTranscodeFailure == true then log('serving original from: ' .. originalFilepath .. filename) - ngx.exec('/luamp-cache', { luamp_cached_video_path = originalFilepath .. filename }) + ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath .. filename }) end else - -- check if transcoded file is > minimumTranscodedFileSize + -- check if transcoded file is > minimumTranscodedVideoSize -- we do this inside the transcoding `if` block to not mess with other threads local transcodedFile = io.open(cachedFilepath .. filename, 'rb') local transcodedFileSize = transcodedFile:seek('end') transcodedFile:close() - if transcodedFileSize > config.minimumTranscodedFileSize then + if transcodedFileSize > config.minimumTranscodedVideoSize then log('transcoded version is good, serving it') -- serve it - ngx.exec('/luamp-cache', { luamp_cached_video_path = cachedFilepath .. filename }) + ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath .. filename }) else log('transcoded version is corrupt') -- delete corrupt one @@ -276,7 +375,7 @@ if cachedFile == nil then -- serve original if config.serveOriginalOnTranscodeFailure == true then log('serving original') - ngx.exec('/luamp-cache', { luamp_cached_video_path = originalFilepath .. filename }) + ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath .. filename }) end end end @@ -285,4 +384,4 @@ else cachedFile:close() end -ngx.exec('/luamp-cache', { luamp_cached_video_path = cachedFilepath .. filename }) +ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath .. filename }) diff --git a/utils.lua b/utils.lua new file mode 100644 index 0000000..b06c33e --- /dev/null +++ b/utils.lua @@ -0,0 +1,34 @@ +local config = require('config') +local utils = {} + +-- Log helper function +---@param data any +function utils.log(data) + if config.logEnabled then + ngx.log(config.logLevel, data) + end +end + +-- Clean up path by allowing only specific characters +---@param path string +---@return string +---@return integer +function utils.cleanupPath(path) + local allowedChars = '[%w_%-/.=]' -- Allow alphanumeric + underscore + dash + slash + dot + local retVal = path:gsub('[^' .. allowedChars .. ']', '') + return retVal:gsub('([\\.][\\.]+)', '') -- Strip double+ dot +end + +-- Check if a file exists +---@param filepath string +---@return boolean +function utils.fileExists(filepath) + local file = io.open(filepath, 'r') + if file then + file:close() + return true + end + return false +end + +return utils From 06fa84ce2909fa7288d4f7aa35f3d757eea364e0 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 26 Jun 2023 15:26:47 +0800 Subject: [PATCH 02/53] Update readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a8d9813..69d175b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ https://user-images.githubusercontent.com/3368441/161866581-ee1c745c-f119-430c-8 - 🚧 webm support (output) ## Requirements + - OpenResty or nginx with ngx_http_lua_module enabled - [ffmpeg 5](https://launchpad.net/~savoury1/+archive/ubuntu/ffmpeg5) installed - [time](https://en.wikipedia.org/wiki/Time_(Unix)) utility if you have `config.logTime` enabled @@ -246,7 +247,7 @@ $ which ffmpeg Where to redirect `ffmpeg` output if `config.logFfmpegOutput` is set to false. -For \*nix (default value): +For *nix (default value): ``` config.ffmpegDevNull = '2> /dev/null' -- nix ``` From f5d3dfca1fc678d78a498c0014c6121ae1ecf43c Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 12 Jul 2023 13:16:29 +0700 Subject: [PATCH 03/53] Add background blur and fixes --- config.lua.example | 6 +- nginx-lua-image.lua | 184 ++++++++++++++++++++++++++------------------ utils.lua | 12 +++ 3 files changed, 122 insertions(+), 80 deletions(-) diff --git a/config.lua.example b/config.lua.example index 6560791..62ac806 100644 --- a/config.lua.example +++ b/config.lua.example @@ -49,12 +49,13 @@ config.flagMap = { -- eg `['padded'] = 'pad'` to use `padded` instead of the default `pad` -- Also, all flag values not present in this table will be considered (and cast to) a number config.flagValueMap = { - blurred = 'blur', -- crop params pad = 'padding', lpad = 'limited_padding', fill = 'fill', -- background params + auto = 'auto', + blurred = 'blur', black = 'black', white = 'white', red = 'red', @@ -120,9 +121,6 @@ config.serveOriginalOnTranscodeFailure = true -- least required size (in bytes) for the transcoded file to not be considered broken and deleted (default is 1KB) config.minimumTranscodedFileSize = 1024 --- least required size (in bytes) for the transcoded file to not be considered broken and deleted (default is 1B) -config.minimumTranscodedImageSize = 1 - -- encoding preset to use https://trac.ffmpeg.org/wiki/Encode/H.264 config.ffmpegPreset = '' -- config.ffmpegPreset = 'ultrafast' diff --git a/nginx-lua-image.lua b/nginx-lua-image.lua index 1fce183..a500b50 100644 --- a/nginx-lua-image.lua +++ b/nginx-lua-image.lua @@ -6,7 +6,6 @@ log('luamp started') -- Set missing config options to the defaults config.setDefaults({ - minimumTranscodedImageSize = 1024, serveOriginalOnTranscodeFailure = true, }) @@ -25,37 +24,75 @@ log('filename: ' .. filename) local flags = { background = { enabled = true, - value = 'white' + default = 'white', + value = nil, }, crop = { enabled = true, - value = nil + default = nil, + value = nil, }, dpr = { enabled = true, - value = 1 + default = 1, + value = nil, }, gravity = { enabled = true, - value = 'center' + default = 'center', + value = nil, }, height = { enabled = true, - value = nil + default = nil, + value = nil, }, width = { enabled = true, - value = nil + default = nil, + value = nil, }, x = { enabled = true, - value = 0 + default = 0, + value = nil, }, y = { enabled = true, - value = 0 - } + default = 0, + value = nil, + }, } +-- Get flag value +---@param f string +---@return any? +local function getFlagValue(f) + return flags[f].value or flags[f].default +end + +-- Apply limits to a given dimension +---@param d string|number|nil +---@param dpr string|number|nil +---@param maxValue string|number|nil +---@return number? +local function limitDimension(d, dpr, maxValue) + if d and dpr and maxValue then + local dNum = tonumber(d) + local dprNum = tonumber(dpr) + local maxValueNum = tonumber(maxValue) + local dimension = dNum and dprNum and math.ceil(dNum * dprNum) + if dimension and maxValueNum and dimension > maxValueNum then + log('Resulting dimension exceeds configured limit, capping it at ' .. maxValueNum) + return maxValueNum + end + + return dimension + end + + log('limitDimension: invalid params') + return nil +end + local flagsOrdered = {} -- Add the flag name to the ordered list @@ -80,20 +117,6 @@ for flag, value in string.gmatch(luamp_flags, '(%w+)' .. config.flagValueDelimit end end --- Apply limits to height and width if specified -local maxImageHeight = config.maxImageHeight -local maxImageWidth = config.maxImageWidth - -if flags.height.value and maxImageHeight and flags.height.value > maxImageHeight then - log('Resulting height exceeds configured limit, capping it at ' .. maxImageHeight) - flags.height.value = maxImageHeight -end - -if flags.width.value and maxImageWidth and flags.width.value > maxImageWidth then - log('Resulting width exceeds configured limit, capping it at ' .. maxImageWidth) - flags.width.value = maxImageWidth -end - -- Coalesce flag values. All flag values are set at this moment local function coalesceFlag(option) local flag = flags[option] @@ -148,9 +171,13 @@ if not utils.fileExists(originalFilepath) then log('Downloaded original, saving') os.execute('mkdir -p ' .. originalDir) local originalFile = io.open(originalFilepath, 'w') - originalFile:write(originalReq.body) - originalFile:close() - log('Saved to ' .. originalFilepath) + if originalFile then + originalFile:write(originalReq.body) + originalFile:close() + log('Saved to ' .. originalFilepath) + else + log('File not found ' .. originalFilepath) + end else ngx.exit(ngx.HTTP_NOT_FOUND) end @@ -164,41 +191,68 @@ log('Original is present on local FS. Transcoding to ' .. cachedFilepath) -- Create cached transcoded file os.execute('mkdir -p ' .. cacheDir) --- Build the convert command -local background = flags.background.value -local crop = flags.crop.value -local gravity = flags.gravity.value -local height = flags.height.value and math.ceil(flags.height.value * flags.dpr.value) -local width = flags.width.value and math.ceil(flags.width.value * flags.dpr.value) -local x = flags.x.value -local y = flags.y.value -local convertCommand = config.magick .. - ' ' .. originalFilepath .. - ' -background ' .. background .. - ' -gravity ' .. gravity - -if crop and width and height then - if crop == 'fill' then +local background = getFlagValue('background') +local crop = getFlagValue('crop') +local gravity = getFlagValue('gravity') +local x = getFlagValue('x') +local y = getFlagValue('y') +local dpr = getFlagValue('dpr') +local width = limitDimension(getFlagValue('width'), dpr, config.maxImageHeight) +local height = limitDimension(getFlagValue('height'), dpr, config.maxImageHeight) + +local convertCommand = config.magick + +if gravity then + convertCommand = convertCommand .. ' -gravity ' .. gravity +end + +-- Create Canvas +if background == 'auto' then + local cmd = config.magick .. ' ' .. originalFilepath .. + ' -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + + local dominantColors = utils.captureCommandOutput(cmd) + + convertCommand = convertCommand .. + ' -size $(identify -ping -format "%wx%h" ' .. originalFilepath .. ')' .. + ' gradient:' .. dominantColors +else + convertCommand = convertCommand .. + ' -size $(identify -ping -format "%wx%h" ' .. originalFilepath .. ')' .. + ' xc:' .. (background or '') +end + +if width or height then + local dimensions = (width or '') .. 'x' .. (height or '') + local resizeFlag = (width and height and '!') or '' + + if crop == 'padding' then convertCommand = convertCommand .. - ' -resize ' .. width .. 'x' .. height .. '^' .. - ' -crop ' .. width .. 'x' .. height .. '+' .. x .. '+' .. y + ' -resize ' .. dimensions .. resizeFlag .. ' ' .. + originalFilepath .. ' -resize ' .. dimensions .. + ' -composite' end if crop == 'limited_padding' then convertCommand = convertCommand .. - ' -resize ' .. '"' .. width .. 'x' .. height .. '>"' .. - ' -extent ' .. width .. 'x' .. height + ' -resize ' .. dimensions .. resizeFlag .. ' ' .. + originalFilepath .. ' -resize ' .. dimensions .. '\\>' .. + ' -composite' end - if crop == 'padding' then - convertCommand = convertCommand .. - ' -resize ' .. width .. 'x' .. height .. - ' -extent ' .. width .. 'x' .. height + if crop == 'fill' then + convertCommand = convertCommand .. ' ' .. + originalFilepath .. ' -resize ' .. dimensions .. '^' .. + ' -composite' .. + ' -crop ' .. dimensions .. '+' .. x .. '+' .. y + end + + if crop == nil then + convertCommand = convertCommand .. ' ' .. + originalFilepath .. + ' -composite' .. + ' -resize ' .. dimensions .. resizeFlag end -elseif width and height then - convertCommand = convertCommand .. ' -resize ' .. width .. 'x' .. height .. '!' -elseif width or height then - convertCommand = convertCommand .. ' -resize ' .. (width or '') .. 'x' .. (height or '') end -- Append the output filepath to the convert command @@ -222,26 +276,4 @@ if executeSuccess == nil then log('Serving original from: ' .. originalFilepath) ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath }) end -else - -- Check if transcoded file is > minimumTranscodedImageSize - -- We do this inside the transcoding `if` block to not mess with other threads - local transcodedFile = io.open(cachedFilepath, 'rb') - local transcodedFileSize = transcodedFile:seek('end') - transcodedFile:close() - - if transcodedFileSize > config.minimumTranscodedImageSize then - log('Transcoded version is good, serving it') - -- Serve it - ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath }) - else - log('Transcoded version is corrupt') - -- Delete corrupt one - os.remove(cachedFilepath) - - -- Serve original - if config.serveOriginalOnTranscodeFailure == true then - log('Serving original') - ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath }) - end - end end diff --git a/utils.lua b/utils.lua index b06c33e..45e0d8c 100644 --- a/utils.lua +++ b/utils.lua @@ -31,4 +31,16 @@ function utils.fileExists(filepath) return false end +-- Function to capture command output +---@param cmd string +---@return any +function utils.captureCommandOutput(cmd) + local file = io.popen(cmd) + if file then + local output = file:read('*a') + file:close() + return output + end +end + return utils From 3f1ce166f163d4e60a4ebc6b0f1346cf9da75bcd Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 12 Jul 2023 13:26:12 +0700 Subject: [PATCH 04/53] Refactor --- nginx-lua-image.lua | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/nginx-lua-image.lua b/nginx-lua-image.lua index a500b50..5c63b99 100644 --- a/nginx-lua-image.lua +++ b/nginx-lua-image.lua @@ -207,19 +207,17 @@ if gravity then end -- Create Canvas +convertCommand = convertCommand .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilepath .. ')' if background == 'auto' then + -- Get 2 dominant colors in format 'x000000-x000000' local cmd = config.magick .. ' ' .. originalFilepath .. ' -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' local dominantColors = utils.captureCommandOutput(cmd) - convertCommand = convertCommand .. - ' -size $(identify -ping -format "%wx%h" ' .. originalFilepath .. ')' .. - ' gradient:' .. dominantColors + convertCommand = convertCommand .. ' gradient:' .. dominantColors else - convertCommand = convertCommand .. - ' -size $(identify -ping -format "%wx%h" ' .. originalFilepath .. ')' .. - ' xc:' .. (background or '') + convertCommand = convertCommand .. ' xc:' .. (background or '') end if width or height then From 383b31cfe14f68d8249094f53412422cb255a4d8 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 12 Jul 2023 18:24:06 +0700 Subject: [PATCH 05/53] Increase saturation --- nginx-lua-image.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nginx-lua-image.lua b/nginx-lua-image.lua index 5c63b99..c75627f 100644 --- a/nginx-lua-image.lua +++ b/nginx-lua-image.lua @@ -211,7 +211,7 @@ convertCommand = convertCommand .. ' -size $(identify -ping -format "%wx%h" ' .. if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' local cmd = config.magick .. ' ' .. originalFilepath .. - ' -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' local dominantColors = utils.captureCommandOutput(cmd) @@ -227,27 +227,27 @@ if width or height then if crop == 'padding' then convertCommand = convertCommand .. ' -resize ' .. dimensions .. resizeFlag .. ' ' .. - originalFilepath .. ' -resize ' .. dimensions .. + originalFilepath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. ' -composite' end if crop == 'limited_padding' then convertCommand = convertCommand .. ' -resize ' .. dimensions .. resizeFlag .. ' ' .. - originalFilepath .. ' -resize ' .. dimensions .. '\\>' .. + originalFilepath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '\\>' .. ' -composite' end if crop == 'fill' then convertCommand = convertCommand .. ' ' .. - originalFilepath .. ' -resize ' .. dimensions .. '^' .. + originalFilepath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '^' .. ' -composite' .. ' -crop ' .. dimensions .. '+' .. x .. '+' .. y end if crop == nil then convertCommand = convertCommand .. ' ' .. - originalFilepath .. + originalFilepath .. ' -modulate 100,120,100' .. ' -composite' .. ' -resize ' .. dimensions .. resizeFlag end From af83a898de32e65f34bb0804c6d19e124c6f573c Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 31 Jul 2023 19:07:25 +0700 Subject: [PATCH 06/53] Refactoring --- command.lua | 137 ++++++++++++++++++++++ config.lua.example | 15 +++ file.lua | 83 +++++++++++++ flag.lua | 58 ++++++++++ media-processor.lua | 155 +++++++++++++++++++++++++ nginx-lua-image.lua | 277 -------------------------------------------- nginx-lua-mp4.lua | 9 +- utils.lua | 21 ---- 8 files changed, 456 insertions(+), 299 deletions(-) create mode 100644 command.lua create mode 100644 file.lua create mode 100644 flag.lua create mode 100644 media-processor.lua delete mode 100644 nginx-lua-image.lua diff --git a/command.lua b/command.lua new file mode 100644 index 0000000..78baf57 --- /dev/null +++ b/command.lua @@ -0,0 +1,137 @@ +local File = require('file') +local utils = require('utils') + +local Command = {} + +-- Build image processing command +---@param config table +---@param file table +---@param flags table +---@return string +local function buildImageProcessingCommand(config, file, flags) + local cacheDir = file.cacheDir + local cachedFilePath = file.cachedFilePath + local originalFilePath = file.originalFilePath + + local background = flags.background.value + local crop = flags.crop.value + local gravity = flags.gravity.value + local x = flags.x.value + local y = flags.y.value + local width = flags.width.value + local height = flags.height.value + + -- Create cached transcoded file + os.execute('mkdir -p ' .. cacheDir) + + -- Construct a command + local command = config.magick + + if gravity then + command = command .. ' -gravity ' .. gravity + end + + -- Create Canvas + command = command .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilePath .. ')' + if background == 'auto' then + -- Get 2 dominant colors in format 'x000000-x000000' + local cmd = config.magick .. ' ' .. originalFilePath .. + ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + + local dominantColors = utils.captureCommandOutput(cmd) + + command = command .. ' gradient:' .. dominantColors + else + command = command .. ' xc:' .. (background or '') + end + + if width or height then + local dimensions = (width or '') .. 'x' .. (height or '') + local resizeFlag = (width and height and '!') or '' + + if crop == 'padding' then + command = command .. + ' -resize ' .. dimensions .. resizeFlag .. ' ' .. + originalFilePath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. + ' -composite' + end + + if crop == 'limited_padding' then + command = command .. + ' -resize ' .. dimensions .. resizeFlag .. ' ' .. + originalFilePath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '\\>' .. + ' -composite' + end + + if crop == 'fill' then + command = command .. ' ' .. + originalFilePath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '^' .. + ' -composite' .. + ' -crop ' .. dimensions .. '+' .. x .. '+' .. y + end + + if crop == nil then + command = command .. ' ' .. + originalFilePath .. ' -modulate 100,120,100' .. + ' -composite' .. + ' -resize ' .. dimensions .. resizeFlag + end + end + + -- Append the output filepath to the convert command + command = command .. ' ' .. cachedFilePath + + if config.logTime then + command = 'time ' .. command + end + + return command +end + +-- Build video processing command +---@param config table +---@param file table +---@param flags table +---@return string +local function buildVideoProcessingCommand(config, file, flags) + return '' +end + +-- Build command +---@param config table +---@param file table +---@param flags table +---@return string +local function buildCommand(config, file, flags) + if file.mediaType == File.IMAGE_TYPE then + return buildImageProcessingCommand(config, file, flags) + end + + if file.mediaType == File.VIDEO_TYPE then + return buildVideoProcessingCommand(config, file, flags) + end + + return '' +end + +-- Base class method new +---@param config table +---@param file table +---@param flags table +function Command.new(config, file, flags) + local self = { + command = buildCommand(config, file, flags), + } + setmetatable(self, { __index = Command }) + return self +end + +-- Execute command +---@return boolean? +function Command:execute() + if self.command ~= '' then + return os.execute(self.command) + end +end + +return Command diff --git a/config.lua.example b/config.lua.example index 62ac806..37514c7 100644 --- a/config.lua.example +++ b/config.lua.example @@ -1,3 +1,5 @@ +local Flag = require('flag') + config = {} -- ########## CONFIG ########## @@ -44,6 +46,19 @@ config.flagMap = { y = 'y', } +config.flagVideoMap = config.flagMap + +config.flagImageMap = { + b = Flag.IMAGE_BACKGROUND_NAME, + c = Flag.IMAGE_CROP_NAME, + dpr = Flag.IMAGE_DPR_NAME, + g = Flag.IMAGE_GRAVITY_NAME, + x = Flag.IMAGE_X_NAME, + y = Flag.IMAGE_Y_NAME, + h = Flag.IMAGE_HEIGHT_NAME, + w = Flag.IMAGE_WIDTH_NAME, +} + -- override URL flag values. Useful when you migrate from another transcoding solution and already have -- some flag values in use on the front end. Customize the left part of the table -- eg `['padded'] = 'pad'` to use `padded` instead of the default `pad` diff --git a/file.lua b/file.lua new file mode 100644 index 0000000..e266bdb --- /dev/null +++ b/file.lua @@ -0,0 +1,83 @@ +local File = {} + +File.IMAGE_TYPE = 'image' +File.VIDEO_TYPE = 'video' + +-- Coalesce flag values. All flag values are set at this moment +---@param flag table +---@return string +local function coalesceFlag(flag) + if flag and flag.value and flag.value ~= '' then + return flag.name .. '_' .. flag.value + end + return '' +end + +---Build cache dir path +---@param basePath string +---@param prefix string +---@param postfix string +---@param flags table +---@return string +local function buildCacheDirPath(basePath, prefix, postfix, flags) + local flagNamesOrdered = {} + + -- Add the flag name to the ordered list + for flagName, _ in pairs(flags) do + table.insert(flagNamesOrdered, flagName) + end + -- Sort flags so path will be the same for `w_1280,h_960` and `h_960,w_1280` + table.sort(flagNamesOrdered) + + -- Generate the options path + local optionsPath = '' + + for _, flagName in ipairs(flagNamesOrdered) do + local pathFragment = coalesceFlag(flags[flagName]) + if pathFragment ~= '' then + optionsPath = optionsPath .. pathFragment .. '/' + end + end + + return basePath .. prefix .. optionsPath .. postfix +end + +-- Base class method new +function File.new(config, prefix, postfix, filename, mediaType, flags) + local self = {} + self.config = config + self.mediaType = mediaType + self.filename = filename + self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags) + self.cachedFilePath = self.cacheDir .. filename + self.originalDir = config.mediaBaseFilepath .. prefix .. postfix + self.originalFilePath = self.originalDir .. filename + setmetatable(self, { __index = File }) + return self +end + +---Checks file is cached +---@return boolean +function File:isCached() + return File.fileExists(self.cachedFilePath) +end + +---Checks file has original +---@return boolean +function File:hasOriginal() + return File.fileExists(self.originalFilePath) +end + +-- Check if a file exists +---@param path string +---@return boolean +function File.fileExists(path) + local f = io.open(path, 'r') + if f then + f:close() + return true + end + return false +end + +return File diff --git a/flag.lua b/flag.lua new file mode 100644 index 0000000..44dfb7c --- /dev/null +++ b/flag.lua @@ -0,0 +1,58 @@ +local Flag = {} + +Flag.IMAGE_BACKGROUND_NAME = 'background' +Flag.IMAGE_CROP_NAME = 'crop' +Flag.IMAGE_DPR_NAME = 'dpr' +Flag.IMAGE_GRAVITY_NAME = 'gravity' +Flag.IMAGE_X_NAME = 'x' +Flag.IMAGE_Y_NAME = 'y' +Flag.IMAGE_HEIGHT_NAME = 'height' +Flag.IMAGE_WIDTH_NAME = 'width' + +local IMAGE_DEFAULTS = { + [Flag.IMAGE_BACKGROUND_NAME] = 'white', + [Flag.IMAGE_DPR_NAME] = 1, + [Flag.IMAGE_GRAVITY_NAME] = 'center', + [Flag.IMAGE_X_NAME] = 0, + [Flag.IMAGE_Y_NAME] = 0, +} + +-- Base class method new +function Flag.new(name, value) + local self = {} + self.name = name + self.value = value or IMAGE_DEFAULTS[name] + + setmetatable(self, { __index = Flag }) + return self +end + +-- Derived class method setValue +---@param value string | number +---@param valueMapper string | number +function Flag:setValue(value, valueMapper) + if value and value ~= '' then + -- Check if it is an allowed text flag or cast to a number + self.value = valueMapper[value] or tonumber(value) + end +end + +-- Apply limits to a given dimension +---@param d number +---@param dpr number +---@param max number +---@return number? +function Flag.limitDimension(d, dpr, max) + if d and dpr and max then + local dimension = d and dpr and math.ceil(d * dpr) + if dimension > max then + return max + end + + return dimension + end + + return nil +end + +return Flag diff --git a/media-processor.lua b/media-processor.lua new file mode 100644 index 0000000..99a6534 --- /dev/null +++ b/media-processor.lua @@ -0,0 +1,155 @@ +local config = require('config') +local Flag = require('flag') +local File = require('file') +local Command = require('command') +local utils = require('utils') + +-- Log function +---@param data any +local function log(data) + if config.logEnabled then + ngx.log(config.logLevel, data) + end +end + +---Proceed cached file +---@param file table +local function proceedCashed(file) + log('Serving cached file: ' .. file.cachedFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) +end + +---Proceed file on transcode failure +---@param file table +local function proceedOnTranscodeFailure(file) + log('Serving original from: ' .. file.originalFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) +end + +---Download original form upstream +---@param prefix string +---@param postfix string +---@param file table +local function downloadOriginals(prefix, postfix, file) + local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, file.filename) + log('Downloading original from ' .. originalsUpstreamPath) + ngx.req.discard_body() -- Clear body + + log('Fetching') + local originalReq = ngx.location.capture('/luamp-upstream', + { vars = { luamp_original_file = originalsUpstreamPath } }) + log('Upstream status: ' .. originalReq.status) + + if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then + log('Downloaded original, saving') + os.execute('mkdir -p ' .. file.originalDir) + + local originalFile = io.open(file.originalFilePath, 'w') + originalFile:write(originalReq.body) + originalFile:close() + log('Saved to ' .. file.originalFilePath) + else + ngx.exit(ngx.HTTP_NOT_FOUND) + end +end + +local function main() + log('luamp started') + + -- Set missing config options to the defaults + config.setDefaults({ + minimumTranscodedVideoSize = 1024, + serveOriginalOnTranscodeFailure = true, + ffmpegPreset = '' + }) + + -- Get URL params + local mediaType = ngx.var.luamp_media_type + local prefix = utils.cleanupPath(ngx.var.luamp_prefix) + local luamp_flags = ngx.var.luamp_flags + local postfix = utils.cleanupPath(ngx.var.luamp_postfix) + local filename = utils.cleanupPath(ngx.var.luamp_filename) + + log('media type: ' .. mediaType) + log('prefix: ' .. prefix) + log('flags: ' .. luamp_flags) + log('postfix: ' .. postfix) + log('filename: ' .. filename) + + local flags = {} + local flagMapper = {} + local valueMapper = {} + + if mediaType == File.IMAGE_TYPE then + flags = { + background = Flag.new(Flag.IMAGE_BACKGROUND_NAME), + crop = Flag.new(Flag.IMAGE_CROP_NAME), + dpr = Flag.new(Flag.IMAGE_DPR_NAME), + gravity = Flag.new(Flag.IMAGE_GRAVITY_NAME), + x = Flag.new(Flag.IMAGE_X_NAME), + y = Flag.new(Flag.IMAGE_Y_NAME), + height = Flag.new(Flag.IMAGE_HEIGHT_NAME), + width = Flag.new(Flag.IMAGE_WIDTH_NAME) + } + flagMapper = config.flagImageMap + valueMapper = config.flagValueMap + elseif mediaType == File.VIDEO_TYPE then + flags = {} + flagMapper = config.flagMap + valueMapper = config.flagValueMap + else + ngx.exit(ngx.HTTP_BAD_REQUEST) + end + + -- Parse flags into a table + for f, v in string.gmatch(luamp_flags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do + -- Preprocess the flag and value if necessary + if config.flagPreprocessHook then + f, v = config.flagPreprocessHook(f, v) + end + + local flag = flags[flagMapper[f]] + + -- Set value if flag exists + if flag then + flag:setValue(v, valueMapper) + end + end + + local file = File.new(config, prefix, postfix, filename, mediaType, flags) + + -- Serve the cached file if it exists + if file:isCached() then + proceedCashed(file) + end + + -- If the cached file doesn't exist, process the original file + log('Cached file not found: ' .. file.cachedFilePath) + + -- Check if the original file exists + if not file:hasOriginal() then + log('Original file not found: ' .. file.originalFilePath) + + if config.downloadOriginals then + -- Download original if upstream download is enabled + downloadOriginals(prefix, postfix, file) + else + ngx.exit(ngx.HTTP_NOT_FOUND) + end + end + + log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) + local command = Command.new(config, file, flags) + log('Command: ' .. command.command) + local executeSuccess = command:execute() + + if executeSuccess == nil then + log('Transcode failed') + + if config.serveOriginalOnTranscodeFailure == true then + proceedOnTranscodeFailure(file) + end + end +end + +main() diff --git a/nginx-lua-image.lua b/nginx-lua-image.lua deleted file mode 100644 index c75627f..0000000 --- a/nginx-lua-image.lua +++ /dev/null @@ -1,277 +0,0 @@ -local config = require('config') -local utils = require('utils') -local log = utils.log - -log('luamp started') - --- Set missing config options to the defaults -config.setDefaults({ - serveOriginalOnTranscodeFailure = true, -}) - --- Get URL params -local prefix = utils.cleanupPath(ngx.var.luamp_prefix) -local luamp_flags = ngx.var.luamp_flags -local postfix = utils.cleanupPath(ngx.var.luamp_postfix) -local filename = utils.cleanupPath(ngx.var.luamp_filename) - -log('prefix: ' .. prefix) -log('flags: ' .. luamp_flags) -log('postfix: ' .. postfix) -log('filename: ' .. filename) - --- Enabled flags with defaults -local flags = { - background = { - enabled = true, - default = 'white', - value = nil, - }, - crop = { - enabled = true, - default = nil, - value = nil, - }, - dpr = { - enabled = true, - default = 1, - value = nil, - }, - gravity = { - enabled = true, - default = 'center', - value = nil, - }, - height = { - enabled = true, - default = nil, - value = nil, - }, - width = { - enabled = true, - default = nil, - value = nil, - }, - x = { - enabled = true, - default = 0, - value = nil, - }, - y = { - enabled = true, - default = 0, - value = nil, - }, -} --- Get flag value ----@param f string ----@return any? -local function getFlagValue(f) - return flags[f].value or flags[f].default -end - --- Apply limits to a given dimension ----@param d string|number|nil ----@param dpr string|number|nil ----@param maxValue string|number|nil ----@return number? -local function limitDimension(d, dpr, maxValue) - if d and dpr and maxValue then - local dNum = tonumber(d) - local dprNum = tonumber(dpr) - local maxValueNum = tonumber(maxValue) - local dimension = dNum and dprNum and math.ceil(dNum * dprNum) - if dimension and maxValueNum and dimension > maxValueNum then - log('Resulting dimension exceeds configured limit, capping it at ' .. maxValueNum) - return maxValueNum - end - - return dimension - end - - log('limitDimension: invalid params') - return nil -end - -local flagsOrdered = {} - --- Add the flag name to the ordered list -for flag, _ in pairs(flags) do - table.insert(flagsOrdered, flag) -end --- Sort flags so path will be the same for `w_1280,h_960` and `h_960,w_1280` -table.sort(flagsOrdered) - --- Parse flags into a table -for flag, value in string.gmatch(luamp_flags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do - local flagMapped = config.flagMap[flag] - -- Check if the flag is enabled - if value and flags[flagMapped] and flags[flagMapped].enabled then - -- Preprocess the flag and value if necessary - if config.flagPreprocessHook then - flag, value = config.flagPreprocessHook(flag, value) - end - - -- Check if it is an allowed text flag or cast to a number - flags[flagMapped].value = config.flagValueMap[value] or tonumber(value) - end -end - --- Coalesce flag values. All flag values are set at this moment -local function coalesceFlag(option) - local flag = flags[option] - if flag and flag.value and flag.value ~= '' then - return option .. '_' .. flag.value - end - return '' -end - --- Generate the options path -local optionsPath = '' - -for _, option in ipairs(flagsOrdered) do - local pathFragment = coalesceFlag(option) - if pathFragment ~= '' then - optionsPath = optionsPath .. pathFragment .. '/' - end -end - --- Check if we already have a cached version of the file -local cacheDir = config.mediaBaseFilepath .. prefix .. optionsPath .. postfix -local cachedFilepath = cacheDir .. filename - --- Serve the cached file if it exists -if utils.fileExists(cachedFilepath) then - log('Serving cached file: ' .. cachedFilepath) - ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath }) - return -end - -log('Cached file not found: ' .. cachedFilepath) - --- If the cached file doesn't exist, process the original file -local originalDir = config.mediaBaseFilepath .. prefix .. postfix -local originalFilepath = originalDir .. filename - --- Check if the original file exists -if not utils.fileExists(originalFilepath) then - log('Original file not found: ' .. originalFilepath) - - if config.downloadOriginals then - -- Download original if upstream download is enabled - local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, filename) - log('Downloading original from ' .. originalsUpstreamPath) - ngx.req.discard_body() -- Clear body - log('Fetching') - local originalReq = ngx.location.capture('/luamp-upstream', - { vars = { luamp_original_file = originalsUpstreamPath } }) - log('Upstream status: ' .. originalReq.status) - - if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then - log('Downloaded original, saving') - os.execute('mkdir -p ' .. originalDir) - local originalFile = io.open(originalFilepath, 'w') - if originalFile then - originalFile:write(originalReq.body) - originalFile:close() - log('Saved to ' .. originalFilepath) - else - log('File not found ' .. originalFilepath) - end - else - ngx.exit(ngx.HTTP_NOT_FOUND) - end - else - ngx.exit(ngx.HTTP_NOT_FOUND) - end -end - -log('Original is present on local FS. Transcoding to ' .. cachedFilepath) - --- Create cached transcoded file -os.execute('mkdir -p ' .. cacheDir) - -local background = getFlagValue('background') -local crop = getFlagValue('crop') -local gravity = getFlagValue('gravity') -local x = getFlagValue('x') -local y = getFlagValue('y') -local dpr = getFlagValue('dpr') -local width = limitDimension(getFlagValue('width'), dpr, config.maxImageHeight) -local height = limitDimension(getFlagValue('height'), dpr, config.maxImageHeight) - -local convertCommand = config.magick - -if gravity then - convertCommand = convertCommand .. ' -gravity ' .. gravity -end - --- Create Canvas -convertCommand = convertCommand .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilepath .. ')' -if background == 'auto' then - -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. originalFilepath .. - ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' - - local dominantColors = utils.captureCommandOutput(cmd) - - convertCommand = convertCommand .. ' gradient:' .. dominantColors -else - convertCommand = convertCommand .. ' xc:' .. (background or '') -end - -if width or height then - local dimensions = (width or '') .. 'x' .. (height or '') - local resizeFlag = (width and height and '!') or '' - - if crop == 'padding' then - convertCommand = convertCommand .. - ' -resize ' .. dimensions .. resizeFlag .. ' ' .. - originalFilepath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. - ' -composite' - end - - if crop == 'limited_padding' then - convertCommand = convertCommand .. - ' -resize ' .. dimensions .. resizeFlag .. ' ' .. - originalFilepath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '\\>' .. - ' -composite' - end - - if crop == 'fill' then - convertCommand = convertCommand .. ' ' .. - originalFilepath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '^' .. - ' -composite' .. - ' -crop ' .. dimensions .. '+' .. x .. '+' .. y - end - - if crop == nil then - convertCommand = convertCommand .. ' ' .. - originalFilepath .. ' -modulate 100,120,100' .. - ' -composite' .. - ' -resize ' .. dimensions .. resizeFlag - end -end - --- Append the output filepath to the convert command -convertCommand = convertCommand .. ' ' .. cachedFilepath - -local executeSuccess - -if convertCommand ~= nil then - if config.logTime then - convertCommand = 'time ' .. convertCommand - end - - log('Command: ' .. convertCommand) - executeSuccess = os.execute(convertCommand) -end - -if executeSuccess == nil then - log('Transcode failed') - - if config.serveOriginalOnTranscodeFailure == true then - log('Serving original from: ' .. originalFilepath) - ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath }) - end -end diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 6d0275b..6ead3bd 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -1,6 +1,13 @@ local config = require('config') local utils = require('utils') -local log = utils.log + +-- Log function +---@param data any +local function log(data) + if config.logEnabled then + ngx.log(config.logLevel, data) + end +end log('luamp started') diff --git a/utils.lua b/utils.lua index 45e0d8c..0a912cc 100644 --- a/utils.lua +++ b/utils.lua @@ -1,14 +1,5 @@ -local config = require('config') local utils = {} --- Log helper function ----@param data any -function utils.log(data) - if config.logEnabled then - ngx.log(config.logLevel, data) - end -end - -- Clean up path by allowing only specific characters ---@param path string ---@return string @@ -19,18 +10,6 @@ function utils.cleanupPath(path) return retVal:gsub('([\\.][\\.]+)', '') -- Strip double+ dot end --- Check if a file exists ----@param filepath string ----@return boolean -function utils.fileExists(filepath) - local file = io.open(filepath, 'r') - if file then - file:close() - return true - end - return false -end - -- Function to capture command output ---@param cmd string ---@return any From f3f08bde6c714fc8dd9a4e02fa868f05b5d7f1c0 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 1 Aug 2023 13:27:41 +0700 Subject: [PATCH 07/53] Refactoring --- flag.lua | 17 ++++++----------- media-processor.lua | 6 ++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/flag.lua b/flag.lua index 44dfb7c..051619c 100644 --- a/flag.lua +++ b/flag.lua @@ -38,21 +38,16 @@ function Flag:setValue(value, valueMapper) end -- Apply limits to a given dimension ----@param d number ---@param dpr number ---@param max number ----@return number? -function Flag.limitDimension(d, dpr, max) - if d and dpr and max then - local dimension = d and dpr and math.ceil(d * dpr) - if dimension > max then - return max - end +function Flag:scaleDimension(dpr, max) + if (self.name == Flag.IMAGE_HEIGHT_NAME or self.name == Flag.IMAGE_WIDTH_NAME) and self.value and dpr then + self.value = math.ceil(self.value * dpr) - return dimension + if self.value > max then + self.value = max + end end - - return nil end return Flag diff --git a/media-processor.lua b/media-processor.lua index 99a6534..45f8585 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -116,6 +116,12 @@ local function main() end end + -- Scale dimensions with respect to limits + local maxHeight = (mediaType == File.IMAGE_TYPE and config.maxImageHeight) or config.maxVideoHeight + local maxWidth = (mediaType == File.IMAGE_TYPE and config.maxImageWidth) or config.maxVideoWidth + flags.height:scaleDimension(flags.dpr.value, maxHeight) + flags.width:scaleDimension(flags.dpr.value, maxWidth) + local file = File.new(config, prefix, postfix, filename, mediaType, flags) -- Serve the cached file if it exists From 67f83ce155b890ca2e12d8ea9122ab6074996afc Mon Sep 17 00:00:00 2001 From: Evgeniy Chekan Date: Tue, 8 Aug 2023 20:06:20 +0200 Subject: [PATCH 08/53] fix log --- config.lua.example | 1 + log.lua | 9 +++++++++ media-processor.lua | 2 ++ nginx-lua-mp4.lua | 9 +-------- 4 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 log.lua diff --git a/config.lua.example b/config.lua.example index 37514c7..47f84e6 100644 --- a/config.lua.example +++ b/config.lua.example @@ -1,4 +1,5 @@ local Flag = require('flag') +local log = require('log') config = {} diff --git a/log.lua b/log.lua new file mode 100644 index 0000000..1255292 --- /dev/null +++ b/log.lua @@ -0,0 +1,9 @@ +-- Log function +---@param data any +local function log(data) + if config.logEnabled then + ngx.log(config.logLevel, data) + end +end + +return log diff --git a/media-processor.lua b/media-processor.lua index 45f8585..dee6543 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -81,6 +81,7 @@ local function main() local valueMapper = {} if mediaType == File.IMAGE_TYPE then + log('MediaType is image') flags = { background = Flag.new(Flag.IMAGE_BACKGROUND_NAME), crop = Flag.new(Flag.IMAGE_CROP_NAME), @@ -94,6 +95,7 @@ local function main() flagMapper = config.flagImageMap valueMapper = config.flagValueMap elseif mediaType == File.VIDEO_TYPE then + log('MediaType is video') flags = {} flagMapper = config.flagMap valueMapper = config.flagValueMap diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 6ead3bd..44ebd5f 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -1,14 +1,7 @@ local config = require('config') +local log = require('log') local utils = require('utils') --- Log function ----@param data any -local function log(data) - if config.logEnabled then - ngx.log(config.logLevel, data) - end -end - log('luamp started') -- Set missing config options to the defaults From 6a63f961468c56d8c0528c86290a51697f8b9f31 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 9 Aug 2023 11:19:06 +0800 Subject: [PATCH 09/53] Update readme --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 69d175b..ea8f758 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,14 @@ location @luamp_process { # image location location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + set $luamp_media_type "image"; + + #pass to transcoder location + try_files $uri @luamp_media_processor; +} + +# image process/transcode location +location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; @@ -90,13 +98,7 @@ location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_ set $luamp_prefix ""; set $luamp_postfix ""; - #pass to transcoder location - try_files $uri @luamp_image_process; -} - -# image process/transcode location -location @luamp_image_process { - content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-image.lua"; + content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; } # cache location From 0c784e39d6e3f9a9ad3dba8ad5bd3cfdfda68d1e Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 9 Aug 2023 12:05:49 +0800 Subject: [PATCH 10/53] Return original if no flags passed --- command.lua | 50 ++++++++++++++++++++++++--------------------- media-processor.lua | 7 +++++-- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/command.lua b/command.lua index 78baf57..8a18179 100644 --- a/command.lua +++ b/command.lua @@ -21,31 +21,35 @@ local function buildImageProcessingCommand(config, file, flags) local width = flags.width.value local height = flags.height.value - -- Create cached transcoded file - os.execute('mkdir -p ' .. cacheDir) -- Construct a command - local command = config.magick + local command + if width or height then + -- Create cached transcoded file + os.execute('mkdir -p ' .. cacheDir) - if gravity then - command = command .. ' -gravity ' .. gravity - end + --- Init with processor + command = config.magick - -- Create Canvas - command = command .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilePath .. ')' - if background == 'auto' then - -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. originalFilePath .. - ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + if gravity then + command = command .. ' -gravity ' .. gravity + end - local dominantColors = utils.captureCommandOutput(cmd) + -- Create Canvas + command = command .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilePath .. ')' + if background == 'auto' then + -- Get 2 dominant colors in format 'x000000-x000000' + local cmd = config.magick .. ' ' .. originalFilePath .. + ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' - command = command .. ' gradient:' .. dominantColors - else - command = command .. ' xc:' .. (background or '') - end + local dominantColors = utils.captureCommandOutput(cmd) - if width or height then + command = command .. ' gradient:' .. dominantColors + else + command = command .. ' xc:' .. (background or '') + end + + -- Crop and resize local dimensions = (width or '') .. 'x' .. (height or '') local resizeFlag = (width and height and '!') or '' @@ -76,12 +80,12 @@ local function buildImageProcessingCommand(config, file, flags) ' -composite' .. ' -resize ' .. dimensions .. resizeFlag end - end - -- Append the output filepath to the convert command - command = command .. ' ' .. cachedFilePath + -- Append the output filepath to the convert command + command = command .. ' ' .. cachedFilePath + end - if config.logTime then + if command and config.logTime then command = 'time ' .. command end @@ -129,7 +133,7 @@ end -- Execute command ---@return boolean? function Command:execute() - if self.command ~= '' then + if self.command and self.command ~= '' then return os.execute(self.command) end end diff --git a/media-processor.lua b/media-processor.lua index 45f8585..2c2cf80 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -146,8 +146,11 @@ local function main() log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local command = Command.new(config, file, flags) - log('Command: ' .. command.command) - local executeSuccess = command:execute() + local executeSuccess + if command.command then + log('Command: ' .. command.command) + executeSuccess = command:execute() + end if executeSuccess == nil then log('Transcode failed') From f8efd6827d4c13777cae105a63127819c99ccd16 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 9 Aug 2023 14:11:46 +0800 Subject: [PATCH 11/53] Move logger to a separate module --- config.lua.example | 5 +++- logger.lua | 20 +++++++++++++ media-processor.lua | 45 +++++++++++++--------------- nginx-lua-mp4.lua | 71 +++++++++++++++++++++------------------------ 4 files changed, 77 insertions(+), 64 deletions(-) create mode 100644 logger.lua diff --git a/config.lua.example b/config.lua.example index 37514c7..a94ea56 100644 --- a/config.lua.example +++ b/config.lua.example @@ -1,4 +1,5 @@ local Flag = require('flag') +local Logger = require('logger') config = {} @@ -142,13 +143,15 @@ config.ffmpegPreset = '' -- config.ffmpegPreset = 'superfast' -- config.ffmpegPreset = 'veryfast' +local logger = Logger.new(config.logEnabled, config.logLevel) + -- Set missing config options to the defaults ---@generic T: table, K, V ---@param defaults T function config.setDefaults(defaults) for o, v in pairs(defaults) do if config[o] == nil then - log('setting default config for ' .. o) + logger:log('Setting default config for ' .. o) config[o] = v end end diff --git a/logger.lua b/logger.lua new file mode 100644 index 0000000..a252b72 --- /dev/null +++ b/logger.lua @@ -0,0 +1,20 @@ +local Logger = {} + +-- Base class method new +function Logger.new(enabled, level) + local self = {} + self.enabled = enabled + self.level = level + setmetatable(self, { __index = Logger }) + return self +end + +-- Log function +---@param ... any +function Logger:log(...) + if self.enabled then + ngx.log(self.level, ...) + end +end + +return Logger diff --git a/media-processor.lua b/media-processor.lua index 2c2cf80..81df911 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -2,27 +2,22 @@ local config = require('config') local Flag = require('flag') local File = require('file') local Command = require('command') +local Logger = require('logger') local utils = require('utils') --- Log function ----@param data any -local function log(data) - if config.logEnabled then - ngx.log(config.logLevel, data) - end -end +local logger = Logger.new(config.logEnabled, config.logLevel) ---Proceed cached file ---@param file table local function proceedCashed(file) - log('Serving cached file: ' .. file.cachedFilePath) + logger:log('Serving cached file: ' .. file.cachedFilePath) ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) end ---Proceed file on transcode failure ---@param file table local function proceedOnTranscodeFailure(file) - log('Serving original from: ' .. file.originalFilePath) + logger:log('Serving original from: ' .. file.originalFilePath) ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) end @@ -32,29 +27,29 @@ end ---@param file table local function downloadOriginals(prefix, postfix, file) local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, file.filename) - log('Downloading original from ' .. originalsUpstreamPath) + logger:log('Downloading original from ' .. originalsUpstreamPath) ngx.req.discard_body() -- Clear body - log('Fetching') + logger:log('Fetching') local originalReq = ngx.location.capture('/luamp-upstream', { vars = { luamp_original_file = originalsUpstreamPath } }) - log('Upstream status: ' .. originalReq.status) + logger:log('Upstream status: ' .. originalReq.status) if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then - log('Downloaded original, saving') + logger:log('Downloaded original, saving') os.execute('mkdir -p ' .. file.originalDir) local originalFile = io.open(file.originalFilePath, 'w') originalFile:write(originalReq.body) originalFile:close() - log('Saved to ' .. file.originalFilePath) + logger:log('Saved to ' .. file.originalFilePath) else ngx.exit(ngx.HTTP_NOT_FOUND) end end local function main() - log('luamp started') + logger:log('luamp started') -- Set missing config options to the defaults config.setDefaults({ @@ -70,11 +65,11 @@ local function main() local postfix = utils.cleanupPath(ngx.var.luamp_postfix) local filename = utils.cleanupPath(ngx.var.luamp_filename) - log('media type: ' .. mediaType) - log('prefix: ' .. prefix) - log('flags: ' .. luamp_flags) - log('postfix: ' .. postfix) - log('filename: ' .. filename) + logger:log('media type: ' .. mediaType) + logger:log('prefix: ' .. prefix) + logger:log('flags: ' .. luamp_flags) + logger:log('postfix: ' .. postfix) + logger:log('filename: ' .. filename) local flags = {} local flagMapper = {} @@ -130,11 +125,11 @@ local function main() end -- If the cached file doesn't exist, process the original file - log('Cached file not found: ' .. file.cachedFilePath) + logger:log('Cached file not found: ' .. file.cachedFilePath) -- Check if the original file exists if not file:hasOriginal() then - log('Original file not found: ' .. file.originalFilePath) + logger:log('Original file not found: ' .. file.originalFilePath) if config.downloadOriginals then -- Download original if upstream download is enabled @@ -144,16 +139,16 @@ local function main() end end - log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) + logger:log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local command = Command.new(config, file, flags) local executeSuccess if command.command then - log('Command: ' .. command.command) + logger:log('Command: ' .. command.command) executeSuccess = command:execute() end if executeSuccess == nil then - log('Transcode failed') + logger:log('Transcode failed') if config.serveOriginalOnTranscodeFailure == true then proceedOnTranscodeFailure(file) diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 6ead3bd..073ee54 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -1,15 +1,10 @@ local config = require('config') local utils = require('utils') +local Logger = require('logger') --- Log function ----@param data any -local function log(data) - if config.logEnabled then - ngx.log(config.logLevel, data) - end -end +local logger = Logger.new(config.logEnabled, config.logLevel) -log('luamp started') +logger:log('luamp started') -- Set missing config options to the defaults config.setDefaults({ @@ -24,10 +19,10 @@ local flags = ngx.var.luamp_flags local postfix = utils.cleanupPath(ngx.var.luamp_postfix) local filename = utils.cleanupPath(ngx.var.luamp_filename) -log('prefix: ' .. prefix) -log('flags: ' .. flags) -log('postfix: ' .. postfix) -log('filename: ' .. filename) +logger:log('prefix: ' .. prefix) +logger:log('flags: ' .. flags) +logger:log('postfix: ' .. postfix) +logger:log('filename: ' .. filename) -- Initialize flag-related variables local flagValues = {} @@ -51,7 +46,7 @@ for flag, value in string.gmatch(flags, '(%w+)' .. config.flagValueDelimiter .. if config.flagPreprocessHook ~= nil then flag, value = config.flagPreprocessHook(flag, value) end - log(config.flagMap[flag] .. ' ' .. value) + logger:log(config.flagMap[flag] .. ' ' .. value) -- Add the flag to the ordered list table.insert(flagOrdered, config.flagMap[flag]) -- Check if it is an allowed text flag or cast to a number @@ -91,37 +86,37 @@ end -- check if we already have cached version of a file local cachedFilepath = config.mediaBaseFilepath .. (prefix or '') .. (optionsPath or '') .. (postfix or '') local originalFilepath = config.mediaBaseFilepath .. (prefix or '') .. (postfix or '') -log('checking for cached transcoded version at: ' .. cachedFilepath .. filename) +logger:log('checking for cached transcoded version at: ' .. cachedFilepath .. filename) local cachedFile = io.open(cachedFilepath .. filename, 'r') if cachedFile == nil then - log('no cached file') + logger:log('no cached file') -- create cached version -- check if we have original file to transcode - log('checking for original version at: ' .. originalFilepath .. filename) + logger:log('checking for original version at: ' .. originalFilepath .. filename) local originalFileCheck = io.open(originalFilepath .. filename) -- check if we have original if not originalFileCheck then - log('no original') + logger:log('no original') if config.downloadOriginals then -- download original, if upstream download is enabled - log('downloading original from ' .. config.getOriginalsUpstreamPath(prefix, postfix, filename)) + logger:log('downloading original from ' .. config.getOriginalsUpstreamPath(prefix, postfix, filename)) -- clear body ngx.req.discard_body() - log('fetching') + logger:log('fetching') -- fetch local originalReq = ngx.location.capture('/luamp-upstream', { vars = { luamp_original_file = config.getOriginalsUpstreamPath(prefix, postfix, filename) } }) - log('upstream status: ' .. originalReq.status) + logger:log('upstream status: ' .. originalReq.status) if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then - log('downloaded original, saving') + logger:log('downloaded original, saving') os.execute('mkdir -p ' .. originalFilepath) local originalFile = io.open(originalFilepath .. filename, 'w') originalFile:write(originalReq.body) originalFile:close() - log('saved to ' .. originalFilepath .. filename) + logger:log('saved to ' .. originalFilepath .. filename) else ngx.exit(ngx.HTTP_NOT_FOUND) end @@ -129,13 +124,13 @@ if cachedFile == nil then ngx.exit(ngx.HTTP_NOT_FOUND) end else - log('original is present on local FS') + logger:log('original is present on local FS') originalFileCheck:close() end -- process DPR if (flagValues['dpr'] ~= nil) then - log('before DPR calculation, w: ' .. + logger:log('before DPR calculation, w: ' .. (flagValues['width'] or 'nil') .. ', h: ' .. (flagValues['height'] or 'nil') .. @@ -155,7 +150,7 @@ if cachedFile == nil then if flagValues['y'] ~= nil and flagValues['y'] >= 1 then flagValues['y'] = flagValues['y'] * flagValues['dpr'] end - log('after DPR calculation, w: ' .. + logger:log('after DPR calculation, w: ' .. (flagValues['width'] or 'nil') .. ', h: ' .. (flagValues['height'] or 'nil') .. @@ -164,14 +159,14 @@ if cachedFile == nil then if config.maxVideoHeight ~= nil and flagValues['height'] ~= nil then if flagValues['height'] > config.maxVideoHeight then - log('resulting height exceeds configured limit, capping it at ' .. config.maxVideoHeight) + logger:log('resulting height exceeds configured limit, capping it at ' .. config.maxVideoHeight) flagValues['height'] = config.maxVideoHeight end end if config.maxVideoWidth ~= nil and flagValues['width'] ~= nil then if flagValues['width'] > config.maxVideoWidth then - log('resulting width exceeds configured limit, capping it at ' .. config.maxVideoWidth) + logger:log('resulting width exceeds configured limit, capping it at ' .. config.maxVideoWidth) flagValues['width'] = config.maxVideoWidth end end @@ -179,21 +174,21 @@ if cachedFile == nil then -- calculate absolute x/y for values in (0, 1) range if flagValues['x'] ~= nil and flagValues['x'] > 0 and flagValues['x'] < 1 then flagValues['x'] = flagValues['x'] * flagValues['width'] - log('absolute x: ' .. flagValues['x']) + logger:log('absolute x: ' .. flagValues['x']) end if flagValues['y'] ~= nil and flagValues['y'] > 0 and flagValues['y'] < 1 then flagValues['y'] = flagValues['y'] * flagValues['height'] - log('absolute y: ' .. flagValues['y']) + logger:log('absolute y: ' .. flagValues['y']) end local preset = '' -- setting x264 preset if (config['ffmpegPreset'] ~= '') then - log('x264 preset: ' .. config['ffmpegPreset']) + logger:log('x264 preset: ' .. config['ffmpegPreset']) preset = ' -preset ' .. config['ffmpegPreset'] .. ' ' end - log('transcoding to ' .. cachedFilepath .. filename) + logger:log('transcoding to ' .. cachedFilepath .. filename) -- create cached transcoded file os.execute('mkdir -p ' .. cachedFilepath) @@ -352,15 +347,15 @@ if cachedFile == nil then if config.logTime then command = 'time ' .. command end - log('ffmpeg command: ' .. command) + logger:log('ffmpeg command: ' .. command) executeSuccess = os.execute(command) end if executeSuccess == nil then - log('transcode failed') + logger:log('transcode failed') if config.serveOriginalOnTranscodeFailure == true then - log('serving original from: ' .. originalFilepath .. filename) + logger:log('serving original from: ' .. originalFilepath .. filename) ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath .. filename }) end else @@ -371,23 +366,23 @@ if cachedFile == nil then transcodedFile:close() if transcodedFileSize > config.minimumTranscodedVideoSize then - log('transcoded version is good, serving it') + logger:log('transcoded version is good, serving it') -- serve it ngx.exec('/luamp-cache', { luamp_cached_file_path = cachedFilepath .. filename }) else - log('transcoded version is corrupt') + logger:log('transcoded version is corrupt') -- delete corrupt one os.remove(cachedFilepath .. filename) -- serve original if config.serveOriginalOnTranscodeFailure == true then - log('serving original') + logger:log('serving original') ngx.exec('/luamp-cache', { luamp_cached_file_path = originalFilepath .. filename }) end end end else - log('found previously transcoded version, serving it') + logger:log('found previously transcoded version, serving it') cachedFile:close() end From 7d40628666f535e7da9d632cd4150a6af1d29c1b Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 10 Aug 2023 12:05:44 +0800 Subject: [PATCH 12/53] Declare config as local var in the conf example --- config.lua.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.lua.example b/config.lua.example index a94ea56..9f90e0c 100644 --- a/config.lua.example +++ b/config.lua.example @@ -1,7 +1,7 @@ local Flag = require('flag') local Logger = require('logger') -config = {} +local config = {} -- ########## CONFIG ########## -- change according to your needs From 5d6ef9fb271f5fd61c31f256f01318d5f25a5315 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 14 Aug 2023 15:36:48 +0800 Subject: [PATCH 13/53] Merge image-processing --- README.md | 16 ++++++++------- command.lua | 50 ++++++++++++++++++++++++--------------------- config.lua.example | 2 +- media-processor.lua | 16 ++++++--------- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 69d175b..ea8f758 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,14 @@ location @luamp_process { # image location location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + set $luamp_media_type "image"; + + #pass to transcoder location + try_files $uri @luamp_media_processor; +} + +# image process/transcode location +location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; @@ -90,13 +98,7 @@ location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_ set $luamp_prefix ""; set $luamp_postfix ""; - #pass to transcoder location - try_files $uri @luamp_image_process; -} - -# image process/transcode location -location @luamp_image_process { - content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-image.lua"; + content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; } # cache location diff --git a/command.lua b/command.lua index 78baf57..8a18179 100644 --- a/command.lua +++ b/command.lua @@ -21,31 +21,35 @@ local function buildImageProcessingCommand(config, file, flags) local width = flags.width.value local height = flags.height.value - -- Create cached transcoded file - os.execute('mkdir -p ' .. cacheDir) -- Construct a command - local command = config.magick + local command + if width or height then + -- Create cached transcoded file + os.execute('mkdir -p ' .. cacheDir) - if gravity then - command = command .. ' -gravity ' .. gravity - end + --- Init with processor + command = config.magick - -- Create Canvas - command = command .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilePath .. ')' - if background == 'auto' then - -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. originalFilePath .. - ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + if gravity then + command = command .. ' -gravity ' .. gravity + end - local dominantColors = utils.captureCommandOutput(cmd) + -- Create Canvas + command = command .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilePath .. ')' + if background == 'auto' then + -- Get 2 dominant colors in format 'x000000-x000000' + local cmd = config.magick .. ' ' .. originalFilePath .. + ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' - command = command .. ' gradient:' .. dominantColors - else - command = command .. ' xc:' .. (background or '') - end + local dominantColors = utils.captureCommandOutput(cmd) - if width or height then + command = command .. ' gradient:' .. dominantColors + else + command = command .. ' xc:' .. (background or '') + end + + -- Crop and resize local dimensions = (width or '') .. 'x' .. (height or '') local resizeFlag = (width and height and '!') or '' @@ -76,12 +80,12 @@ local function buildImageProcessingCommand(config, file, flags) ' -composite' .. ' -resize ' .. dimensions .. resizeFlag end - end - -- Append the output filepath to the convert command - command = command .. ' ' .. cachedFilePath + -- Append the output filepath to the convert command + command = command .. ' ' .. cachedFilePath + end - if config.logTime then + if command and config.logTime then command = 'time ' .. command end @@ -129,7 +133,7 @@ end -- Execute command ---@return boolean? function Command:execute() - if self.command ~= '' then + if self.command and self.command ~= '' then return os.execute(self.command) end end diff --git a/config.lua.example b/config.lua.example index 47f84e6..91f0bea 100644 --- a/config.lua.example +++ b/config.lua.example @@ -149,7 +149,7 @@ config.ffmpegPreset = '' function config.setDefaults(defaults) for o, v in pairs(defaults) do if config[o] == nil then - log('setting default config for ' .. o) + log('Setting default config for ' .. o) config[o] = v end end diff --git a/media-processor.lua b/media-processor.lua index dee6543..88de54a 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -2,16 +2,9 @@ local config = require('config') local Flag = require('flag') local File = require('file') local Command = require('command') +local log = require('log') local utils = require('utils') --- Log function ----@param data any -local function log(data) - if config.logEnabled then - ngx.log(config.logLevel, data) - end -end - ---Proceed cached file ---@param file table local function proceedCashed(file) @@ -148,8 +141,11 @@ local function main() log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local command = Command.new(config, file, flags) - log('Command: ' .. command.command) - local executeSuccess = command:execute() + local executeSuccess + if command.command then + log('Command: ' .. command.command) + executeSuccess = command:execute() + end if executeSuccess == nil then log('Transcode failed') From 02bc0bf3affb656ac131736d14e70fba26f30047 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 14 Aug 2023 17:39:54 +0800 Subject: [PATCH 14/53] Fix white screen --- media-processor.lua | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/media-processor.lua b/media-processor.lua index 88de54a..8b50158 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -5,20 +5,6 @@ local Command = require('command') local log = require('log') local utils = require('utils') ----Proceed cached file ----@param file table -local function proceedCashed(file) - log('Serving cached file: ' .. file.cachedFilePath) - ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) -end - ----Proceed file on transcode failure ----@param file table -local function proceedOnTranscodeFailure(file) - log('Serving original from: ' .. file.originalFilePath) - ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) -end - ---Download original form upstream ---@param prefix string ---@param postfix string @@ -47,7 +33,7 @@ local function downloadOriginals(prefix, postfix, file) end local function main() - log('luamp started') + log('Luamp started') -- Set missing config options to the defaults config.setDefaults({ @@ -63,18 +49,17 @@ local function main() local postfix = utils.cleanupPath(ngx.var.luamp_postfix) local filename = utils.cleanupPath(ngx.var.luamp_filename) - log('media type: ' .. mediaType) - log('prefix: ' .. prefix) - log('flags: ' .. luamp_flags) - log('postfix: ' .. postfix) - log('filename: ' .. filename) + log('MediaType: ' .. mediaType) + log('Prefix: ' .. prefix) + log('Flags: ' .. luamp_flags) + log('Postfix: ' .. postfix) + log('Filename: ' .. filename) local flags = {} local flagMapper = {} local valueMapper = {} if mediaType == File.IMAGE_TYPE then - log('MediaType is image') flags = { background = Flag.new(Flag.IMAGE_BACKGROUND_NAME), crop = Flag.new(Flag.IMAGE_CROP_NAME), @@ -88,7 +73,6 @@ local function main() flagMapper = config.flagImageMap valueMapper = config.flagValueMap elseif mediaType == File.VIDEO_TYPE then - log('MediaType is video') flags = {} flagMapper = config.flagMap valueMapper = config.flagValueMap @@ -121,7 +105,8 @@ local function main() -- Serve the cached file if it exists if file:isCached() then - proceedCashed(file) + log('Serving cached file: ' .. file.cachedFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) end -- If the cached file doesn't exist, process the original file @@ -151,8 +136,12 @@ local function main() log('Transcode failed') if config.serveOriginalOnTranscodeFailure == true then - proceedOnTranscodeFailure(file) + log('Serving original from: ' .. file.originalFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) end + else + log('Transcoded version is good, serving it') + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) end end From 52b55d86d7bd3ca4304393e6aec50bc419b2e2bf Mon Sep 17 00:00:00 2001 From: Evgeniy Chekan Date: Wed, 30 Aug 2023 08:45:27 +0200 Subject: [PATCH 15/53] strip date and time meta from png for consistent encoding results --- command.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/command.lua b/command.lua index 8a18179..caf62d6 100644 --- a/command.lua +++ b/command.lua @@ -29,7 +29,7 @@ local function buildImageProcessingCommand(config, file, flags) os.execute('mkdir -p ' .. cacheDir) --- Init with processor - command = config.magick + command = config.magick .. ' -define png:exclude-chunks=date,time' if gravity then command = command .. ' -gravity ' .. gravity From 25acd59549e62d5b559abcd65d5a16fa9e7afba0 Mon Sep 17 00:00:00 2001 From: Evgeniy Chekan Date: Wed, 30 Aug 2023 09:01:54 +0200 Subject: [PATCH 16/53] make `identify` path configurable --- command.lua | 2 +- config.lua.example | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/command.lua b/command.lua index 8a18179..2489893 100644 --- a/command.lua +++ b/command.lua @@ -36,7 +36,7 @@ local function buildImageProcessingCommand(config, file, flags) end -- Create Canvas - command = command .. ' -size $(identify -ping -format "%wx%h" ' .. originalFilePath .. ')' + command = command .. ' -size $(' .. config.identify .. ' -ping -format "%wx%h" ' .. originalFilePath .. ')' if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' local cmd = config.magick .. ' ' .. originalFilePath .. diff --git a/config.lua.example b/config.lua.example index 91f0bea..4e962b9 100644 --- a/config.lua.example +++ b/config.lua.example @@ -12,6 +12,9 @@ config.ffmpeg = '/usr/local/bin/ffmpeg' -- `which magick` config.magick = '/usr/bin/magick' +-- `which identify` +config.identify = '/usr/bin/identify' + -- where to save original and transcoded files (trailing slash required) config.mediaBaseFilepath = '/tmp/nginx/' From 0b54da669235b0d01a0515d65171c623c26b28fe Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 25 Sep 2023 12:15:19 +0200 Subject: [PATCH 17/53] Add color profile settings --- command.lua | 12 ++++++++++-- config.lua.example | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/command.lua b/command.lua index 815e560..a4da4b2 100644 --- a/command.lua +++ b/command.lua @@ -21,7 +21,6 @@ local function buildImageProcessingCommand(config, file, flags) local width = flags.width.value local height = flags.height.value - -- Construct a command local command if width or height then @@ -29,7 +28,7 @@ local function buildImageProcessingCommand(config, file, flags) os.execute('mkdir -p ' .. cacheDir) --- Init with processor - command = config.magick .. ' -define png:exclude-chunks=date,time' + command = config.magick .. ' -define png:exclude-chunks=date,time -quality 80' if gravity then command = command .. ' -gravity ' .. gravity @@ -81,6 +80,15 @@ local function buildImageProcessingCommand(config, file, flags) ' -resize ' .. dimensions .. resizeFlag end + if config.stripColorProfile then + command = command .. ' -strip' + end + + + if config.colorProfilePath ~= '' and File.fileExists(config.colorProfilePath) then + command = command .. ' -profile /home/nginx/sRGB.icc' + end + -- Append the output filepath to the convert command command = command .. ' ' .. cachedFilePath end diff --git a/config.lua.example b/config.lua.example index 4e962b9..161d10d 100644 --- a/config.lua.example +++ b/config.lua.example @@ -21,6 +21,12 @@ config.mediaBaseFilepath = '/tmp/nginx/' -- set to `true` to enable originals download from the upstream/CDN. See `getOriginalsUpstreamUrl` below config.downloadOriginals = true +-- removes image color profile on conversion +config.stripColorProfile = true + +-- color profile will be applied if path set +config.colorProfilePath = + -- function to get a URL where originals are stored, when `downloadOriginals` set to true. function config.getOriginalsUpstreamPath(prefix, postfix, filename) -- return ngx.var.request_uri From 7c73b77cc23714cfa7056b55f27668a7c06a6d6c Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 25 Sep 2023 12:20:19 +0200 Subject: [PATCH 18/53] Correct color profile settings description --- config.lua.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.lua.example b/config.lua.example index 161d10d..10a195a 100644 --- a/config.lua.example +++ b/config.lua.example @@ -21,10 +21,10 @@ config.mediaBaseFilepath = '/tmp/nginx/' -- set to `true` to enable originals download from the upstream/CDN. See `getOriginalsUpstreamUrl` below config.downloadOriginals = true --- removes image color profile on conversion +-- remove image color profile on conversion config.stripColorProfile = true --- color profile will be applied if path set +-- apply color profile if path is set config.colorProfilePath = -- function to get a URL where originals are stored, when `downloadOriginals` set to true. From 4f0d3c5d7f06b46a8bd6ae071dbff5efc6f4eeb1 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 25 Sep 2023 12:24:24 +0200 Subject: [PATCH 19/53] Add comments --- command.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/command.lua b/command.lua index a4da4b2..9d5c732 100644 --- a/command.lua +++ b/command.lua @@ -80,11 +80,13 @@ local function buildImageProcessingCommand(config, file, flags) ' -resize ' .. dimensions .. resizeFlag end + -- Remove color profiles if config.stripColorProfile then command = command .. ' -strip' end + -- Apply selected color profile if config.colorProfilePath ~= '' and File.fileExists(config.colorProfilePath) then command = command .. ' -profile /home/nginx/sRGB.icc' end From f9aa7c1897b28cf4f9de5d97c40180fb3e127982 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 26 Sep 2023 15:14:25 +0200 Subject: [PATCH 20/53] Formatting --- command.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/command.lua b/command.lua index 9d5c732..5bfb11b 100644 --- a/command.lua +++ b/command.lua @@ -85,7 +85,6 @@ local function buildImageProcessingCommand(config, file, flags) command = command .. ' -strip' end - -- Apply selected color profile if config.colorProfilePath ~= '' and File.fileExists(config.colorProfilePath) then command = command .. ' -profile /home/nginx/sRGB.icc' From 8a24f82c82efc3cdc8fe0f9750ba953d6d9fbf64 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 4 Oct 2023 15:51:13 +0200 Subject: [PATCH 21/53] Review fixes --- command.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/command.lua b/command.lua index 5bfb11b..78e895c 100644 --- a/command.lua +++ b/command.lua @@ -87,7 +87,7 @@ local function buildImageProcessingCommand(config, file, flags) -- Apply selected color profile if config.colorProfilePath ~= '' and File.fileExists(config.colorProfilePath) then - command = command .. ' -profile /home/nginx/sRGB.icc' + command = command .. ' -profile ' .. config.colorProfilePath end -- Append the output filepath to the convert command From e7cad14caf4bbd8aa5621368ff0f7347e6cc717a Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 5 Oct 2023 16:20:29 +0200 Subject: [PATCH 22/53] Add blurred background setting --- command.lua | 128 ++++++++++++++++++++++----------------------- config.lua.example | 5 +- 2 files changed, 64 insertions(+), 69 deletions(-) diff --git a/command.lua b/command.lua index 78e895c..190109f 100644 --- a/command.lua +++ b/command.lua @@ -3,17 +3,34 @@ local utils = require('utils') local Command = {} +local function getBackgroundImage(config, file, flags) + local background = flags.background.value + local inputFilePath = file.originalFilePath + local backgroundImage = '' + + if background == 'auto' then + -- Get 2 dominant colors in format 'x000000-x000000' + local cmd = config.magick .. ' ' .. inputFilePath .. + ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + + local dominantColors = utils.captureCommandOutput(cmd) + + backgroundImage = inputFilePath .. ' -size 100% gradient:' .. dominantColors .. ' -delete 0 ' + elseif background == 'blurred' then + backgroundImage = inputFilePath .. ' -gravity center -crop 80%x80% +repage -blur 0x8 ' + else + backgroundImage = inputFilePath .. ' -size 100% xc:' .. (background or '') .. ' -delete 0 ' + end + + return backgroundImage +end + -- Build image processing command ---@param config table ---@param file table ---@param flags table ---@return string local function buildImageProcessingCommand(config, file, flags) - local cacheDir = file.cacheDir - local cachedFilePath = file.cachedFilePath - local originalFilePath = file.originalFilePath - - local background = flags.background.value local crop = flags.crop.value local gravity = flags.gravity.value local x = flags.x.value @@ -22,62 +39,48 @@ local function buildImageProcessingCommand(config, file, flags) local height = flags.height.value -- Construct a command - local command - if width or height then - -- Create cached transcoded file - os.execute('mkdir -p ' .. cacheDir) - - --- Init with processor - command = config.magick .. ' -define png:exclude-chunks=date,time -quality 80' - - if gravity then - command = command .. ' -gravity ' .. gravity - end - - -- Create Canvas - command = command .. ' -size $(' .. config.identify .. ' -ping -format "%wx%h" ' .. originalFilePath .. ')' - if background == 'auto' then - -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. originalFilePath .. - ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' - - local dominantColors = utils.captureCommandOutput(cmd) - - command = command .. ' gradient:' .. dominantColors - else - command = command .. ' xc:' .. (background or '') - end - - -- Crop and resize - local dimensions = (width or '') .. 'x' .. (height or '') - local resizeFlag = (width and height and '!') or '' - - if crop == 'padding' then - command = command .. - ' -resize ' .. dimensions .. resizeFlag .. ' ' .. - originalFilePath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. - ' -composite' - end - - if crop == 'limited_padding' then - command = command .. - ' -resize ' .. dimensions .. resizeFlag .. ' ' .. - originalFilePath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '\\>' .. - ' -composite' - end + local inputFilePath = file.originalFilePath + local outputDir = file.cacheDir + local outputFilePath = file.cachedFilePath + + local executorWithPreset = config.magick .. ' -define png:exclude-chunks=date,time -quality 80 ' + local gravityCommand = (gravity and ' -gravity ' .. gravity .. ' ') or '' + local backgroundImage = getBackgroundImage(config, file, flags) + local foregroundImage = inputFilePath .. ' -modulate 100,120,100 ' + local dimensions = (width or '') .. 'x' .. (height or '') + + local command = '' + + -- Gravity is optional only for 'fill', 'lpad' and 'pad' cropping + -- Background is optional only for 'lpad' and 'pad' cropping + if crop == 'fill' and (width or height) then + command = + executorWithPreset .. gravityCommand .. + foregroundImage .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+' .. x .. '+' .. y + elseif crop == 'limited_padding' and (width or height) then + command = + executorWithPreset .. gravityCommand .. + backgroundImage .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+0+0 ' .. + foregroundImage .. ' -resize ' .. dimensions .. '\\>' .. + ' -composite' + elseif crop == 'padding' and (width or height) then + command = + executorWithPreset .. gravityCommand .. + backgroundImage .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+0+0 ' .. + foregroundImage .. ' -resize ' .. dimensions .. + ' -composite' + elseif width or height then + local forceResizeFlag = (width and height and '! ') or ' ' + command = + executorWithPreset .. + foregroundImage .. ' -resize ' .. dimensions .. forceResizeFlag + end - if crop == 'fill' then - command = command .. ' ' .. - originalFilePath .. ' -modulate 100,120,100' .. ' -resize ' .. dimensions .. '^' .. - ' -composite' .. - ' -crop ' .. dimensions .. '+' .. x .. '+' .. y - end + if command and command ~= '' then + os.execute('mkdir -p ' .. outputDir) - if crop == nil then - command = command .. ' ' .. - originalFilePath .. ' -modulate 100,120,100' .. - ' -composite' .. - ' -resize ' .. dimensions .. resizeFlag + if config.logTime then + command = 'time ' .. command end -- Remove color profiles @@ -90,12 +93,7 @@ local function buildImageProcessingCommand(config, file, flags) command = command .. ' -profile ' .. config.colorProfilePath end - -- Append the output filepath to the convert command - command = command .. ' ' .. cachedFilePath - end - - if command and config.logTime then - command = 'time ' .. command + command = command .. ' ' .. outputFilePath end return command diff --git a/config.lua.example b/config.lua.example index 10a195a..2dbc5a5 100644 --- a/config.lua.example +++ b/config.lua.example @@ -12,9 +12,6 @@ config.ffmpeg = '/usr/local/bin/ffmpeg' -- `which magick` config.magick = '/usr/bin/magick' --- `which identify` -config.identify = '/usr/bin/identify' - -- where to save original and transcoded files (trailing slash required) config.mediaBaseFilepath = '/tmp/nginx/' @@ -80,7 +77,7 @@ config.flagValueMap = { fill = 'fill', -- background params auto = 'auto', - blurred = 'blur', + blurred = 'blurred', black = 'black', white = 'white', red = 'red', From 3bcd66b6153637acee0bc83410e9304bc0b1ef79 Mon Sep 17 00:00:00 2001 From: JuliaSparkles Date: Mon, 16 Oct 2023 20:02:27 +0400 Subject: [PATCH 23/53] add test suite --- .gitignore | 4 + tests/leva_test_image_lua_do_not_rename.jpeg | Bin 0 -> 47707 bytes tests/leva_test_video_luamp_do_not_rename.mp4 | Bin 0 -> 221499 bytes tests/links_image.example | 15 +++ tests/links_video.example | 15 +++ tests/test.sh | 99 ++++++++++++++++++ 6 files changed, 133 insertions(+) create mode 100644 tests/leva_test_image_lua_do_not_rename.jpeg create mode 100644 tests/leva_test_video_luamp_do_not_rename.mp4 create mode 100644 tests/links_image.example create mode 100644 tests/links_video.example create mode 100755 tests/test.sh diff --git a/.gitignore b/.gitignore index c2f2e6e..5061b43 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .vscode .idea config.lua +tests/originals +tests/runfiles +tests/links_image +tests/links_video \ No newline at end of file diff --git a/tests/leva_test_image_lua_do_not_rename.jpeg b/tests/leva_test_image_lua_do_not_rename.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..43cf6b5c55b24638a6511093bda41936f7c86a75 GIT binary patch literal 47707 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%0Ay>tFM`RrK-H7WL-+L9LY(YNuNa0Ceu%FAwwL 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

jW5l^?CW!Hdkvv1qU=(9 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 I|7`pJ0VT($b^rhX literal 0 HcmV?d00001 diff --git a/tests/links_image.example b/tests/links_image.example new file mode 100644 index 0000000..c943173 --- /dev/null +++ b/tests/links_image.example @@ -0,0 +1,15 @@ +amondo-media/image/upload/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_300/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_600,h_1200/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_pad/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_lpad/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_1200/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_600,h_600/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_pad/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_pad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_lpad/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_lpad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ \ No newline at end of file diff --git a/tests/links_video.example b/tests/links_video.example new file mode 100644 index 0000000..0a623fc --- /dev/null +++ b/tests/links_video.example @@ -0,0 +1,15 @@ +amondo-media/video/upload/v1685920143/prod/tile/media/ +amondo-media/video/upload/w_300/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_600,h_1200/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_pad/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_lpad/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_1200/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_600,h_600/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_pad/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_pad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_lpad/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_lpad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/video/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 0000000..65557ff --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,99 @@ +HOST="http://localhost/" +MEDIA_DIR="/tmp/nginx" +CURRENT_DIR=$(pwd) +VIDEO_FILE="leva_test_video_luamp_do_not_rename.mp4" +IMAGE_FILE="leva_test_image_lua_do_not_rename" +IMAGE_FORMATS=("jpeg") +LINKS_VIDEO=($(cat $CURRENT_DIR/links_video)) +LINKS_IMAGES=($(cat $CURRENT_DIR/links_image)) + +if [ $# -eq 0 ]; then + argument="null" +else + argument="$1" +fi + +if [ "$argument" == "--init" ]; then + rm -fr ./originals/* + mkdir -p ./originals/video + touch ./originals/checksums_video.txt + + + INDEX=1 + for LINK in "${LINKS_VIDEO[@]}" + do + FULL_LINK="$HOST$LINK" + wget -q -O "originals/video/$INDEX.mp4" "$FULL_LINK$VIDEO_FILE" + INDEX=$(expr $INDEX + 1) + done + + sleep 5 + + cd originals/video/ + for FILE in ./* + do + shasum "$FILE" >> ../checksums_video.txt + done + + cd ../../ + + mkdir -p ./originals/images + touch ./originals/checksums_images.txt + + INDEX=1 + for LINK in "${LINKS_IMAGES[@]}"; do + for FORMAT in "${IMAGE_FORMATS[@]}"; do + FULL_LINK="$HOST$LINK$IMAGE_FILE.$FORMAT" + wget -q -O "originals/images/$INDEX.$FORMAT" "$FULL_LINK" + done + INDEX=$(expr $INDEX + 1) + done + + sleep 5 + + cd originals/images/ + for FILE in ./* + do + shasum "$FILE" >> ../checksums_images.txt + done + + cd ../ + touch checksums.txt + cat checksums_video.txt >> checksums.txt + cat checksums_images.txt >> checksums.txt +fi +if [ "$argument" = "null" ] || [ "$argument" = "--video" ]; then + cd $CURRENT_DIR + rm -fr ./runfiles/video + mkdir -p runfiles/video + find "$MEDIA_DIR" -name "$VIDEO_FILE" -exec shasum {} + | grep -v "c8b2ca0dde83154818f8718488aa7128f3a15454" | cut -c 43- | xargs rm + INDEX=1 + for LINK in "${LINKS_VIDEO[@]}" + do + FULL_LINK="$HOST$LINK" + wget -q -O "$CURRENT_DIR/runfiles/video/$INDEX.mp4" "$FULL_LINK$VIDEO_FILE" + diff <(cd originals/video && ffprobe -hide_banner $INDEX.mp4 2>&1) <(cd runfiles/video && ffprobe -hide_banner $INDEX.mp4 2>&1) + INDEX=$(expr $INDEX + 1) + done + cp originals/checksums_video.txt runfiles/checksums_video.txt + cd runfiles/video/ + shasum -c $CURRENT_DIR/runfiles/checksums_video.txt +fi +if [ "$argument" = "null" ] || [ "$argument" = "--image" ]; then + cd $CURRENT_DIR + rm -fr ./runfiles/images + mkdir -p runfiles/images + find "$MEDIA_DIR" -name "$IMAGE_FILE" -exec shasum {} + | grep -v "fb02cdbe98744713d2b9ef09a2e264b56fd8274b" | cut -c 43- | xargs rm + INDEX=1 + for LINK in "${LINKS_IMAGES[@]}"; do + for FORMAT in "${IMAGE_FORMATS[@]}"; do + FULL_LINK="$HOST$LINK$IMAGE_FILE.$FORMAT" + wget -q -O "$CURRENT_DIR/runfiles/images/$INDEX.$FORMAT" "$FULL_LINK" + diff <(cd originals/images && ffprobe -hide_banner "$INDEX.$FORMAT" 2>&1) <(cd runfiles/images && ffprobe -hide_banner "$INDEX.$FORMAT" 2>&1) + done + INDEX=$(expr $INDEX + 1) + done + cp originals/checksums_images.txt runfiles/checksums_images.txt + cd runfiles/images + shasum -c $CURRENT_DIR/runfiles/checksums_images.txt +fi From 5225ee570dca60c38a76b4885b6cd07560d633f1 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 18 Oct 2023 16:35:19 +0200 Subject: [PATCH 24/53] Add a setting for rounded corners --- command.lua | 124 ++++++++++++++++++++++++++++---------------- config.lua.example | 2 + flag.lua | 4 ++ media-processor.lua | 22 ++++---- 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/command.lua b/command.lua index 190109f..112f867 100644 --- a/command.lua +++ b/command.lua @@ -1,28 +1,28 @@ local File = require('file') +local Flag = require('flag') local utils = require('utils') local Command = {} -local function getBackgroundImage(config, file, flags) - local background = flags.background.value - local inputFilePath = file.originalFilePath - local backgroundImage = '' +local function getCanvas(config, file, flags) + local background = flags[Flag.IMAGE_BACKGROUND_NAME].value + local canvas = '' if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. inputFilePath .. + local cmd = config.magick .. ' ' .. file.originalFilePath .. ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' local dominantColors = utils.captureCommandOutput(cmd) - backgroundImage = inputFilePath .. ' -size 100% gradient:' .. dominantColors .. ' -delete 0 ' + canvas = file.originalFilePath .. ' -size %wx%h gradient:' .. dominantColors .. ' -delete 0 ' elseif background == 'blurred' then - backgroundImage = inputFilePath .. ' -gravity center -crop 80%x80% +repage -blur 0x8 ' + canvas = file.originalFilePath .. ' -crop 80%x80% +repage -blur 0x8 ' else - backgroundImage = inputFilePath .. ' -size 100% xc:' .. (background or '') .. ' -delete 0 ' + canvas = file.originalFilePath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' end - return backgroundImage + return canvas end -- Build image processing command @@ -31,53 +31,85 @@ end ---@param flags table ---@return string local function buildImageProcessingCommand(config, file, flags) - local crop = flags.crop.value - local gravity = flags.gravity.value - local x = flags.x.value - local y = flags.y.value - local width = flags.width.value - local height = flags.height.value + local crop = flags[Flag.IMAGE_CROP_NAME].value + local gravity = flags[Flag.IMAGE_GRAVITY_NAME].value + local x = flags[Flag.IMAGE_X_NAME].value + local y = flags[Flag.IMAGE_Y_NAME].value + local width = flags[Flag.IMAGE_WIDTH_NAME].value + local height = flags[Flag.IMAGE_HEIGHT_NAME].value + local radius = flags[Flag.IMAGE_RADIUS_NAME].value + local quality = flags[Flag.IMAGE_QUALITY_NAME].value -- Construct a command - local inputFilePath = file.originalFilePath - local outputDir = file.cacheDir - local outputFilePath = file.cachedFilePath - - local executorWithPreset = config.magick .. ' -define png:exclude-chunks=date,time -quality 80 ' - local gravityCommand = (gravity and ' -gravity ' .. gravity .. ' ') or '' - local backgroundImage = getBackgroundImage(config, file, flags) - local foregroundImage = inputFilePath .. ' -modulate 100,120,100 ' - local dimensions = (width or '') .. 'x' .. (height or '') - local command = '' + local executorWithPreset = config.magick .. + ' -define png:exclude-chunks=date,time' .. + ' -quality ' .. quality .. + ' -gravity ' .. gravity .. ' ' + local canvas = getCanvas(config, file, flags) + local image = file.originalFilePath .. ' -modulate 100,120,100 ' + local mask = + '-size %[origwidth]x%[origheight]' .. + ' xc:black' .. + ' -fill white' .. + ' -draw "roundrectangle 0,0,%[origwidth],%[origheight],' .. radius .. ',' .. radius .. '"' .. + ' -alpha Copy' + local dimensions = (width or '') .. 'x' .. (height or '') - -- Gravity is optional only for 'fill', 'lpad' and 'pad' cropping - -- Background is optional only for 'lpad' and 'pad' cropping if crop == 'fill' and (width or height) then - command = - executorWithPreset .. gravityCommand .. - foregroundImage .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+' .. x .. '+' .. y + command = executorWithPreset .. + canvas .. + ' -resize ' .. dimensions .. '^' .. + ' -crop ' .. dimensions .. '+' .. x .. '+' .. y .. + ' \\( ' .. + image .. + ' -resize ' .. dimensions .. '^' .. + ' -crop ' .. dimensions .. '+' .. x .. '+' .. y .. + ' -set option:origwidth %w' .. + ' -set option:origheight %h' .. + ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. + ' \\) -compose over -composite' elseif crop == 'limited_padding' and (width or height) then - command = - executorWithPreset .. gravityCommand .. - backgroundImage .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+0+0 ' .. - foregroundImage .. ' -resize ' .. dimensions .. '\\>' .. - ' -composite' + command = executorWithPreset .. + canvas .. + ' -resize ' .. dimensions .. '^' .. + ' -crop ' .. dimensions .. '+0+0 ' .. + ' \\( ' .. + image .. + ' -resize ' .. dimensions .. '\\>' .. + ' -set option:origwidth %w' .. + ' -set option:origheight %h' .. + ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. + ' \\) -compose over -composite' elseif crop == 'padding' and (width or height) then - command = - executorWithPreset .. gravityCommand .. - backgroundImage .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+0+0 ' .. - foregroundImage .. ' -resize ' .. dimensions .. - ' -composite' + command = executorWithPreset .. + canvas .. + ' -resize ' .. dimensions .. '^' .. + ' -crop ' .. dimensions .. '+0+0 ' .. + ' \\( ' .. + image .. + ' -resize ' .. dimensions .. + ' -set option:origwidth %w' .. + ' -set option:origheight %h' .. + ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. + ' \\) -compose over -composite' elseif width or height then - local forceResizeFlag = (width and height and '! ') or ' ' - command = - executorWithPreset .. - foregroundImage .. ' -resize ' .. dimensions .. forceResizeFlag + local forceResizeFlag = (width and height and '! ') or '' + + command = executorWithPreset .. + canvas .. + ' -resize ' .. dimensions .. forceResizeFlag .. + ' \\( ' .. + image .. + ' -resize ' .. dimensions .. forceResizeFlag .. + ' -set option:origwidth %w' .. + ' -set option:origheight %h' .. + ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. + ' \\) -compose over -composite' end if command and command ~= '' then - os.execute('mkdir -p ' .. outputDir) + os.execute('mkdir -p ' .. file.cacheDir) if config.logTime then command = 'time ' .. command @@ -93,7 +125,7 @@ local function buildImageProcessingCommand(config, file, flags) command = command .. ' -profile ' .. config.colorProfilePath end - command = command .. ' ' .. outputFilePath + command = command .. ' ' .. file.cachedFilePath end return command diff --git a/config.lua.example b/config.lua.example index 2dbc5a5..6d701f0 100644 --- a/config.lua.example +++ b/config.lua.example @@ -64,6 +64,8 @@ config.flagImageMap = { y = Flag.IMAGE_Y_NAME, h = Flag.IMAGE_HEIGHT_NAME, w = Flag.IMAGE_WIDTH_NAME, + r = Flag.IMAGE_RADIUS_NAME, + q = Flag.IMAGE_QUALITY_NAME, } -- override URL flag values. Useful when you migrate from another transcoding solution and already have diff --git a/flag.lua b/flag.lua index 051619c..57535f1 100644 --- a/flag.lua +++ b/flag.lua @@ -8,6 +8,8 @@ Flag.IMAGE_X_NAME = 'x' Flag.IMAGE_Y_NAME = 'y' Flag.IMAGE_HEIGHT_NAME = 'height' Flag.IMAGE_WIDTH_NAME = 'width' +Flag.IMAGE_RADIUS_NAME = 'radius' +Flag.IMAGE_QUALITY_NAME = 'quality' local IMAGE_DEFAULTS = { [Flag.IMAGE_BACKGROUND_NAME] = 'white', @@ -15,6 +17,8 @@ local IMAGE_DEFAULTS = { [Flag.IMAGE_GRAVITY_NAME] = 'center', [Flag.IMAGE_X_NAME] = 0, [Flag.IMAGE_Y_NAME] = 0, + [Flag.IMAGE_RADIUS_NAME] = 0.1, + [Flag.IMAGE_QUALITY_NAME] = 80 } -- Base class method new diff --git a/media-processor.lua b/media-processor.lua index 8b50158..0811b5c 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -61,14 +61,16 @@ local function main() if mediaType == File.IMAGE_TYPE then flags = { - background = Flag.new(Flag.IMAGE_BACKGROUND_NAME), - crop = Flag.new(Flag.IMAGE_CROP_NAME), - dpr = Flag.new(Flag.IMAGE_DPR_NAME), - gravity = Flag.new(Flag.IMAGE_GRAVITY_NAME), - x = Flag.new(Flag.IMAGE_X_NAME), - y = Flag.new(Flag.IMAGE_Y_NAME), - height = Flag.new(Flag.IMAGE_HEIGHT_NAME), - width = Flag.new(Flag.IMAGE_WIDTH_NAME) + [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(Flag.IMAGE_BACKGROUND_NAME), + [Flag.IMAGE_CROP_NAME] = Flag.new(Flag.IMAGE_CROP_NAME), + [Flag.IMAGE_DPR_NAME] = Flag.new(Flag.IMAGE_DPR_NAME), + [Flag.IMAGE_GRAVITY_NAME] = Flag.new(Flag.IMAGE_GRAVITY_NAME), + [Flag.IMAGE_X_NAME] = Flag.new(Flag.IMAGE_X_NAME), + [Flag.IMAGE_Y_NAME] = Flag.new(Flag.IMAGE_Y_NAME), + [Flag.IMAGE_HEIGHT_NAME] = Flag.new(Flag.IMAGE_HEIGHT_NAME), + [Flag.IMAGE_WIDTH_NAME] = Flag.new(Flag.IMAGE_WIDTH_NAME), + [Flag.IMAGE_RADIUS_NAME] = Flag.new(Flag.IMAGE_RADIUS_NAME), + [Flag.IMAGE_QUALITY_NAME] = Flag.new(Flag.IMAGE_QUALITY_NAME), } flagMapper = config.flagImageMap valueMapper = config.flagValueMap @@ -98,8 +100,8 @@ local function main() -- Scale dimensions with respect to limits local maxHeight = (mediaType == File.IMAGE_TYPE and config.maxImageHeight) or config.maxVideoHeight local maxWidth = (mediaType == File.IMAGE_TYPE and config.maxImageWidth) or config.maxVideoWidth - flags.height:scaleDimension(flags.dpr.value, maxHeight) - flags.width:scaleDimension(flags.dpr.value, maxWidth) + flags[Flag.IMAGE_HEIGHT_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxHeight) + flags[Flag.IMAGE_WIDTH_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxWidth) local file = File.new(config, prefix, postfix, filename, mediaType, flags) From 0168f839535484fd1cb69a998bdf6ac0c7a86fce Mon Sep 17 00:00:00 2001 From: JuliaSparkles Date: Thu, 26 Oct 2023 17:09:03 +0400 Subject: [PATCH 25/53] add "TESTING.MD" --- TESTING.MD | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 TESTING.MD diff --git a/TESTING.MD b/TESTING.MD new file mode 100644 index 0000000..5857f44 --- /dev/null +++ b/TESTING.MD @@ -0,0 +1,68 @@ +### **Prerequisites** + +For the script to work correctly, you must place two test files in the directory specified in the **`config.lua`** file under **`config.mediaBaseFilepath`**. + + +- **`leva_test_video_luamp_do_not_rename.mp4`** - for video tests. +- **`leva_test_image_lua_do_not_rename.jpeg`** - for image tests. + +The script should only be run from the `tests` directory. + +### **Usage:** + +The script supports the following modes of operation: + +1. `--init`: initialization - loading the original videos and images for future comparison. This mode should be called once in order to download reference files that test files will be compared against. +2. Without arguments: download and compare both video and image files. +3. `--video`: download and compare video files. +4. `--image`: download and compare image files. + +**Initialization (--init)** + +To initialize, execute the following command: + +```bash +./test.sh --init +``` + +In this mode, the script will download reference videos and images, calculate their hash sums, and create checksum files for future verification. + +**Processing Videos Only (--video)** + +To process video files only, execute the following command: + +```bash +./test.sh --video +``` + +In this mode, the script will download and compare video files with reference video files. + +**Processing Images Only (--image)** + +To process images only, execute the following command: + +```bash +./test.sh --image +``` + +In this mode, the script will download and verify images, comparing them with reference images. + +**Processing Both Videos and Images (No Arguments)** + +The script can also be run without any arguments, which will compare both video and image files with the reference files: + +```bash +./test.sh +``` + +### **How the Script Works** + +The script operates as follows: + +- **Initialization (--init):** It downloads the source videos and images, calculates and records their hash sums in respective files. Within the **`tests`** folder, a directory called **`originals`** is created. Subsequently, **`video`** and **`images`** directories are created within it, where the original/reference files are downloaded. In the **`originals`** folder, **`.txt`** files are created, specifically **`checksums.txt`**, **`checksums_video.txt`**, and **`checksums_images.txt`**. Hash sums of the original files are recorded in the **`.txt`** files for future comparisons. + + +- **Video Testing (--video) and Image Testing (--image):** It loads files for processing and verifies their integrity by comparing them with the original files in the **`originals`** directory. Within the **`tests`** folder, a **`runfiles`** directory is created. Subsequently, **`video`** and **`images`** directories are created within **`runfiles`**, and test files are downloaded into them. In the **`runfiles`** folder, **`.txt`** files are generated, specifically **`checksums_video.txt`** and **`checksums_images.txt`**. In the **`.txt`** files, if the files don't match the originals, it displays an error message; if they match the original files, it shows an **`"OK"`** message. + + +- **Testing Both Video and Images (Without Arguments):** Performs both types of processing as described above. \ No newline at end of file From 3088fcc1573fb4c54267055d8d9483ae6e2cbd49 Mon Sep 17 00:00:00 2001 From: JuliaSparkles Date: Thu, 26 Oct 2023 17:24:22 +0400 Subject: [PATCH 26/53] add config for tests --- .gitignore | 3 ++- TESTING.MD | 4 +++- tests/links_image.example | 2 +- tests/test.config.example | 2 ++ tests/test.sh | 3 +-- 5 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 tests/test.config.example diff --git a/.gitignore b/.gitignore index 5061b43..065ca1f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ config.lua tests/originals tests/runfiles tests/links_image -tests/links_video \ No newline at end of file +tests/links_video +tests/test.config diff --git a/TESTING.MD b/TESTING.MD index 5857f44..29be679 100644 --- a/TESTING.MD +++ b/TESTING.MD @@ -1,4 +1,6 @@ -### **Prerequisites** +### **Setup** + +Copy `test.config.example` into `test.config` and set parameters in it. For the script to work correctly, you must place two test files in the directory specified in the **`config.lua`** file under **`config.mediaBaseFilepath`**. diff --git a/tests/links_image.example b/tests/links_image.example index c943173..cdbf0a6 100644 --- a/tests/links_image.example +++ b/tests/links_image.example @@ -12,4 +12,4 @@ amondo-media/image/upload/w_1200,h_600,c_pad,b_blurred/v1680776555/prod/tile/med amondo-media/image/upload/w_1200,h_600,c_lpad/v1680776555/prod/tile/media/ amondo-media/image/upload/w_1200,h_600,c_lpad,b_blurred/v1680776555/prod/tile/media/ amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ \ No newline at end of file +amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ diff --git a/tests/test.config.example b/tests/test.config.example new file mode 100644 index 0000000..36c1ecb --- /dev/null +++ b/tests/test.config.example @@ -0,0 +1,2 @@ +HOST="http://localhost/" +MEDIA_DIR="/tmp/nginx" diff --git a/tests/test.sh b/tests/test.sh index 65557ff..ecd26b8 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -1,5 +1,4 @@ -HOST="http://localhost/" -MEDIA_DIR="/tmp/nginx" +source test.config CURRENT_DIR=$(pwd) VIDEO_FILE="leva_test_video_luamp_do_not_rename.mp4" IMAGE_FILE="leva_test_image_lua_do_not_rename" From 4e7e5fecc276472668466ad02d72493a5f5d1970 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 2 Nov 2023 11:39:13 +0100 Subject: [PATCH 27/53] Process image transofrmation by image public id --- .gitignore | 4 +++ README.md | 25 +++++++--------- file.lua | 24 +++++++++------ media-processor.lua | 14 +++++---- nginx-lua-mp4.lua | 11 +++++-- nginx.conf.example | 73 +++++++++++++++++++++++++++++++++++++++++++++ utils.lua | 8 ++--- 7 files changed, 123 insertions(+), 36 deletions(-) create mode 100644 nginx.conf.example diff --git a/.gitignore b/.gitignore index c2f2e6e..eb3e32d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .vscode .idea config.lua +tmp +nginx.conf +docker-compose.yml +Dockerfile diff --git a/README.md b/README.md index ea8f758..ff616b9 100644 --- a/README.md +++ b/README.md @@ -62,28 +62,26 @@ http { And here's minimal viable config for 4 locations you need to set up. These locations are described in the sections below: ``` # video location -location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.mp4)$ { +location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; - + # these are needed to be set if you did not use them in regex matching location set $luamp_prefix ""; set $luamp_postfix ""; #pass to transcoder location - try_files $uri @luamp_process; + try_files $uri @luamp_video_process; } -# process/transcode location -location @luamp_process { - content_by_lua_file "/absolute/path/to/nginx-lua-mp4/nginx-lua-mp4.lua"; +# video process/transcode location +location @luamp_video_process { + content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4.lua"; } # image location -location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { - set $luamp_media_type "image"; - +location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { #pass to transcoder location try_files $uri @luamp_media_processor; } @@ -93,7 +91,7 @@ location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; - + # these are needed to be set if you did not use them in regex matching location set $luamp_prefix ""; set $luamp_postfix ""; @@ -141,17 +139,16 @@ luamp_filename: new_year_boardgames_party.mp4 If you do not need prefix and postfix, you can omit them from the regexp, but do make sure you `set` them to an empty string in the location. Here's the minimal viable example for simpler URLs with no prefix/postfix: ``` -location ~ ^/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+\.mp4)$ { +location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; - + # these are needed to be set if you did not use them in regex matching location set $luamp_prefix ""; set $luamp_postfix ""; - #pass to transcoder location - try_files $uri @luamp_process; + content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; } ``` diff --git a/file.lua b/file.lua index e266bdb..99ea794 100644 --- a/file.lua +++ b/file.lua @@ -1,3 +1,4 @@ +local utils = require('utils') local File = {} File.IMAGE_TYPE = 'image' @@ -18,8 +19,9 @@ end ---@param prefix string ---@param postfix string ---@param flags table +---@param mediaType string ---@return string -local function buildCacheDirPath(basePath, prefix, postfix, flags) +local function buildCacheDirPath(basePath, prefix, postfix, flags, mediaType) local flagNamesOrdered = {} -- Add the flag name to the ordered list @@ -30,7 +32,7 @@ local function buildCacheDirPath(basePath, prefix, postfix, flags) table.sort(flagNamesOrdered) -- Generate the options path - local optionsPath = '' + local optionsPath = mediaType .. '/' for _, flagName in ipairs(flagNamesOrdered) do local pathFragment = coalesceFlag(flags[flagName]) @@ -43,15 +45,17 @@ local function buildCacheDirPath(basePath, prefix, postfix, flags) end -- Base class method new -function File.new(config, prefix, postfix, filename, mediaType, flags) +function File.new(config, prefix, postfix, publicId, extension, mediaType, flags) local self = {} self.config = config self.mediaType = mediaType - self.filename = filename - self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags) - self.cachedFilePath = self.cacheDir .. filename - self.originalDir = config.mediaBaseFilepath .. prefix .. postfix - self.originalFilePath = self.originalDir .. filename + self.publicId = publicId + self.extension = extension + self.filename = publicId .. '.' .. extension + self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags, mediaType) + self.cachedFilePath = self.cacheDir .. self.filename + self.originalDir = config.mediaBaseFilepath .. mediaType .. '/originals/' .. prefix .. postfix + self.originalFilePath = self.originalDir .. self.publicId .. '.*' setmetatable(self, { __index = File }) return self end @@ -65,7 +69,9 @@ end ---Checks file has original ---@return boolean function File:hasOriginal() - return File.fileExists(self.originalFilePath) + local cmd = string.format("ls -1 %s | grep '%s'", self.originalDir, self.publicId .. '.*') + local result = utils.captureCommandOutput(cmd) + return (result and result ~= "") or false end -- Check if a file exists diff --git a/media-processor.lua b/media-processor.lua index 0811b5c..eac12c6 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -45,15 +45,17 @@ local function main() -- Get URL params local mediaType = ngx.var.luamp_media_type local prefix = utils.cleanupPath(ngx.var.luamp_prefix) - local luamp_flags = ngx.var.luamp_flags + local luampFlags = ngx.var.luamp_flags local postfix = utils.cleanupPath(ngx.var.luamp_postfix) - local filename = utils.cleanupPath(ngx.var.luamp_filename) + local publicId = utils.cleanupPath(ngx.var.luamp_public_id) + local extension = ngx.var.luamp_extension log('MediaType: ' .. mediaType) log('Prefix: ' .. prefix) - log('Flags: ' .. luamp_flags) + log('Flags: ' .. luampFlags) log('Postfix: ' .. postfix) - log('Filename: ' .. filename) + log('PublicId: ' .. publicId) + log('Extension: ' .. extension) local flags = {} local flagMapper = {} @@ -83,7 +85,7 @@ local function main() end -- Parse flags into a table - for f, v in string.gmatch(luamp_flags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do + for f, v in string.gmatch(luampFlags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do -- Preprocess the flag and value if necessary if config.flagPreprocessHook then f, v = config.flagPreprocessHook(f, v) @@ -103,7 +105,7 @@ local function main() flags[Flag.IMAGE_HEIGHT_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxHeight) flags[Flag.IMAGE_WIDTH_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxWidth) - local file = File.new(config, prefix, postfix, filename, mediaType, flags) + local file = File.new(config, prefix, postfix, publicId, extension, mediaType, flags) -- Serve the cached file if it exists if file:isCached() then diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 44ebd5f..636ef15 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -12,10 +12,13 @@ config.setDefaults({ }) -- Get URL params +local mediaType = ngx.var.luamp_media_type local prefix = utils.cleanupPath(ngx.var.luamp_prefix) local flags = ngx.var.luamp_flags local postfix = utils.cleanupPath(ngx.var.luamp_postfix) -local filename = utils.cleanupPath(ngx.var.luamp_filename) +local publicId = utils.cleanupPath(ngx.var.luamp_public_id) +local extension = ngx.var.luamp_extension +local filename = publicId .. '.' .. extension log('prefix: ' .. prefix) log('flags: ' .. flags) @@ -82,8 +85,10 @@ if optionsPath ~= '' then end -- check if we already have cached version of a file -local cachedFilepath = config.mediaBaseFilepath .. (prefix or '') .. (optionsPath or '') .. (postfix or '') -local originalFilepath = config.mediaBaseFilepath .. (prefix or '') .. (postfix or '') +local cachedFilepath = config.mediaBaseFilepath .. + mediaType .. '/' .. (prefix or '') .. (optionsPath or '') .. (postfix or '') +local originalFilepath = config.mediaBaseFilepath .. + mediaType .. '/originals/' .. (prefix or '') .. (postfix or '') log('checking for cached transcoded version at: ' .. cachedFilepath .. filename) local cachedFile = io.open(cachedFilepath .. filename, 'r') diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..ad31a25 --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,73 @@ + +user nginx; +worker_processes 1; + +error_log logs/error.log info; + +events { + worker_connections 1024; +} + +http { + lua_package_path "/usr/local/openresty/nginx/?.lua;;"; + + server { + listen 80; + + # video location + location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp|mp4))$ { + # these two are required to be set regardless + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + + # these are needed to be set if you did not use them in regex matching location + set $luamp_prefix ""; + set $luamp_postfix ""; + + #pass to transcoder location + try_files $uri @luamp_video_process; + } + + # video process/transcode location + location @luamp_video_process { + content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4.lua"; + } + + # image location + location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + #pass to transcoder location + try_files $uri @luamp_media_processor; + } + + # image process/transcode location + location @luamp_media_processor { + # these two are required to be set regardless + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + + # these are needed to be set if you did not use them in regex matching location + set $luamp_prefix ""; + set $luamp_postfix ""; + + content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; + } + + # cache location + location =/luamp-cache { + internal; + root /; + index off; + + set_unescape_uri $luamp_transcoded_file $arg_luamp_cached_file_path; + + try_files $luamp_transcoded_file =404; + } + + # upstream location + location =/luamp-upstream { + internal; + rewrite ^(.+)$ $luamp_original_file break; + proxy_pass "https://example.com"; + } + } +} diff --git a/utils.lua b/utils.lua index 0a912cc..574358a 100644 --- a/utils.lua +++ b/utils.lua @@ -14,10 +14,10 @@ end ---@param cmd string ---@return any function utils.captureCommandOutput(cmd) - local file = io.popen(cmd) - if file then - local output = file:read('*a') - file:close() + local handle = io.popen(cmd) + if handle then + local output = handle:read('*a') + handle:close() return output end end From e974741a37e80371cf81e3134cdf3a150c989159 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 2 Nov 2023 12:00:57 +0100 Subject: [PATCH 28/53] Fix typo --- file.lua | 2 +- nginx-lua-mp4.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/file.lua b/file.lua index 99ea794..950044d 100644 --- a/file.lua +++ b/file.lua @@ -54,7 +54,7 @@ function File.new(config, prefix, postfix, publicId, extension, mediaType, flags self.filename = publicId .. '.' .. extension self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags, mediaType) self.cachedFilePath = self.cacheDir .. self.filename - self.originalDir = config.mediaBaseFilepath .. mediaType .. '/originals/' .. prefix .. postfix + self.originalDir = config.mediaBaseFilepath .. mediaType .. '/original/' .. prefix .. postfix self.originalFilePath = self.originalDir .. self.publicId .. '.*' setmetatable(self, { __index = File }) return self diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 636ef15..4284b85 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -88,7 +88,7 @@ end local cachedFilepath = config.mediaBaseFilepath .. mediaType .. '/' .. (prefix or '') .. (optionsPath or '') .. (postfix or '') local originalFilepath = config.mediaBaseFilepath .. - mediaType .. '/originals/' .. (prefix or '') .. (postfix or '') + mediaType .. '/original/' .. (prefix or '') .. (postfix or '') log('checking for cached transcoded version at: ' .. cachedFilepath .. filename) local cachedFile = io.open(cachedFilepath .. filename, 'r') From d30f9d918b47187d6812a4c7ad1d12973d671cac Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 8 Nov 2023 16:17:16 +0100 Subject: [PATCH 29/53] Update gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c2f2e6e..eb3e32d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .vscode .idea config.lua +tmp +nginx.conf +docker-compose.yml +Dockerfile From 6f0d44933f4ddaa2db10eaae04ab80749914af5f Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 8 Nov 2023 20:28:41 +0100 Subject: [PATCH 30/53] Update a doc for tests --- TESTING.MD | 2 ++ tests/links_image.example | 2 ++ 2 files changed, 4 insertions(+) diff --git a/TESTING.MD b/TESTING.MD index 29be679..1bd26e8 100644 --- a/TESTING.MD +++ b/TESTING.MD @@ -1,6 +1,8 @@ ### **Setup** Copy `test.config.example` into `test.config` and set parameters in it. +Copy `links_image.example` into `links_image` and set parameters in it. +Copy `links_video.example` into `links_video` and set parameters in it. For the script to work correctly, you must place two test files in the directory specified in the **`config.lua`** file under **`config.mediaBaseFilepath`**. diff --git a/tests/links_image.example b/tests/links_image.example index cdbf0a6..3a0e1db 100644 --- a/tests/links_image.example +++ b/tests/links_image.example @@ -13,3 +13,5 @@ amondo-media/image/upload/w_1200,h_600,c_lpad/v1680776555/prod/tile/media/ amondo-media/image/upload/w_1200,h_600,c_lpad,b_blurred/v1680776555/prod/tile/media/ amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred,r_20/v1680776555/prod/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred,r_20/v1680776555/prod/tile/media/ From fda5cdec8316741e19b12c8a5d9f47f69e85f444 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 20 Nov 2023 15:52:02 +0100 Subject: [PATCH 31/53] Review fixes --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ff616b9..27d30f3 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|) # video process/transcode location location @luamp_video_process { - content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4.lua"; + content_by_lua_file "/absolute/path/to/nginx-lua-mp4/nginx-lua-mp4.lua"; } # image location @@ -96,7 +96,7 @@ location @luamp_media_processor { set $luamp_prefix ""; set $luamp_postfix ""; - content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; + content_by_lua_file "/absolute/path/to/nginx-lua-mp4/media-processor.lua"; } # cache location @@ -148,7 +148,7 @@ location @luamp_media_processor { set $luamp_prefix ""; set $luamp_postfix ""; - content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; + content_by_lua_file "/absolute/path/to/nginx-lua-mp4/media-processor.lua"; } ``` @@ -171,8 +171,8 @@ These sanitisation rules are enough to prevent shell injections and path travers Process location is pretty simple, it just passes execution to the LUA part of luamp module: ``` -location @luamp_process { - content_by_lua_file "/absolute/path/to/nginx-lua-mp4/nginx-lua-mp4.lua"; +location @luamp_media_processor { + content_by_lua_file "/absolute/path/to/nginx-lua-mp4/media-processor.lua"; } ``` From eefa919a564c64bbd272815476e9f701ac68095e Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 22 Nov 2023 15:55:38 +0100 Subject: [PATCH 32/53] Review fixes --- README.md | 18 ++-- command.lua | 12 ++- file.lua | 27 +++--- media-processor.lua | 86 ++++++++++-------- nginx-lua-mp4.lua | 6 +- nginx.conf.example | 4 +- ... leva_test_image_luamp_do_not_rename.jpeg} | Bin tests/test.sh | 2 +- utils.lua | 12 +++ 9 files changed, 99 insertions(+), 68 deletions(-) rename tests/{leva_test_image_lua_do_not_rename.jpeg => leva_test_image_luamp_do_not_rename.jpeg} (100%) diff --git a/README.md b/README.md index 27d30f3..d50e998 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ http { And here's minimal viable config for 4 locations you need to set up. These locations are described in the sections below: ``` # video location -location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { +location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; @@ -81,7 +81,7 @@ location @luamp_video_process { } # image location -location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { +location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { #pass to transcoder location try_files $uri @luamp_media_processor; } @@ -124,16 +124,18 @@ This location used as an entry point and to set initial variables. This is usual There are two variables you need to `set`/initialise: `$luamp_original_file` and `$luamp_transcoded_file`. -There are four variables that may be used as a named capture group in location regex: `luamp_prefix`, `luamp_flags`, `luamp_postfix`, `luamp_filename`. +There are six variables that may be used as a named capture group in location regex: `luamp_prefix`, `luamp_media_type`, `luamp_flags`, `luamp_postfix`, `luamp_media_id`, `luamp_media_extension`. For example: ``` https://example.com/asset/video/width_1980,height_1080,crop_padding/2019/12/new_year_boardgames_party.mp4 -luamp_prefix: asset/video/ +luamp_prefix: asset/ +luamp_media_type: video luamp_flags: width_1980,height_1080,crop_padding luamp_postfix: 2019/12/ -luamp_filename: new_year_boardgames_party.mp4 +luamp_media_id: new_year_boardgames_party +luamp_media_extension: mp4 ``` If you do not need prefix and postfix, you can omit them from the regexp, but do make sure you `set` them to an empty string in the location. Here's the minimal viable example for simpler URLs with no prefix/postfix: @@ -154,7 +156,7 @@ location @luamp_media_processor { ``` #### Security considerations -`prefix`, `postfix` and `filename` are passed to the `os.execute()` with following sanitisation: +`prefix`, `postfix` and `media_id` are passed to the `os.execute()` with following sanitisation: - alphanumeric symbols, underscores, dots and slashes are allowed. - all other symbols are stripped. @@ -164,7 +166,7 @@ These sanitisation rules are enough to prevent shell injections and path travers - `(?[0-9a-zA-Z_\-\.\/]+\/)` - `(?([0-9a-zA-Z_,\.:]+)\/|)` - `(?[0-9a-zA-Z_\-\.\/]+\/)` -- `(?[0-9a-zA-Z_\-\.]+\.mp4)` +- `(?[0-9a-zA-Z_\-\.]+\.mp4)` #### 2.2. Process location @@ -230,7 +232,7 @@ $ nano config.lua When set to `true`, `luamp` will attempt to download missing original videos from the upstream. Set it to `false` if you have original videos provided by other means to this directory: ``` -config.mediaBaseFilepath/$prefix/$postfix/$filename +config.mediaBaseFilepath//original/$prefix/$postfix/$media_id ``` #### `config.ffmpeg` diff --git a/command.lua b/command.lua index 112f867..495c16a 100644 --- a/command.lua +++ b/command.lua @@ -146,11 +146,11 @@ end ---@param flags table ---@return string local function buildCommand(config, file, flags) - if file.mediaType == File.IMAGE_TYPE then + if file.type == File.IMAGE_TYPE then return buildImageProcessingCommand(config, file, flags) end - if file.mediaType == File.VIDEO_TYPE then + if file.type == File.VIDEO_TYPE then return buildVideoProcessingCommand(config, file, flags) end @@ -162,9 +162,11 @@ end ---@param file table ---@param flags table function Command.new(config, file, flags) - local self = { - command = buildCommand(config, file, flags), - } + local self = {} + + self.command = buildCommand(config, file, flags) + self.isValid = self.command ~= nil + setmetatable(self, { __index = Command }) return self end diff --git a/file.lua b/file.lua index 950044d..0b96aca 100644 --- a/file.lua +++ b/file.lua @@ -19,9 +19,9 @@ end ---@param prefix string ---@param postfix string ---@param flags table ----@param mediaType string +---@param type string ---@return string -local function buildCacheDirPath(basePath, prefix, postfix, flags, mediaType) +local function buildCacheDirPath(basePath, prefix, postfix, flags, type) local flagNamesOrdered = {} -- Add the flag name to the ordered list @@ -32,7 +32,7 @@ local function buildCacheDirPath(basePath, prefix, postfix, flags, mediaType) table.sort(flagNamesOrdered) -- Generate the options path - local optionsPath = mediaType .. '/' + local optionsPath = type .. '/' for _, flagName in ipairs(flagNamesOrdered) do local pathFragment = coalesceFlag(flags[flagName]) @@ -45,17 +45,20 @@ local function buildCacheDirPath(basePath, prefix, postfix, flags, mediaType) end -- Base class method new -function File.new(config, prefix, postfix, publicId, extension, mediaType, flags) +function File.new(config, prefix, postfix, id, extension, type, flags) local self = {} self.config = config - self.mediaType = mediaType - self.publicId = publicId + self.id = id + self.type = type self.extension = extension - self.filename = publicId .. '.' .. extension - self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags, mediaType) - self.cachedFilePath = self.cacheDir .. self.filename - self.originalDir = config.mediaBaseFilepath .. mediaType .. '/original/' .. prefix .. postfix - self.originalFilePath = self.originalDir .. self.publicId .. '.*' + self.name = id .. '.' .. extension + self.originalDir = config.mediaBaseFilepath .. type .. '/original/' .. prefix .. postfix + self.originalFilePath = self.originalDir .. id .. '.*' + self.cacheDir = self.originalDir + if not utils.isTableEmpty(flags) then + self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags, type) + end + self.cachedFilePath = self.cacheDir .. self.name setmetatable(self, { __index = File }) return self end @@ -69,7 +72,7 @@ end ---Checks file has original ---@return boolean function File:hasOriginal() - local cmd = string.format("ls -1 %s | grep '%s'", self.originalDir, self.publicId .. '.*') + local cmd = string.format("ls -1 %s | grep '%s'", self.originalDir, self.id .. '.*') local result = utils.captureCommandOutput(cmd) return (result and result ~= "") or false end diff --git a/media-processor.lua b/media-processor.lua index eac12c6..e64f2b8 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -10,7 +10,7 @@ local utils = require('utils') ---@param postfix string ---@param file table local function downloadOriginals(prefix, postfix, file) - local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, file.filename) + local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, file.name) log('Downloading original from ' .. originalsUpstreamPath) ngx.req.discard_body() -- Clear body @@ -24,9 +24,13 @@ local function downloadOriginals(prefix, postfix, file) os.execute('mkdir -p ' .. file.originalDir) local originalFile = io.open(file.originalFilePath, 'w') - originalFile:write(originalReq.body) - originalFile:close() - log('Saved to ' .. file.originalFilePath) + if originalFile then + originalFile:write(originalReq.body) + originalFile:close() + log('Saved to ' .. file.originalFilePath) + else + log('Something went wrong on saving original to ' .. file.originalFilePath) + end else ngx.exit(ngx.HTTP_NOT_FOUND) end @@ -47,35 +51,37 @@ local function main() local prefix = utils.cleanupPath(ngx.var.luamp_prefix) local luampFlags = ngx.var.luamp_flags local postfix = utils.cleanupPath(ngx.var.luamp_postfix) - local publicId = utils.cleanupPath(ngx.var.luamp_public_id) - local extension = ngx.var.luamp_extension + local mediaId = utils.cleanupPath(ngx.var.luamp_media_id) + local mediaExtension = ngx.var.luamp_media_extension log('MediaType: ' .. mediaType) log('Prefix: ' .. prefix) - log('Flags: ' .. luampFlags) log('Postfix: ' .. postfix) - log('PublicId: ' .. publicId) - log('Extension: ' .. extension) + log('Flags: ' .. luampFlags) + log('MediaId: ' .. mediaId) + log('MediaExtension: ' .. mediaExtension) local flags = {} local flagMapper = {} local valueMapper = {} if mediaType == File.IMAGE_TYPE then - flags = { - [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(Flag.IMAGE_BACKGROUND_NAME), - [Flag.IMAGE_CROP_NAME] = Flag.new(Flag.IMAGE_CROP_NAME), - [Flag.IMAGE_DPR_NAME] = Flag.new(Flag.IMAGE_DPR_NAME), - [Flag.IMAGE_GRAVITY_NAME] = Flag.new(Flag.IMAGE_GRAVITY_NAME), - [Flag.IMAGE_X_NAME] = Flag.new(Flag.IMAGE_X_NAME), - [Flag.IMAGE_Y_NAME] = Flag.new(Flag.IMAGE_Y_NAME), - [Flag.IMAGE_HEIGHT_NAME] = Flag.new(Flag.IMAGE_HEIGHT_NAME), - [Flag.IMAGE_WIDTH_NAME] = Flag.new(Flag.IMAGE_WIDTH_NAME), - [Flag.IMAGE_RADIUS_NAME] = Flag.new(Flag.IMAGE_RADIUS_NAME), - [Flag.IMAGE_QUALITY_NAME] = Flag.new(Flag.IMAGE_QUALITY_NAME), - } - flagMapper = config.flagImageMap - valueMapper = config.flagValueMap + if luampFlags ~= '' then + flags = { + [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(Flag.IMAGE_BACKGROUND_NAME), + [Flag.IMAGE_CROP_NAME] = Flag.new(Flag.IMAGE_CROP_NAME), + [Flag.IMAGE_DPR_NAME] = Flag.new(Flag.IMAGE_DPR_NAME), + [Flag.IMAGE_GRAVITY_NAME] = Flag.new(Flag.IMAGE_GRAVITY_NAME), + [Flag.IMAGE_X_NAME] = Flag.new(Flag.IMAGE_X_NAME), + [Flag.IMAGE_Y_NAME] = Flag.new(Flag.IMAGE_Y_NAME), + [Flag.IMAGE_HEIGHT_NAME] = Flag.new(Flag.IMAGE_HEIGHT_NAME), + [Flag.IMAGE_WIDTH_NAME] = Flag.new(Flag.IMAGE_WIDTH_NAME), + [Flag.IMAGE_RADIUS_NAME] = Flag.new(Flag.IMAGE_RADIUS_NAME), + [Flag.IMAGE_QUALITY_NAME] = Flag.new(Flag.IMAGE_QUALITY_NAME), + } + flagMapper = config.flagImageMap + valueMapper = config.flagValueMap + end elseif mediaType == File.VIDEO_TYPE then flags = {} flagMapper = config.flagMap @@ -92,21 +98,27 @@ local function main() end local flag = flags[flagMapper[f]] - -- Set value if flag exists if flag then flag:setValue(v, valueMapper) end end + -- Scale dimensions with respect to limits - local maxHeight = (mediaType == File.IMAGE_TYPE and config.maxImageHeight) or config.maxVideoHeight - local maxWidth = (mediaType == File.IMAGE_TYPE and config.maxImageWidth) or config.maxVideoWidth - flags[Flag.IMAGE_HEIGHT_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxHeight) - flags[Flag.IMAGE_WIDTH_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxWidth) + if flags[Flag.IMAGE_HEIGHT_NAME] then + local maxHeight = (mediaType == File.IMAGE_TYPE and config.maxImageHeight) or config.maxVideoHeight + flags[Flag.IMAGE_HEIGHT_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxHeight) + end + if flags[Flag.IMAGE_WIDTH_NAME] then + local maxWidth = (mediaType == File.IMAGE_TYPE and config.maxImageWidth) or config.maxVideoWidth + flags[Flag.IMAGE_WIDTH_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxWidth) + end - local file = File.new(config, prefix, postfix, publicId, extension, mediaType, flags) + local file = File.new(config, prefix, postfix, mediaId, mediaExtension, mediaType, flags) + log(file.cachedFilePath) + log(file.originalFilePath) -- Serve the cached file if it exists if file:isCached() then log('Serving cached file: ' .. file.cachedFilePath) @@ -129,23 +141,23 @@ local function main() end log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) - local command = Command.new(config, file, flags) + local cmd = Command.new(config, file, flags) local executeSuccess - if command.command then - log('Command: ' .. command.command) - executeSuccess = command:execute() + if cmd.isValid then + log('Command: ' .. cmd.command) + executeSuccess = cmd:execute() end - if executeSuccess == nil then + if executeSuccess then + log('Transcoded version is good, serving it') + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) + else log('Transcode failed') if config.serveOriginalOnTranscodeFailure == true then log('Serving original from: ' .. file.originalFilePath) ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) end - else - log('Transcoded version is good, serving it') - ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) end end diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 4284b85..d06db4c 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -16,9 +16,9 @@ local mediaType = ngx.var.luamp_media_type local prefix = utils.cleanupPath(ngx.var.luamp_prefix) local flags = ngx.var.luamp_flags local postfix = utils.cleanupPath(ngx.var.luamp_postfix) -local publicId = utils.cleanupPath(ngx.var.luamp_public_id) -local extension = ngx.var.luamp_extension -local filename = publicId .. '.' .. extension +local mediaId = utils.cleanupPath(ngx.var.luamp_media_id) +local mediaExtension = ngx.var.luamp_media_extension +local filename = mediaId .. '.' .. mediaExtension log('prefix: ' .. prefix) log('flags: ' .. flags) diff --git a/nginx.conf.example b/nginx.conf.example index ad31a25..42f42ca 100644 --- a/nginx.conf.example +++ b/nginx.conf.example @@ -15,7 +15,7 @@ http { listen 80; # video location - location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp|mp4))$ { + location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp|mp4))$ { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; @@ -34,7 +34,7 @@ http { } # image location - location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { #pass to transcoder location try_files $uri @luamp_media_processor; } diff --git a/tests/leva_test_image_lua_do_not_rename.jpeg b/tests/leva_test_image_luamp_do_not_rename.jpeg similarity index 100% rename from tests/leva_test_image_lua_do_not_rename.jpeg rename to tests/leva_test_image_luamp_do_not_rename.jpeg diff --git a/tests/test.sh b/tests/test.sh index ecd26b8..aafff93 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -1,7 +1,7 @@ source test.config CURRENT_DIR=$(pwd) VIDEO_FILE="leva_test_video_luamp_do_not_rename.mp4" -IMAGE_FILE="leva_test_image_lua_do_not_rename" +IMAGE_FILE="leva_test_image_luamp_do_not_rename" IMAGE_FORMATS=("jpeg") LINKS_VIDEO=($(cat $CURRENT_DIR/links_video)) LINKS_IMAGES=($(cat $CURRENT_DIR/links_image)) diff --git a/utils.lua b/utils.lua index 574358a..5b7bb81 100644 --- a/utils.lua +++ b/utils.lua @@ -22,4 +22,16 @@ function utils.captureCommandOutput(cmd) end end +---Check table is empty +---@param table table +---@return boolean +function utils.isTableEmpty(table) + for _ in pairs(table) do + -- If anything is in the table, return false + return false + end + -- If the loop didn't return anything, the table is empty + return true +end + return utils From 572df112939d7b28c660caf871c710544b0f6320 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 10 Jan 2024 18:21:09 +0100 Subject: [PATCH 33/53] Change the way of storing media --- .gitignore | 1 + command.lua | 15 ++++----- file.lua | 23 +++++++------- luamp-locations.conf.example | 53 +++++++++++++++++++++++++++++++ media-processor.lua | 24 +++++++-------- nginx-lua-mp4.lua | 16 +++++----- nginx.conf.example | 60 ++---------------------------------- 7 files changed, 94 insertions(+), 98 deletions(-) create mode 100644 luamp-locations.conf.example diff --git a/.gitignore b/.gitignore index b300b3e..3b0f189 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ config.lua tmp nginx.conf +luamp-locations.conf docker-compose.yml Dockerfile tests/originals diff --git a/command.lua b/command.lua index 495c16a..1e660a5 100644 --- a/command.lua +++ b/command.lua @@ -10,16 +10,16 @@ local function getCanvas(config, file, flags) if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. file.originalFilePath .. + local cmd = config.magick .. ' ' .. file.originalFileIdPath .. ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' local dominantColors = utils.captureCommandOutput(cmd) - canvas = file.originalFilePath .. ' -size %wx%h gradient:' .. dominantColors .. ' -delete 0 ' + canvas = file.originalFileIdPath .. ' -size %wx%h gradient:' .. dominantColors .. ' -delete 0 ' elseif background == 'blurred' then - canvas = file.originalFilePath .. ' -crop 80%x80% +repage -blur 0x8 ' + canvas = file.originalFileIdPath .. ' -crop 80%x80% +repage -blur 0x8 ' else - canvas = file.originalFilePath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' + canvas = file.originalFileIdPath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' end return canvas @@ -47,7 +47,7 @@ local function buildImageProcessingCommand(config, file, flags) ' -quality ' .. quality .. ' -gravity ' .. gravity .. ' ' local canvas = getCanvas(config, file, flags) - local image = file.originalFilePath .. ' -modulate 100,120,100 ' + local image = file.originalFileIdPath .. ' -modulate 100,120,100 ' local mask = '-size %[origwidth]x%[origheight]' .. ' xc:black' .. @@ -165,7 +165,7 @@ function Command.new(config, file, flags) local self = {} self.command = buildCommand(config, file, flags) - self.isValid = self.command ~= nil + self.isValid = self.command and self.command ~= '' setmetatable(self, { __index = Command }) return self @@ -174,9 +174,10 @@ end -- Execute command ---@return boolean? function Command:execute() - if self.command and self.command ~= '' then + if self.isValid then return os.execute(self.command) end + return false end return Command diff --git a/file.lua b/file.lua index 0b96aca..0984668 100644 --- a/file.lua +++ b/file.lua @@ -16,12 +16,13 @@ end ---Build cache dir path ---@param basePath string ----@param prefix string ----@param postfix string ---@param flags table ----@param type string ---@return string -local function buildCacheDirPath(basePath, prefix, postfix, flags, type) +local function buildCacheDirPath(basePath, flags) + if utils.isTableEmpty(flags) then + return basePath + end + local flagNamesOrdered = {} -- Add the flag name to the ordered list @@ -32,7 +33,7 @@ local function buildCacheDirPath(basePath, prefix, postfix, flags, type) table.sort(flagNamesOrdered) -- Generate the options path - local optionsPath = type .. '/' + local optionsPath = '' for _, flagName in ipairs(flagNamesOrdered) do local pathFragment = coalesceFlag(flags[flagName]) @@ -41,7 +42,7 @@ local function buildCacheDirPath(basePath, prefix, postfix, flags, type) end end - return basePath .. prefix .. optionsPath .. postfix + return basePath .. optionsPath end -- Base class method new @@ -52,12 +53,10 @@ function File.new(config, prefix, postfix, id, extension, type, flags) self.type = type self.extension = extension self.name = id .. '.' .. extension - self.originalDir = config.mediaBaseFilepath .. type .. '/original/' .. prefix .. postfix - self.originalFilePath = self.originalDir .. id .. '.*' - self.cacheDir = self.originalDir - if not utils.isTableEmpty(flags) then - self.cacheDir = buildCacheDirPath(config.mediaBaseFilepath, prefix, postfix, flags, type) - end + self.originalDir = config.mediaBaseFilepath .. prefix .. postfix + self.originalFilePath = self.originalDir .. self.name + self.originalFileIdPath = self.originalDir .. id .. '.*' + self.cacheDir = buildCacheDirPath(self.originalDir, flags) self.cachedFilePath = self.cacheDir .. self.name setmetatable(self, { __index = File }) return self diff --git a/luamp-locations.conf.example b/luamp-locations.conf.example new file mode 100644 index 0000000..a9d258e --- /dev/null +++ b/luamp-locations.conf.example @@ -0,0 +1,53 @@ +# This file contains the shared location blocks for both HTTP and HTTPS servers + +# video location +location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { + set $luamp_media_type "video"; + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + # these are needed to be set if you did not use them in regex matching location + # set $luamp_prefix ""; + # set $luamp_postfix ""; + try_files $uri @luamp_video_process; +} + +# video process/transcode location +location @luamp_video_process { + content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4/nginx-lua-mp4.lua"; +} + +# image location +location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + set $luamp_media_type "image"; + # these are needed to be set if you did not use them in regex matching location + # set $luamp_prefix ""; + # set $luamp_postfix ""; + try_files $uri @luamp_media_processor; +} + + +# image process/transcode location +location @luamp_media_processor { + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + + content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4/media-processor.lua"; +} + +# cache location +location =/luamp-cache { + internal; + root /; + index off; + expires 24h; + set_unescape_uri $luamp_transcoded_file $arg_luamp_cached_file_path; + try_files $luamp_transcoded_file =404; +} + +# upstream location +location =/luamp-upstream { + internal; + proxy_ssl_server_name on; + rewrite ^(.+)$ $luamp_original_file break; + proxy_pass "https://example.com"; +} diff --git a/media-processor.lua b/media-processor.lua index e64f2b8..f03abcb 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -117,8 +117,6 @@ local function main() local file = File.new(config, prefix, postfix, mediaId, mediaExtension, mediaType, flags) - log(file.cachedFilePath) - log(file.originalFilePath) -- Serve the cached file if it exists if file:isCached() then log('Serving cached file: ' .. file.cachedFilePath) @@ -142,22 +140,22 @@ local function main() log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local cmd = Command.new(config, file, flags) - local executeSuccess - if cmd.isValid then - log('Command: ' .. cmd.command) - executeSuccess = cmd:execute() - end + local executeSuccess = cmd:execute() if executeSuccess then log('Transcoded version is good, serving it') ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) - else - log('Transcode failed') + end - if config.serveOriginalOnTranscodeFailure == true then - log('Serving original from: ' .. file.originalFilePath) - ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) - end + log('Transcode failed') + + if not cmd.isValid then + log('Invalid command') + end + + if config.serveOriginalOnTranscodeFailure == true then + log('Serving original from: ' .. file.originalFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) end end diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index d06db4c..2fc33b6 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -20,10 +20,12 @@ local mediaId = utils.cleanupPath(ngx.var.luamp_media_id) local mediaExtension = ngx.var.luamp_media_extension local filename = mediaId .. '.' .. mediaExtension -log('prefix: ' .. prefix) -log('flags: ' .. flags) -log('postfix: ' .. postfix) -log('filename: ' .. filename) +log('MediaType: ' .. mediaType) +log('Prefix: ' .. prefix) +log('Postfix: ' .. postfix) +log('Flags: ' .. flags) +log('MediaId: ' .. mediaId) +log('MediaExtension: ' .. mediaExtension) -- Initialize flag-related variables local flagValues = {} @@ -85,10 +87,8 @@ if optionsPath ~= '' then end -- check if we already have cached version of a file -local cachedFilepath = config.mediaBaseFilepath .. - mediaType .. '/' .. (prefix or '') .. (optionsPath or '') .. (postfix or '') -local originalFilepath = config.mediaBaseFilepath .. - mediaType .. '/original/' .. (prefix or '') .. (postfix or '') +local originalFilepath = config.mediaBaseFilepath .. (prefix or '') .. (postfix or '') +local cachedFilepath = originalFilepath .. (optionsPath or '') log('checking for cached transcoded version at: ' .. cachedFilepath .. filename) local cachedFile = io.open(cachedFilepath .. filename, 'r') diff --git a/nginx.conf.example b/nginx.conf.example index 42f42ca..01a9dd6 100644 --- a/nginx.conf.example +++ b/nginx.conf.example @@ -1,5 +1,3 @@ - -user nginx; worker_processes 1; error_log logs/error.log info; @@ -9,65 +7,11 @@ events { } http { - lua_package_path "/usr/local/openresty/nginx/?.lua;;"; + lua_package_path "/usr/local/openresty/nginx/nginx-lua-mp4/?.lua;;"; server { listen 80; - - # video location - location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp|mp4))$ { - # these two are required to be set regardless - set $luamp_original_file ""; - set $luamp_transcoded_file ""; - - # these are needed to be set if you did not use them in regex matching location - set $luamp_prefix ""; - set $luamp_postfix ""; - - #pass to transcoder location - try_files $uri @luamp_video_process; - } - - # video process/transcode location - location @luamp_video_process { - content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4.lua"; - } - - # image location - location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { - #pass to transcoder location - try_files $uri @luamp_media_processor; - } - - # image process/transcode location - location @luamp_media_processor { - # these two are required to be set regardless - set $luamp_original_file ""; - set $luamp_transcoded_file ""; - - # these are needed to be set if you did not use them in regex matching location - set $luamp_prefix ""; - set $luamp_postfix ""; - - content_by_lua_file "/usr/local/openresty/nginx/media-processor.lua"; - } - - # cache location - location =/luamp-cache { - internal; - root /; - index off; - - set_unescape_uri $luamp_transcoded_file $arg_luamp_cached_file_path; - - try_files $luamp_transcoded_file =404; - } - # upstream location - location =/luamp-upstream { - internal; - rewrite ^(.+)$ $luamp_original_file break; - proxy_pass "https://example.com"; - } + include ./luamp-locations.conf; } } From a66e1d8a57a203d1319a72fde0d5f492945cccaf Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 17 Jan 2024 15:14:07 +0100 Subject: [PATCH 34/53] Add minpads for lpad cropping --- README.md | 54 ++++++++++++++++++++++++++++++--------------- command.lua | 29 ++++++++++++++++-------- config.lua.example | 1 + file.lua | 5 ++++- flag.lua | 34 +++++++++++++++++++--------- media-processor.lua | 39 +++++++++++++++++--------------- 6 files changed, 106 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index d50e998..ef39ebf 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,12 @@ http { And here's minimal viable config for 4 locations you need to set up. These locations are described in the sections below: ``` # video location -location ~ ^/(?(video))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { +location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { # these two are required to be set regardless + set $luamp_media_type "video"; set $luamp_original_file ""; set $luamp_transcoded_file ""; - + # these are needed to be set if you did not use them in regex matching location set $luamp_prefix ""; set $luamp_postfix ""; @@ -81,7 +82,12 @@ location @luamp_video_process { } # image location -location ~ ^/(?(image))/(?([0-9a-zA-Z_,\.:]+)\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { +location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { + # these are needed to be set if you did not use them in regex matching location + set $luamp_media_type "image"; + set $luamp_prefix ""; + set $luamp_postfix ""; + #pass to transcoder location try_files $uri @luamp_media_processor; } @@ -91,10 +97,6 @@ location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; - - # these are needed to be set if you did not use them in regex matching location - set $luamp_prefix ""; - set $luamp_postfix ""; content_by_lua_file "/absolute/path/to/nginx-lua-mp4/media-processor.lua"; } @@ -124,14 +126,13 @@ This location used as an entry point and to set initial variables. This is usual There are two variables you need to `set`/initialise: `$luamp_original_file` and `$luamp_transcoded_file`. -There are six variables that may be used as a named capture group in location regex: `luamp_prefix`, `luamp_media_type`, `luamp_flags`, `luamp_postfix`, `luamp_media_id`, `luamp_media_extension`. +There are six variables that may be used as a named capture group in location regex: `luamp_prefix`, `luamp_flags`, `luamp_postfix`, `luamp_media_id`, `luamp_media_extension`. For example: ``` https://example.com/asset/video/width_1980,height_1080,crop_padding/2019/12/new_year_boardgames_party.mp4 -luamp_prefix: asset/ -luamp_media_type: video +luamp_prefix: asset/video/ luamp_flags: width_1980,height_1080,crop_padding luamp_postfix: 2019/12/ luamp_media_id: new_year_boardgames_party @@ -145,10 +146,6 @@ location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; set $luamp_transcoded_file ""; - - # these are needed to be set if you did not use them in regex matching location - set $luamp_prefix ""; - set $luamp_postfix ""; content_by_lua_file "/absolute/path/to/nginx-lua-mp4/media-processor.lua"; } @@ -174,6 +171,10 @@ Process location is pretty simple, it just passes execution to the LUA part of l ``` location @luamp_media_processor { + # these two are required to be set regardless + set $luamp_original_file ""; + set $luamp_transcoded_file ""; + content_by_lua_file "/absolute/path/to/nginx-lua-mp4/media-processor.lua"; } ``` @@ -244,6 +245,15 @@ $ which ffmpeg /usr/local/bin/ffmpeg ``` +#### `config.magick` + +Path to the `magick` executable. Can be figured out by using `which` command in the terminal: + +``` +$ which magick +/usr/local/bin/magick +``` + #### `config.ffmpegDevNull` Where to redirect `ffmpeg` output if `config.logFfmpegOutput` is set to false. @@ -337,9 +347,9 @@ Log level, available values: `ngx.STDERR`, `ngx.EMERG`, `ngx.ALERT`, `ngx.CRIT`, Whether to prepend `ffmpeg` command with `time` utility, if you wish to log time spent in transcoding. -#### `config.maxHeight` and `config.maxWidth` +#### `config.maxVideoHeight`,`config.maxVideoWidth`, `config.maxImageHeight` and `config.maxImageWidth` -Limit the output video's maximum height or width. If the resulting height or width is exceeding the limit (for example, after a high DPR calculation), it will be capped at the `config.maxHeight` and `config.maxWidth`. +Limit the maximum height or width of the output media. If the resulting height or width exceeds the limit (for example, after a high DPR calculation), it will be limited to the specified limits. #### `config.mediaBaseFilepath` @@ -355,6 +365,14 @@ During the transcoding, errors may occur and ffmpeg sometimes leaves corrupt fil Serve original file when transcode failed. If set to `false`, luamp will respond with 404 in this case +#### `config.stripColorProfile` + +Removes a color profile from the original file. + +#### `config.colorProfilePath` + +Applies a color profile from specified location. + ## Update To update luamp, just do a `git pull`. @@ -389,9 +407,9 @@ You definitely want to keep what was customized by you but also to get new confi ``` nginx-lua-mp4 $ sdiff -s config.lua config.lua.example | grep -e '\s*>' | sed -ne "s/^[[:space:]]*>\t//p" -- top limit for output video height (default 4k UHD) -config.maxHeight = 2160 +config.maxVideoHeight = 2160 -- top limit for output video width (default 4k UHD) -config.maxWidth = 3840 +config.maxVideoWidth = 3840 ``` You can now just copy and paste these lines above the `return config` in `config.lua` and that's it 👌 diff --git a/command.lua b/command.lua index 1e660a5..e02a24a 100644 --- a/command.lua +++ b/command.lua @@ -17,7 +17,7 @@ local function getCanvas(config, file, flags) canvas = file.originalFileIdPath .. ' -size %wx%h gradient:' .. dominantColors .. ' -delete 0 ' elseif background == 'blurred' then - canvas = file.originalFileIdPath .. ' -crop 80%x80% +repage -blur 0x8 ' + canvas = file.originalFileIdPath .. ' -crop 80%x80% +repage -scale 10% -blur 0x2.5 -resize 1000% ' else canvas = file.originalFileIdPath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' end @@ -25,6 +25,21 @@ local function getCanvas(config, file, flags) return canvas end +local function getMask(radius) + local mask = + ' -size %[origwidth]x%[origheight]' .. + ' xc:black' .. + ' -fill white' + + if radius then + mask = mask .. ' -draw "roundrectangle 0,0,%[origwidth],%[origheight],' .. radius .. ',' .. radius .. '"' + else + mask = mask .. ' -draw "rectangle 0,0,%[origwidth],%[origheight]"' + end + + return mask .. ' -alpha Copy' +end + -- Build image processing command ---@param config table ---@param file table @@ -39,6 +54,7 @@ local function buildImageProcessingCommand(config, file, flags) local height = flags[Flag.IMAGE_HEIGHT_NAME].value local radius = flags[Flag.IMAGE_RADIUS_NAME].value local quality = flags[Flag.IMAGE_QUALITY_NAME].value + local minpad = flags[Flag.IMAGE_MINPAD_NAME].value -- Construct a command local command = '' @@ -47,13 +63,8 @@ local function buildImageProcessingCommand(config, file, flags) ' -quality ' .. quality .. ' -gravity ' .. gravity .. ' ' local canvas = getCanvas(config, file, flags) - local image = file.originalFileIdPath .. ' -modulate 100,120,100 ' - local mask = - '-size %[origwidth]x%[origheight]' .. - ' xc:black' .. - ' -fill white' .. - ' -draw "roundrectangle 0,0,%[origwidth],%[origheight],' .. radius .. ',' .. radius .. '"' .. - ' -alpha Copy' + local image = file.originalFileIdPath .. ' -modulate 100,120,100' + local mask = getMask(radius) local dimensions = (width or '') .. 'x' .. (height or '') if crop == 'fill' and (width or height) then @@ -76,7 +87,7 @@ local function buildImageProcessingCommand(config, file, flags) ' -crop ' .. dimensions .. '+0+0 ' .. ' \\( ' .. image .. - ' -resize ' .. dimensions .. '\\>' .. + ' -resize ' .. (width - (minpad or 0)) .. 'x' .. (height - (minpad or 0)) .. '\\>' .. ' -set option:origwidth %w' .. ' -set option:origheight %h' .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. diff --git a/config.lua.example b/config.lua.example index 6d701f0..0d839cb 100644 --- a/config.lua.example +++ b/config.lua.example @@ -66,6 +66,7 @@ config.flagImageMap = { w = Flag.IMAGE_WIDTH_NAME, r = Flag.IMAGE_RADIUS_NAME, q = Flag.IMAGE_QUALITY_NAME, + minp = Flag.IMAGE_MINPAD_NAME, } -- override URL flag values. Useful when you migrate from another transcoding solution and already have diff --git a/file.lua b/file.lua index 0984668..4371053 100644 --- a/file.lua +++ b/file.lua @@ -27,7 +27,10 @@ local function buildCacheDirPath(basePath, flags) -- Add the flag name to the ordered list for flagName, _ in pairs(flags) do - table.insert(flagNamesOrdered, flagName) + local flag = flags[flagName] + if flag.makeDir then + table.insert(flagNamesOrdered, flagName) + end end -- Sort flags so path will be the same for `w_1280,h_960` and `h_960,w_1280` table.sort(flagNamesOrdered) diff --git a/flag.lua b/flag.lua index 57535f1..b84f5c4 100644 --- a/flag.lua +++ b/flag.lua @@ -10,22 +10,32 @@ Flag.IMAGE_HEIGHT_NAME = 'height' Flag.IMAGE_WIDTH_NAME = 'width' Flag.IMAGE_RADIUS_NAME = 'radius' Flag.IMAGE_QUALITY_NAME = 'quality' +Flag.IMAGE_MINPAD_NAME = 'minpad' local IMAGE_DEFAULTS = { [Flag.IMAGE_BACKGROUND_NAME] = 'white', - [Flag.IMAGE_DPR_NAME] = 1, [Flag.IMAGE_GRAVITY_NAME] = 'center', [Flag.IMAGE_X_NAME] = 0, [Flag.IMAGE_Y_NAME] = 0, - [Flag.IMAGE_RADIUS_NAME] = 0.1, [Flag.IMAGE_QUALITY_NAME] = 80 } -- Base class method new -function Flag.new(name, value) +function Flag.new(config, name, value) local self = {} + self.config = config self.name = name self.value = value or IMAGE_DEFAULTS[name] + self.isScalable = false + self.makeDir = true + + if self.name == Flag.IMAGE_HEIGHT_NAME or self.name == Flag.IMAGE_WIDTH_NAME or self.name == Flag.IMAGE_RADIUS_NAME or self.name == Flag.IMAGE_MINPAD_NAME then + self.isScalable = true + end + + if self.name == Flag.IMAGE_DPR_NAME then + self.makeDir = false + end setmetatable(self, { __index = Flag }) return self @@ -41,15 +51,19 @@ function Flag:setValue(value, valueMapper) end end --- Apply limits to a given dimension +-- Scale dimension ---@param dpr number ----@param max number -function Flag:scaleDimension(dpr, max) - if (self.name == Flag.IMAGE_HEIGHT_NAME or self.name == Flag.IMAGE_WIDTH_NAME) and self.value and dpr then - self.value = math.ceil(self.value * dpr) +function Flag:scale(dpr) + if self.value and self.value ~= '' then + self.value = math.ceil(self.value * (dpr or 1)) + + -- Apply limits + if self.name == Flag.IMAGE_HEIGHT_NAME and self.config.maxImageHeight and self.value > self.config.maxImageHeight then + self.value = self.config.maxImageHeight + end - if self.value > max then - self.value = max + if self.name == Flag.IMAGE_WIDTH_NAME and self.config.maxImageWidth and self.value > self.config.maxImageWidth then + self.value = self.config.maxImageWidth end end end diff --git a/media-processor.lua b/media-processor.lua index f03abcb..e5997ad 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -68,16 +68,17 @@ local function main() if mediaType == File.IMAGE_TYPE then if luampFlags ~= '' then flags = { - [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(Flag.IMAGE_BACKGROUND_NAME), - [Flag.IMAGE_CROP_NAME] = Flag.new(Flag.IMAGE_CROP_NAME), - [Flag.IMAGE_DPR_NAME] = Flag.new(Flag.IMAGE_DPR_NAME), - [Flag.IMAGE_GRAVITY_NAME] = Flag.new(Flag.IMAGE_GRAVITY_NAME), - [Flag.IMAGE_X_NAME] = Flag.new(Flag.IMAGE_X_NAME), - [Flag.IMAGE_Y_NAME] = Flag.new(Flag.IMAGE_Y_NAME), - [Flag.IMAGE_HEIGHT_NAME] = Flag.new(Flag.IMAGE_HEIGHT_NAME), - [Flag.IMAGE_WIDTH_NAME] = Flag.new(Flag.IMAGE_WIDTH_NAME), - [Flag.IMAGE_RADIUS_NAME] = Flag.new(Flag.IMAGE_RADIUS_NAME), - [Flag.IMAGE_QUALITY_NAME] = Flag.new(Flag.IMAGE_QUALITY_NAME), + [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(config, Flag.IMAGE_BACKGROUND_NAME), + [Flag.IMAGE_CROP_NAME] = Flag.new(config, Flag.IMAGE_CROP_NAME), + [Flag.IMAGE_DPR_NAME] = Flag.new(config, Flag.IMAGE_DPR_NAME), + [Flag.IMAGE_GRAVITY_NAME] = Flag.new(config, Flag.IMAGE_GRAVITY_NAME), + [Flag.IMAGE_X_NAME] = Flag.new(config, Flag.IMAGE_X_NAME), + [Flag.IMAGE_Y_NAME] = Flag.new(config, Flag.IMAGE_Y_NAME), + [Flag.IMAGE_HEIGHT_NAME] = Flag.new(config, Flag.IMAGE_HEIGHT_NAME), + [Flag.IMAGE_WIDTH_NAME] = Flag.new(config, Flag.IMAGE_WIDTH_NAME), + [Flag.IMAGE_RADIUS_NAME] = Flag.new(config, Flag.IMAGE_RADIUS_NAME), + [Flag.IMAGE_QUALITY_NAME] = Flag.new(config, Flag.IMAGE_QUALITY_NAME), + [Flag.IMAGE_MINPAD_NAME] = Flag.new(config, Flag.IMAGE_MINPAD_NAME), } flagMapper = config.flagImageMap valueMapper = config.flagValueMap @@ -104,15 +105,16 @@ local function main() end end - -- Scale dimensions with respect to limits - if flags[Flag.IMAGE_HEIGHT_NAME] then - local maxHeight = (mediaType == File.IMAGE_TYPE and config.maxImageHeight) or config.maxVideoHeight - flags[Flag.IMAGE_HEIGHT_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxHeight) - end - if flags[Flag.IMAGE_WIDTH_NAME] then - local maxWidth = (mediaType == File.IMAGE_TYPE and config.maxImageWidth) or config.maxVideoWidth - flags[Flag.IMAGE_WIDTH_NAME]:scaleDimension(flags[Flag.IMAGE_DPR_NAME].value, maxWidth) + local dpr = flags[Flag.IMAGE_DPR_NAME] + if dpr and dpr.value then + for flagName, _ in pairs(flags) do + local flag = flags[flagName] + if flag.isScalable then + log('Scaling flag: ' .. flagName) + flag:scale(dpr.value) + end + end end local file = File.new(config, prefix, postfix, mediaId, mediaExtension, mediaType, flags) @@ -140,6 +142,7 @@ local function main() log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local cmd = Command.new(config, file, flags) + log('Command: ' .. cmd.command) local executeSuccess = cmd:execute() if executeSuccess then From 3907f1be830efaeddba86e6c4e6ed8bede782fa2 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 17 Jan 2024 15:24:06 +0100 Subject: [PATCH 35/53] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ef39ebf..f2a3169 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ $ nano config.lua When set to `true`, `luamp` will attempt to download missing original videos from the upstream. Set it to `false` if you have original videos provided by other means to this directory: ``` -config.mediaBaseFilepath//original/$prefix/$postfix/$media_id +config.mediaBaseFilepath/$prefix/$postfix/$media_id.$media_extension ``` #### `config.ffmpeg` From e8be0bbef04367101da79953e22a316cd725a8f0 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 23 Jan 2024 13:08:23 +0100 Subject: [PATCH 36/53] Fix returning of upstreamed file if no flags passed --- media-processor.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/media-processor.lua b/media-processor.lua index e5997ad..2c7f70a 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -140,6 +140,12 @@ local function main() end end + -- Serve the cached file if it exists + if file:isCached() then + log('Serving cached file: ' .. file.cachedFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) + end + log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local cmd = Command.new(config, file, flags) log('Command: ' .. cmd.command) From f4d150e37a0723fce61ea915e30eabdac910d2bf Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 23 Jan 2024 13:10:00 +0100 Subject: [PATCH 37/53] Change dir hierarchy --- command.lua | 10 +++++----- file.lua | 7 ++----- nginx-lua-mp4.lua | 5 +++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/command.lua b/command.lua index e02a24a..e04a0ce 100644 --- a/command.lua +++ b/command.lua @@ -10,16 +10,16 @@ local function getCanvas(config, file, flags) if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. file.originalFileIdPath .. + local cmd = config.magick .. ' ' .. file.originalFilePath .. ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' local dominantColors = utils.captureCommandOutput(cmd) - canvas = file.originalFileIdPath .. ' -size %wx%h gradient:' .. dominantColors .. ' -delete 0 ' + canvas = file.originalFilePath .. ' -size %wx%h gradient:' .. dominantColors .. ' -delete 0 ' elseif background == 'blurred' then - canvas = file.originalFileIdPath .. ' -crop 80%x80% +repage -scale 10% -blur 0x2.5 -resize 1000% ' + canvas = file.originalFilePath .. ' -crop 80%x80% +repage -scale 10% -blur 0x2.5 -resize 1000% ' else - canvas = file.originalFileIdPath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' + canvas = file.originalFilePath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' end return canvas @@ -63,7 +63,7 @@ local function buildImageProcessingCommand(config, file, flags) ' -quality ' .. quality .. ' -gravity ' .. gravity .. ' ' local canvas = getCanvas(config, file, flags) - local image = file.originalFileIdPath .. ' -modulate 100,120,100' + local image = file.originalFilePath .. ' -modulate 100,120,100' local mask = getMask(radius) local dimensions = (width or '') .. 'x' .. (height or '') diff --git a/file.lua b/file.lua index 4371053..a7a519a 100644 --- a/file.lua +++ b/file.lua @@ -56,9 +56,8 @@ function File.new(config, prefix, postfix, id, extension, type, flags) self.type = type self.extension = extension self.name = id .. '.' .. extension - self.originalDir = config.mediaBaseFilepath .. prefix .. postfix + self.originalDir = config.mediaBaseFilepath .. prefix .. postfix .. id .. '/' .. extension .. '/' self.originalFilePath = self.originalDir .. self.name - self.originalFileIdPath = self.originalDir .. id .. '.*' self.cacheDir = buildCacheDirPath(self.originalDir, flags) self.cachedFilePath = self.cacheDir .. self.name setmetatable(self, { __index = File }) @@ -74,9 +73,7 @@ end ---Checks file has original ---@return boolean function File:hasOriginal() - local cmd = string.format("ls -1 %s | grep '%s'", self.originalDir, self.id .. '.*') - local result = utils.captureCommandOutput(cmd) - return (result and result ~= "") or false + return File.fileExists(self.originalFilePath) end -- Check if a file exists diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 2fc33b6..590398c 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -87,7 +87,8 @@ if optionsPath ~= '' then end -- check if we already have cached version of a file -local originalFilepath = config.mediaBaseFilepath .. (prefix or '') .. (postfix or '') +local originalFilepath = config.mediaBaseFilepath .. + (prefix or '') .. (postfix or '') .. mediaId .. '/' .. mediaExtension .. '/' local cachedFilepath = originalFilepath .. (optionsPath or '') log('checking for cached transcoded version at: ' .. cachedFilepath .. filename) local cachedFile = io.open(cachedFilepath .. filename, 'r') @@ -112,7 +113,7 @@ if cachedFile == nil then -- fetch local originalReq = ngx.location.capture('/luamp-upstream', { vars = { luamp_original_file = config.getOriginalsUpstreamPath(prefix, postfix, filename) } }) - log('upstream status: ' .. originalReq.status) + log('upstream status: ' .. originalReq.body) if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then log('downloaded original, saving') os.execute('mkdir -p ' .. originalFilepath) From 80fd4a1c83ea55748ad083d7b28f1291e8afb9c5 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 24 Jan 2024 13:17:12 +0100 Subject: [PATCH 38/53] Fix blur param for videos --- README.md | 4 ++-- nginx-lua-mp4.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f2a3169..e7b6c99 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ Default flag values, e.g. `c_pad` or `c_lpad`, also `b_blurred`: config.flagValueMap = { ['pad'] = 'padding', ['lpad'] = 'limited_padding', - ['blurred'] = 'blur', + ['blurred'] = 'blurred', } ``` @@ -323,7 +323,7 @@ Full flag values, e.g. `c_padding` or `c_limited-padding`: config.flagValueMap = { ['pading'] = 'padding', ['limited-padding'] = 'limited_padding', - ['blurred'] = 'blur', + ['blurred'] = 'blurred', } ``` diff --git a/nginx-lua-mp4.lua b/nginx-lua-mp4.lua index 590398c..18609d8 100755 --- a/nginx-lua-mp4.lua +++ b/nginx-lua-mp4.lua @@ -200,7 +200,7 @@ if cachedFile == nil then -- create command local command - if (flagValues['background'] ~= nil and flagValues['background'] == 'blur' and flagValues['crop'] ~= nil and flagValues['crop'] == 'limited_padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then + if (flagValues['background'] ~= nil and flagValues['background'] == 'blurred' and flagValues['crop'] ~= nil and flagValues['crop'] == 'limited_padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- scale + padded (no upscale) + blurred bg command = config.ffmpeg .. ' -i ' .. @@ -229,7 +229,7 @@ if cachedFile == nil then '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. (flagValues['y'] or '(H-h)/2') .. ':x=' .. (flagValues['x'] or '(W-w)/2') .. '" -c:a copy ' .. preset .. cachedFilepath .. filename - elseif (flagValues['background'] ~= nil and flagValues['background'] == 'blur' and flagValues['crop'] ~= nil and flagValues['crop'] == 'padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then + elseif (flagValues['background'] ~= nil and flagValues['background'] == 'blurred' and flagValues['crop'] ~= nil and flagValues['crop'] == 'padding' and flagValues['width'] ~= nil and flagValues['height'] ~= nil) then -- scale + padded (with upscale) + blurred bg command = config.ffmpeg .. ' -i ' .. From 6977def461388418793c0b759e361239e6d6e1f5 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 24 Jan 2024 16:51:14 +0100 Subject: [PATCH 39/53] Fix minpads for images --- command.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/command.lua b/command.lua index e04a0ce..b3b5501 100644 --- a/command.lua +++ b/command.lua @@ -87,7 +87,7 @@ local function buildImageProcessingCommand(config, file, flags) ' -crop ' .. dimensions .. '+0+0 ' .. ' \\( ' .. image .. - ' -resize ' .. (width - (minpad or 0)) .. 'x' .. (height - (minpad or 0)) .. '\\>' .. + ' -resize ' .. (width - 2 * (minpad or 0)) .. 'x' .. (height - 2 * (minpad or 0)) .. '\\>' .. ' -set option:origwidth %w' .. ' -set option:origheight %h' .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. From ca641fe252b8e081903cda189976826605f0341e Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 29 Jan 2024 19:15:47 +0100 Subject: [PATCH 40/53] Move videos part to the new boilerplate --- README.md | 38 +++---- command.lua | 194 ++++++++++++++++++++++++++++++++--- config.lua.example | 50 +++++---- file.lua | 45 +++++--- flag.lua | 105 ++++++++++++++----- luamp-locations.conf.example | 9 +- media-processor.lua | 132 ++++++++++++++---------- 7 files changed, 407 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index e7b6c99..687f3dc 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ And here's minimal viable config for 4 locations you need to set up. These locat # video location location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { # these two are required to be set regardless - set $luamp_media_type "video"; set $luamp_original_file ""; set $luamp_transcoded_file ""; @@ -73,18 +72,13 @@ location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|) set $luamp_postfix ""; #pass to transcoder location - try_files $uri @luamp_video_process; + try_files $uri @luamp_media_processor; } -# video process/transcode location -location @luamp_video_process { - content_by_lua_file "/absolute/path/to/nginx-lua-mp4/nginx-lua-mp4.lua"; -} # image location location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { # these are needed to be set if you did not use them in regex matching location - set $luamp_media_type "image"; set $luamp_prefix ""; set $luamp_postfix ""; @@ -92,7 +86,7 @@ location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|) try_files $uri @luamp_media_processor; } -# image process/transcode location +# media process/transcode location location @luamp_media_processor { # these two are required to be set regardless set $luamp_original_file ""; @@ -268,33 +262,33 @@ For win: config.ffmpegDevNull = '2>NUL' -- win ``` -#### `config.flagMap` +#### `config.flagVideoMap`, `config.flagImageMap` -Use this table to customize how flags are called in your URLs. Defaults are one letter flags like `w` for `width`, but you can customise these by editing left side of `flagMap` table: +Use this table to customize how flags are called in your URLs. Defaults are one letter flags like `w` for `width`, but you can customise these by editing left side of `flagVideoMap` or `flagImageMap` table: One letter flags (except for DPR) if you want to use flags like `w_200,h_180,c_pad`: ``` - ['c'] = 'crop', - ['b'] = 'background', - ['dpr'] = 'dpr', - ['h'] = 'height', - ['w'] = 'width', + c = Flag.VIDEO_CROP_NAME, + b = Flag.VIDEO_BACKGROUND_NAME, + dpr = Flag.VIDEO_DPR_NAME, + h = Flag.VIDEO_HEIGHT_NAME, + w = Flag.VIDEO_WIDTH_NAME, ``` Full flags if you want to use flags like `width_200,height_180,crop_pad`: ``` - ['crop'] = 'crop', - ['background'] = 'background', - ['dpr'] = 'dpr', - ['height'] = 'height', - ['width'] = 'width', + crop = Flag.VIDEO_CROP_NAME, + background = Flag.VIDEO_BACKGROUND_NAME, + dpr = Flag.VIDEO_DPR_NAME, + height = Flag.VIDEO_HEIGHT_NAME, + width = Flag.VIDEO_WIDTH_NAME, ``` #### `config.flagPreprocessHook(flag, value)` -Customize this function to preprocess flags or their values. Return values should contain values that are present in `config.flagMap` and `config.flagValueMap`. +Customize this function to preprocess flags or their values. Return values should contain values that are present in `config.flagVideoMap` or `config.flagImageMap` and `config.flagValueMap`. #### `config.flagsDelimiter` @@ -306,7 +300,7 @@ Character that is used to separate flag name from the value, e.g. underscores in #### `config.flagValueMap` -Similar to `config.flagMap` above, but for non-number flag *values* rather than flag names. +Similar to `config.flagVideoMap` above, but for non-number flag *values* rather than flag names. Default flag values, e.g. `c_pad` or `c_lpad`, also `b_blurred`: diff --git a/command.lua b/command.lua index b3b5501..350f353 100644 --- a/command.lua +++ b/command.lua @@ -46,27 +46,32 @@ end ---@param flags table ---@return string local function buildImageProcessingCommand(config, file, flags) - local crop = flags[Flag.IMAGE_CROP_NAME].value - local gravity = flags[Flag.IMAGE_GRAVITY_NAME].value - local x = flags[Flag.IMAGE_X_NAME].value - local y = flags[Flag.IMAGE_Y_NAME].value - local width = flags[Flag.IMAGE_WIDTH_NAME].value - local height = flags[Flag.IMAGE_HEIGHT_NAME].value - local radius = flags[Flag.IMAGE_RADIUS_NAME].value - local quality = flags[Flag.IMAGE_QUALITY_NAME].value - local minpad = flags[Flag.IMAGE_MINPAD_NAME].value + local crop = flags[Flag.IMAGE_CROP_NAME] and flags[Flag.IMAGE_CROP_NAME].value + local gravity = flags[Flag.IMAGE_GRAVITY_NAME] and flags[Flag.IMAGE_GRAVITY_NAME].value + local x = flags[Flag.IMAGE_X_NAME] and flags[Flag.IMAGE_X_NAME].value + local y = flags[Flag.IMAGE_Y_NAME] and flags[Flag.IMAGE_Y_NAME].value + local width = flags[Flag.IMAGE_WIDTH_NAME] and flags[Flag.IMAGE_WIDTH_NAME].value + local height = flags[Flag.IMAGE_HEIGHT_NAME] and flags[Flag.IMAGE_HEIGHT_NAME].value + local radius = flags[Flag.IMAGE_RADIUS_NAME] and flags[Flag.IMAGE_RADIUS_NAME].value + local quality = flags[Flag.IMAGE_QUALITY_NAME] and flags[Flag.IMAGE_QUALITY_NAME].value + local minpad = flags[Flag.IMAGE_MINPAD_NAME] and flags[Flag.IMAGE_MINPAD_NAME].value -- Construct a command local command = '' - local executorWithPreset = config.magick .. - ' -define png:exclude-chunks=date,time' .. - ' -quality ' .. quality .. - ' -gravity ' .. gravity .. ' ' + local executorWithPreset = config.magick .. ' -define png:exclude-chunks=date,time' local canvas = getCanvas(config, file, flags) local image = file.originalFilePath .. ' -modulate 100,120,100' local mask = getMask(radius) local dimensions = (width or '') .. 'x' .. (height or '') + if quality then + executorWithPreset = executorWithPreset .. ' -quality ' .. quality .. ' ' + end + + if gravity then + executorWithPreset = executorWithPreset .. ' -gravity ' .. gravity .. ' ' + end + if crop == 'fill' and (width or height) then command = executorWithPreset .. canvas .. @@ -120,8 +125,6 @@ local function buildImageProcessingCommand(config, file, flags) end if command and command ~= '' then - os.execute('mkdir -p ' .. file.cacheDir) - if config.logTime then command = 'time ' .. command end @@ -148,7 +151,166 @@ end ---@param flags table ---@return string local function buildVideoProcessingCommand(config, file, flags) - return '' + local crop = flags[Flag.VIDEO_CROP_NAME] and flags[Flag.VIDEO_CROP_NAME].value + local background = flags[Flag.VIDEO_BACKGROUND_NAME] and flags[Flag.VIDEO_BACKGROUND_NAME].value + local x = flags[Flag.VIDEO_X_NAME] and flags[Flag.VIDEO_X_NAME].value + local y = flags[Flag.VIDEO_Y_NAME] and flags[Flag.VIDEO_Y_NAME].value + local width = flags[Flag.VIDEO_WIDTH_NAME] and flags[Flag.VIDEO_WIDTH_NAME].value + local height = flags[Flag.VIDEO_HEIGHT_NAME] and flags[Flag.VIDEO_HEIGHT_NAME].value + local preset = '' + + -- setting x264 preset + if config.ffmpegPreset ~= '' then + preset = ' -preset ' .. config.ffmpegPreset .. ' ' + end + + local command = '' + + if background == 'blur' and crop == 'limited_padding' and width and height then + -- scale + padded (no upscale) + blurred bg + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. + width .. + '\\,iw*(max(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):max(' .. + height .. + '\\,ih*(max(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. + width .. + ':' .. + height .. + ', setsar=1[background];[second]scale=min(' .. + width .. + '\\,iw):min(' .. + height .. + '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. + (y or '(H-h)/2') .. + ':x=' .. (x or '(W-w)/2') .. '" -c:a copy ' .. preset .. file.cachedFilePath + elseif background == 'blur' and crop == 'padding' and width and height then + -- scale + padded (with upscale) + blurred bg + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. + width .. + '\\,iw*(max(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):max(' .. + height .. + '\\,ih*(max(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. + width .. + ':' .. + height .. + ', setsar=1[background];[second]scale=min(' .. + width .. + '\\,iw*(min(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):min(' .. + height .. + '\\,ih*(min(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. + (y or '(H-h)/2') .. + ':x=' .. (x or '(W-w)/2') .. '" -c:a copy ' .. preset .. file.cachedFilePath + elseif crop == 'limited_padding' and width and height then + -- scale (no upscale) with padding (blackbox) + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "scale=min(' .. + width .. + '\\,iw):min(' .. + height .. + '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1,pad=' .. + width .. + ':' .. + height .. + ':y=' .. + (y or '-1') .. + ':x=' .. (x or '-1') .. ':color=black" -c:a copy ' .. preset .. file.cachedFilePath + elseif crop == 'padding' and width and height then + -- scale (with upscale) with padding (blackbox) + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "scale=min(' .. + width .. + '\\,iw*(min(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):min(' .. + height .. + '\\,ih*(min(' .. + width .. + '/iw\\,' .. + height .. + '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1,pad=' .. + width .. + ':' .. + height .. + ':y=' .. + (y or '-1') .. + ':x=' .. (x or '-1') .. ':color=black" -c:a copy ' .. preset .. file.cachedFilePath + elseif width and height then + -- simple scale (no aspect ratio) + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "scale=' .. + width .. + ':' .. + height .. + ':force_divisible_by=2:force_original_aspect_ratio=disable,setsar=1" -c:a copy ' .. + preset .. file.cachedFilePath + elseif height then + -- simple one-side scale (h) + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "scale=-1:' .. + height .. + ':force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. + preset .. file.cachedFilePath + elseif width then + -- simple one-side scale (w) + command = config.ffmpeg .. + ' -i ' .. + file.originalFilePath .. + ' -filter_complex "scale=' .. + width .. + ':-1:force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. + preset .. file.cachedFilePath + end + + if command and command ~= '' then + if config.logFfmpegOutput == false then + command = command .. ' ' .. config.ffmpegDevNull + end + if config.logTime then + command = 'time ' .. command + end + end + + return command end -- Build command diff --git a/config.lua.example b/config.lua.example index 0d839cb..f0485fc 100644 --- a/config.lua.example +++ b/config.lua.example @@ -13,21 +13,21 @@ config.ffmpeg = '/usr/local/bin/ffmpeg' config.magick = '/usr/bin/magick' -- where to save original and transcoded files (trailing slash required) -config.mediaBaseFilepath = '/tmp/nginx/' +config.mediaBaseFilepath = '/usr/local/openresty/nginx/html/media/' -- set to `true` to enable originals download from the upstream/CDN. See `getOriginalsUpstreamUrl` below config.downloadOriginals = true --- remove image color profile on conversion +-- removes color profile on conversion config.stripColorProfile = true --- apply color profile if path is set -config.colorProfilePath = +-- color profile will be applied if path set +config.colorProfilePath = '/usr/local/openresty/nginx/html/media/sRGB.icc' -- function to get a URL where originals are stored, when `downloadOriginals` set to true. function config.getOriginalsUpstreamPath(prefix, postfix, filename) -- return ngx.var.request_uri - return (prefix or '') .. (postfix or '') .. (filename or '') + return '/' .. (prefix or '') .. (postfix or '') .. (filename or '') end -- character that is used to separate different flags in URL. @@ -41,20 +41,16 @@ config.flagValueDelimiter = '_' -- override URL flag names. Useful when you migrate from another transcoding solution and already have -- some flags in use on the front end. Customize the left part of the table. -- eg `['cropping'] = 'crop'` to use `cropping` instead of the default `c` -config.flagMap = { - c = 'crop', -- crop / scale - b = 'background', - dpr = 'dpr', -- DPR, https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio - -- f = 'format', - g = 'gravity', - h = 'height', - w = 'width', - x = 'x', - y = 'y', +config.flagVideoMap = { + b = Flag.VIDEO_BACKGROUND_NAME, + c = Flag.VIDEO_CROP_NAME, + dpr = Flag.VIDEO_DPR_NAME, + x = Flag.VIDEO_X_NAME, + y = Flag.VIDEO_Y_NAME, + h = Flag.VIDEO_HEIGHT_NAME, + w = Flag.VIDEO_WIDTH_NAME, } -config.flagVideoMap = config.flagMap - config.flagImageMap = { b = Flag.IMAGE_BACKGROUND_NAME, c = Flag.IMAGE_CROP_NAME, @@ -103,9 +99,9 @@ config.flagValueMap = { } -- log transcoding process. Useful when doing initial setup or debugging issues -config.logEnabled = false -config.logLevel = ngx.ERR --- config.logLevel = ngx.INFO +config.logEnabled = true +-- config.logLevel = ngx.ERR +config.logLevel = ngx.INFO -- config.logLevel = ngx.DEBUG -- log `ffmpeg` output. @@ -117,7 +113,7 @@ config.ffmpegDevNull = '2> /dev/null' -- nix -- config.ffmpegDevNull = '2>NUL' -- win -- whether to prepend transcoding command with `time` utility to log time spent in ffmpeg -config.logTime = false +config.logTime = true -- top limit for output video height (default 4k UHD) config.maxVideoHeight = 2160 @@ -125,14 +121,14 @@ config.maxVideoHeight = 2160 -- top limit for output video width (default 4k UHD) config.maxVideoWidth = 3840 --- top limit for output image height -config.maxImageHeight = 2160 +-- top limit for output image height (default 4k UHD) +config.maxImageHeight = 2000 --- top limit for output image width -config.maxImageWidth = 3840 +-- top limit for output image width (default 4k UHD) +config.maxImageWidth = 2000 -- customize this function to preprocess flags or their values --- return values should contain values that are present in `config.flagMap` and `config.flagValueMap` +-- return values should contain values that are present in `config.flagVideoMap` or `config.flagImageMap` and `config.flagValueMap` function config.flagPreprocessHook(flag, value) -- do some processing -- strip sub parameters `c_pad:pink` -> `c_pad` @@ -144,7 +140,7 @@ end config.serveOriginalOnTranscodeFailure = true -- least required size (in bytes) for the transcoded file to not be considered broken and deleted (default is 1KB) -config.minimumTranscodedFileSize = 1024 +config.minimumTranscodedVideoSize = 1 -- encoding preset to use https://trac.ffmpeg.org/wiki/Encode/H.264 config.ffmpegPreset = '' diff --git a/file.lua b/file.lua index a7a519a..a6df48c 100644 --- a/file.lua +++ b/file.lua @@ -3,6 +3,21 @@ local File = {} File.IMAGE_TYPE = 'image' File.VIDEO_TYPE = 'video' +File.TYPE_EXTENSION_MAP = { + -- Video + mp4 = File.VIDEO_TYPE, + -- Image + jpg = File.IMAGE_TYPE, + jpeg = File.IMAGE_TYPE, + png = File.IMAGE_TYPE, + gif = File.IMAGE_TYPE, + bmp = File.IMAGE_TYPE, + tif = File.IMAGE_TYPE, + tiff = File.IMAGE_TYPE, + svg = File.IMAGE_TYPE, + pdf = File.IMAGE_TYPE, + webp = File.IMAGE_TYPE, +} -- Coalesce flag values. All flag values are set at this moment ---@param flag table @@ -19,10 +34,7 @@ end ---@param flags table ---@return string local function buildCacheDirPath(basePath, flags) - if utils.isTableEmpty(flags) then - return basePath - end - + local path = basePath local flagNamesOrdered = {} -- Add the flag name to the ordered list @@ -36,34 +48,43 @@ local function buildCacheDirPath(basePath, flags) table.sort(flagNamesOrdered) -- Generate the options path - local optionsPath = '' - for _, flagName in ipairs(flagNamesOrdered) do local pathFragment = coalesceFlag(flags[flagName]) if pathFragment ~= '' then - optionsPath = optionsPath .. pathFragment .. '/' + path = path .. pathFragment .. '/' end end - return basePath .. optionsPath + return path end -- Base class method new -function File.new(config, prefix, postfix, id, extension, type, flags) +function File.new(config, prefix, postfix, id, extension) local self = {} self.config = config self.id = id - self.type = type + self.type = File.TYPE_EXTENSION_MAP[extension] self.extension = extension self.name = id .. '.' .. extension self.originalDir = config.mediaBaseFilepath .. prefix .. postfix .. id .. '/' .. extension .. '/' self.originalFilePath = self.originalDir .. self.name - self.cacheDir = buildCacheDirPath(self.originalDir, flags) - self.cachedFilePath = self.cacheDir .. self.name + self.cacheDir = self.originalDir + self.cachedFilePath = self.originalFilePath + self.upstreamPath = nil + if config.downloadOriginals then + self.upstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, self.name) + end + setmetatable(self, { __index = File }) return self end +---Sets cache dir path +function File:updateCacheDirPath(flags) + self.cacheDir = buildCacheDirPath(self.originalDir, flags) + self.cachedFilePath = self.cacheDir .. self.name +end + ---Checks file is cached ---@return boolean function File:isCached() diff --git a/flag.lua b/flag.lua index b84f5c4..bf4b91d 100644 --- a/flag.lua +++ b/flag.lua @@ -1,39 +1,51 @@ -local Flag = {} +local Flag = {} +-- IMAGE Flag.IMAGE_BACKGROUND_NAME = 'background' -Flag.IMAGE_CROP_NAME = 'crop' -Flag.IMAGE_DPR_NAME = 'dpr' -Flag.IMAGE_GRAVITY_NAME = 'gravity' -Flag.IMAGE_X_NAME = 'x' -Flag.IMAGE_Y_NAME = 'y' -Flag.IMAGE_HEIGHT_NAME = 'height' -Flag.IMAGE_WIDTH_NAME = 'width' -Flag.IMAGE_RADIUS_NAME = 'radius' -Flag.IMAGE_QUALITY_NAME = 'quality' -Flag.IMAGE_MINPAD_NAME = 'minpad' +Flag.IMAGE_CROP_NAME = 'crop' +Flag.IMAGE_DPR_NAME = 'dpr' +Flag.IMAGE_GRAVITY_NAME = 'gravity' +Flag.IMAGE_X_NAME = 'x' +Flag.IMAGE_Y_NAME = 'y' +Flag.IMAGE_HEIGHT_NAME = 'height' +Flag.IMAGE_WIDTH_NAME = 'width' +Flag.IMAGE_RADIUS_NAME = 'radius' +Flag.IMAGE_QUALITY_NAME = 'quality' +Flag.IMAGE_MINPAD_NAME = 'minpad' -local IMAGE_DEFAULTS = { - [Flag.IMAGE_BACKGROUND_NAME] = 'white', - [Flag.IMAGE_GRAVITY_NAME] = 'center', - [Flag.IMAGE_X_NAME] = 0, - [Flag.IMAGE_Y_NAME] = 0, - [Flag.IMAGE_QUALITY_NAME] = 80 -} +-- VIDEO +Flag.VIDEO_BACKGROUND_NAME = 'background' +Flag.VIDEO_CROP_NAME = 'crop' +Flag.VIDEO_DPR_NAME = 'dpr' +Flag.VIDEO_X_NAME = 'x' +Flag.VIDEO_Y_NAME = 'y' +Flag.VIDEO_HEIGHT_NAME = 'height' +Flag.VIDEO_WIDTH_NAME = 'width' -- Base class method new function Flag.new(config, name, value) local self = {} self.config = config self.name = name - self.value = value or IMAGE_DEFAULTS[name] + self.value = value self.isScalable = false self.makeDir = true - if self.name == Flag.IMAGE_HEIGHT_NAME or self.name == Flag.IMAGE_WIDTH_NAME or self.name == Flag.IMAGE_RADIUS_NAME or self.name == Flag.IMAGE_MINPAD_NAME then + if self.name == Flag.IMAGE_HEIGHT_NAME + or self.name == Flag.IMAGE_WIDTH_NAME + or self.name == Flag.IMAGE_X_NAME + or self.name == Flag.IMAGE_Y_NAME + or self.name == Flag.IMAGE_RADIUS_NAME + or self.name == Flag.IMAGE_MINPAD_NAME + or self.name == Flag.VIDEO_HEIGHT_NAME + or self.name == Flag.VIDEO_WIDTH_NAME + or self.name == Flag.VIDEO_X_NAME + or self.name == Flag.VIDEO_Y_NAME + then self.isScalable = true end - if self.name == Flag.IMAGE_DPR_NAME then + if self.name == Flag.IMAGE_DPR_NAME or self.name == Flag.VIDEO_DPR_NAME then self.makeDir = false end @@ -55,16 +67,61 @@ end ---@param dpr number function Flag:scale(dpr) if self.value and self.value ~= '' then - self.value = math.ceil(self.value * (dpr or 1)) + local scaledValue = math.ceil(self.value * (dpr or 1)) + + if self.name == Flag.IMAGE_X_NAME + or self.name == Flag.IMAGE_Y_NAME + or self.name == Flag.VIDEO_X_NAME + or self.name == Flag.VIDEO_Y_NAME + then + if self.value >= 1 then + self.value = scaledValue + end + else + self.value = scaledValue + end -- Apply limits - if self.name == Flag.IMAGE_HEIGHT_NAME and self.config.maxImageHeight and self.value > self.config.maxImageHeight then + if self.name == Flag.IMAGE_HEIGHT_NAME + and self.config.maxImageHeight + and self.value > self.config.maxImageHeight + then self.value = self.config.maxImageHeight end - if self.name == Flag.IMAGE_WIDTH_NAME and self.config.maxImageWidth and self.value > self.config.maxImageWidth then + if self.name == Flag.IMAGE_WIDTH_NAME + and self.config.maxImageWidth + and self.value > self.config.maxImageWidth + then self.value = self.config.maxImageWidth end + + if self.name == Flag.VIDEO_HEIGHT_NAME + and self.config.maxVideoHeight + and self.value > self.config.maxVideoHeight + then + self.value = self.config.maxVideoHeight + end + + if self.name == Flag.VIDEO_WIDTH_NAME + and self.config.maxVideoWidth + and self.value > self.config.maxVideoWidth + then + self.value = self.config.maxVideoWidth + end + end +end + +-- Calculate absolute x/y for values in (0, 1) range +---@param dimension number +function Flag:coordinateToAbsolute(dimension) + if dimension + and (self.name == Flag.VIDEO_X_NAME or self.name == Flag.VIDEO_Y_NAME) + and self.value + and self.value > 0 + and self.value < 1 + then + self.value = self.value * dimension end end diff --git a/luamp-locations.conf.example b/luamp-locations.conf.example index a9d258e..ac8400c 100644 --- a/luamp-locations.conf.example +++ b/luamp-locations.conf.example @@ -2,23 +2,16 @@ # video location location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { - set $luamp_media_type "video"; set $luamp_original_file ""; set $luamp_transcoded_file ""; # these are needed to be set if you did not use them in regex matching location # set $luamp_prefix ""; # set $luamp_postfix ""; - try_files $uri @luamp_video_process; -} - -# video process/transcode location -location @luamp_video_process { - content_by_lua_file "/usr/local/openresty/nginx/nginx-lua-mp4/nginx-lua-mp4.lua"; + try_files $uri @luamp_media_processor; } # image location location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { - set $luamp_media_type "image"; # these are needed to be set if you did not use them in regex matching location # set $luamp_prefix ""; # set $luamp_postfix ""; diff --git a/media-processor.lua b/media-processor.lua index 2c7f70a..accee71 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -6,17 +6,15 @@ local log = require('log') local utils = require('utils') ---Download original form upstream ----@param prefix string ----@param postfix string ---@param file table -local function downloadOriginals(prefix, postfix, file) - local originalsUpstreamPath = config.getOriginalsUpstreamPath(prefix, postfix, file.name) - log('Downloading original from ' .. originalsUpstreamPath) +local function downloadOriginals(file) + log('Downloading original from ' .. file.upstreamPath) ngx.req.discard_body() -- Clear body log('Fetching') - local originalReq = ngx.location.capture('/luamp-upstream', - { vars = { luamp_original_file = originalsUpstreamPath } }) + local originalReq = ngx.location.capture('/luamp-upstream', { + vars = { luamp_original_file = file.upstreamPath } + }) log('Upstream status: ' .. originalReq.status) if originalReq.status == ngx.HTTP_OK and originalReq.body:len() > 0 then @@ -43,49 +41,57 @@ local function main() config.setDefaults({ minimumTranscodedVideoSize = 1024, serveOriginalOnTranscodeFailure = true, - ffmpegPreset = '' + ffmpegPreset = '', }) -- Get URL params - local mediaType = ngx.var.luamp_media_type local prefix = utils.cleanupPath(ngx.var.luamp_prefix) local luampFlags = ngx.var.luamp_flags local postfix = utils.cleanupPath(ngx.var.luamp_postfix) local mediaId = utils.cleanupPath(ngx.var.luamp_media_id) local mediaExtension = ngx.var.luamp_media_extension + local file = File.new(config, prefix, postfix, mediaId, mediaExtension) - log('MediaType: ' .. mediaType) log('Prefix: ' .. prefix) log('Postfix: ' .. postfix) log('Flags: ' .. luampFlags) log('MediaId: ' .. mediaId) log('MediaExtension: ' .. mediaExtension) + log('MediaType: ' .. file.type) + + if luampFlags == '' then + -- Check if the original file exists + if not file:hasOriginal() then + log('Original file not found: ' .. file.originalFilePath) + + if config.downloadOriginals then + -- Download original if upstream download is enabled + downloadOriginals(file) + else + ngx.exit(ngx.HTTP_NOT_FOUND) + end + end + + log('Serving original from: ' .. file.originalFilePath) + ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) + end local flags = {} local flagMapper = {} local valueMapper = {} - if mediaType == File.IMAGE_TYPE then - if luampFlags ~= '' then - flags = { - [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(config, Flag.IMAGE_BACKGROUND_NAME), - [Flag.IMAGE_CROP_NAME] = Flag.new(config, Flag.IMAGE_CROP_NAME), - [Flag.IMAGE_DPR_NAME] = Flag.new(config, Flag.IMAGE_DPR_NAME), - [Flag.IMAGE_GRAVITY_NAME] = Flag.new(config, Flag.IMAGE_GRAVITY_NAME), - [Flag.IMAGE_X_NAME] = Flag.new(config, Flag.IMAGE_X_NAME), - [Flag.IMAGE_Y_NAME] = Flag.new(config, Flag.IMAGE_Y_NAME), - [Flag.IMAGE_HEIGHT_NAME] = Flag.new(config, Flag.IMAGE_HEIGHT_NAME), - [Flag.IMAGE_WIDTH_NAME] = Flag.new(config, Flag.IMAGE_WIDTH_NAME), - [Flag.IMAGE_RADIUS_NAME] = Flag.new(config, Flag.IMAGE_RADIUS_NAME), - [Flag.IMAGE_QUALITY_NAME] = Flag.new(config, Flag.IMAGE_QUALITY_NAME), - [Flag.IMAGE_MINPAD_NAME] = Flag.new(config, Flag.IMAGE_MINPAD_NAME), - } - flagMapper = config.flagImageMap - valueMapper = config.flagValueMap - end - elseif mediaType == File.VIDEO_TYPE then - flags = {} - flagMapper = config.flagMap + if file.type == File.IMAGE_TYPE then + flags = { + [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(config, Flag.IMAGE_BACKGROUND_NAME, 'white'), + [Flag.IMAGE_GRAVITY_NAME] = Flag.new(config, Flag.IMAGE_GRAVITY_NAME, 'center'), + [Flag.IMAGE_X_NAME] = Flag.new(config, Flag.IMAGE_X_NAME, 0), + [Flag.IMAGE_Y_NAME] = Flag.new(config, Flag.IMAGE_Y_NAME, 0), + [Flag.IMAGE_QUALITY_NAME] = Flag.new(config, Flag.IMAGE_QUALITY_NAME, 80), + } + flagMapper = config.flagImageMap + valueMapper = config.flagValueMap + elseif file.type == File.VIDEO_TYPE then + flagMapper = config.flagVideoMap valueMapper = config.flagValueMap else ngx.exit(ngx.HTTP_BAD_REQUEST) @@ -98,26 +104,41 @@ local function main() f, v = config.flagPreprocessHook(f, v) end - local flag = flags[flagMapper[f]] - -- Set value if flag exists - if flag then - flag:setValue(v, valueMapper) + local flagName = flagMapper[f] + if flagName then + flags[flagName] = Flag.new(config, flagName) + flags[flagName]:setValue(v, valueMapper) end end -- Scale dimensions with respect to limits - local dpr = flags[Flag.IMAGE_DPR_NAME] - if dpr and dpr.value then - for flagName, _ in pairs(flags) do - local flag = flags[flagName] - if flag.isScalable then - log('Scaling flag: ' .. flagName) - flag:scale(dpr.value) - end + local dpr = flags[Flag.IMAGE_DPR_NAME] or flags[Flag.VIDEO_DPR_NAME] + for flagName, _ in pairs(flags) do + local flag = flags[flagName] + if flag.isScalable and dpr and dpr.value then + log('Scaling a flag: ' .. flagName) + flag:scale(dpr.value) + end + end + + -- Calculate absolute x/y for values in (0, 1) range + if file.type == File.VIDEO_TYPE then + local videoX = flags[Flag.VIDEO_X_NAME] + local videoY = flags[Flag.VIDEO_Y_NAME] + local videoWidth = flags[Flag.VIDEO_WIDTH_NAME] + local videoHeight = flags[Flag.VIDEO_HEIGHT_NAME] + if videoX and videoWidth then + videoX.coordinateToAbsolute(videoWidth.value) + log('Absolute x: ' .. videoX.value) + end + if videoY and videoWidth then + videoY.coordinateToAbsolute(videoHeight.value) + log('Absolute y: ' .. videoY.value) end end - local file = File.new(config, prefix, postfix, mediaId, mediaExtension, mediaType, flags) + -- Recalculate cache dir path + file:updateCacheDirPath(flags) -- Serve the cached file if it exists if file:isCached() then @@ -134,22 +155,23 @@ local function main() if config.downloadOriginals then -- Download original if upstream download is enabled - downloadOriginals(prefix, postfix, file) + downloadOriginals(file) else ngx.exit(ngx.HTTP_NOT_FOUND) end end - -- Serve the cached file if it exists - if file:isCached() then - log('Serving cached file: ' .. file.cachedFilePath) - ngx.exec('/luamp-cache', { luamp_cached_file_path = file.cachedFilePath }) - end - log('Original is present on local FS. Transcoding to ' .. file.cachedFilePath) local cmd = Command.new(config, file, flags) - log('Command: ' .. cmd.command) - local executeSuccess = cmd:execute() + local executeSuccess + + if cmd.isValid then + os.execute('mkdir -p ' .. file.cacheDir) + log('Command: ' .. cmd.command) + executeSuccess = cmd:execute() + else + log('Invalid command') + end if executeSuccess then log('Transcoded version is good, serving it') @@ -158,10 +180,6 @@ local function main() log('Transcode failed') - if not cmd.isValid then - log('Invalid command') - end - if config.serveOriginalOnTranscodeFailure == true then log('Serving original from: ' .. file.originalFilePath) ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) From 65c034b3006ff00e8c5bd20bb0676595d5815cca Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 30 Jan 2024 17:46:21 +0100 Subject: [PATCH 41/53] Add unique keys for flags --- README.md | 20 +-- command.lua | 32 ++--- config.lua.example | 38 ++--- file.lua | 1 - flag.lua | 260 +++++++++++++++++++++++++---------- luamp-locations.conf.example | 1 - media-processor.lua | 60 ++++---- 7 files changed, 267 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 687f3dc..a743c18 100644 --- a/README.md +++ b/README.md @@ -269,21 +269,21 @@ Use this table to customize how flags are called in your URLs. Defaults are one One letter flags (except for DPR) if you want to use flags like `w_200,h_180,c_pad`: ``` - c = Flag.VIDEO_CROP_NAME, - b = Flag.VIDEO_BACKGROUND_NAME, - dpr = Flag.VIDEO_DPR_NAME, - h = Flag.VIDEO_HEIGHT_NAME, - w = Flag.VIDEO_WIDTH_NAME, + c = Flag.VIDEO_CROP_KEY, + b = Flag.VIDEO_BACKGROUND_KEY, + dpr = Flag.VIDEO_DPR_KEY, + h = Flag.VIDEO_HEIGHT_KEY, + w = Flag.VIDEO_WIDTH_KEY, ``` Full flags if you want to use flags like `width_200,height_180,crop_pad`: ``` - crop = Flag.VIDEO_CROP_NAME, - background = Flag.VIDEO_BACKGROUND_NAME, - dpr = Flag.VIDEO_DPR_NAME, - height = Flag.VIDEO_HEIGHT_NAME, - width = Flag.VIDEO_WIDTH_NAME, + crop = Flag.VIDEO_CROP_KEY, + background = Flag.VIDEO_BACKGROUND_KEY, + dpr = Flag.VIDEO_DPR_KEY, + height = Flag.VIDEO_HEIGHT_KEY, + width = Flag.VIDEO_WIDTH_KEY, ``` #### `config.flagPreprocessHook(flag, value)` diff --git a/command.lua b/command.lua index 350f353..a5654d8 100644 --- a/command.lua +++ b/command.lua @@ -5,7 +5,7 @@ local utils = require('utils') local Command = {} local function getCanvas(config, file, flags) - local background = flags[Flag.IMAGE_BACKGROUND_NAME].value + local background = flags[Flag.IMAGE_BACKGROUND_KEY] and flags[Flag.IMAGE_BACKGROUND_KEY].value local canvas = '' if background == 'auto' then @@ -46,15 +46,15 @@ end ---@param flags table ---@return string local function buildImageProcessingCommand(config, file, flags) - local crop = flags[Flag.IMAGE_CROP_NAME] and flags[Flag.IMAGE_CROP_NAME].value - local gravity = flags[Flag.IMAGE_GRAVITY_NAME] and flags[Flag.IMAGE_GRAVITY_NAME].value - local x = flags[Flag.IMAGE_X_NAME] and flags[Flag.IMAGE_X_NAME].value - local y = flags[Flag.IMAGE_Y_NAME] and flags[Flag.IMAGE_Y_NAME].value - local width = flags[Flag.IMAGE_WIDTH_NAME] and flags[Flag.IMAGE_WIDTH_NAME].value - local height = flags[Flag.IMAGE_HEIGHT_NAME] and flags[Flag.IMAGE_HEIGHT_NAME].value - local radius = flags[Flag.IMAGE_RADIUS_NAME] and flags[Flag.IMAGE_RADIUS_NAME].value - local quality = flags[Flag.IMAGE_QUALITY_NAME] and flags[Flag.IMAGE_QUALITY_NAME].value - local minpad = flags[Flag.IMAGE_MINPAD_NAME] and flags[Flag.IMAGE_MINPAD_NAME].value + local crop = flags[Flag.IMAGE_CROP_KEY] and flags[Flag.IMAGE_CROP_KEY].value + local gravity = flags[Flag.IMAGE_GRAVITY_KEY] and flags[Flag.IMAGE_GRAVITY_KEY].value + local x = flags[Flag.IMAGE_X_KEY] and flags[Flag.IMAGE_X_KEY].value + local y = flags[Flag.IMAGE_Y_KEY] and flags[Flag.IMAGE_Y_KEY].value + local width = flags[Flag.IMAGE_WIDTH_KEY] and flags[Flag.IMAGE_WIDTH_KEY].value + local height = flags[Flag.IMAGE_HEIGHT_KEY] and flags[Flag.IMAGE_HEIGHT_KEY].value + local radius = flags[Flag.IMAGE_RADIUS_KEY] and flags[Flag.IMAGE_RADIUS_KEY].value + local quality = flags[Flag.IMAGE_QUALITY_KEY] and flags[Flag.IMAGE_QUALITY_KEY].value + local minpad = flags[Flag.IMAGE_MINPAD_KEY] and flags[Flag.IMAGE_MINPAD_KEY].value -- Construct a command local command = '' @@ -151,12 +151,12 @@ end ---@param flags table ---@return string local function buildVideoProcessingCommand(config, file, flags) - local crop = flags[Flag.VIDEO_CROP_NAME] and flags[Flag.VIDEO_CROP_NAME].value - local background = flags[Flag.VIDEO_BACKGROUND_NAME] and flags[Flag.VIDEO_BACKGROUND_NAME].value - local x = flags[Flag.VIDEO_X_NAME] and flags[Flag.VIDEO_X_NAME].value - local y = flags[Flag.VIDEO_Y_NAME] and flags[Flag.VIDEO_Y_NAME].value - local width = flags[Flag.VIDEO_WIDTH_NAME] and flags[Flag.VIDEO_WIDTH_NAME].value - local height = flags[Flag.VIDEO_HEIGHT_NAME] and flags[Flag.VIDEO_HEIGHT_NAME].value + local crop = flags[Flag.VIDEO_CROP_KEY] and flags[Flag.VIDEO_CROP_KEY].value + local background = flags[Flag.VIDEO_BACKGROUND_KEY] and flags[Flag.VIDEO_BACKGROUND_KEY].value + local x = flags[Flag.VIDEO_X_KEY] and flags[Flag.VIDEO_X_KEY].value + local y = flags[Flag.VIDEO_Y_KEY] and flags[Flag.VIDEO_Y_KEY].value + local width = flags[Flag.VIDEO_WIDTH_KEY] and flags[Flag.VIDEO_WIDTH_KEY].value + local height = flags[Flag.VIDEO_HEIGHT_KEY] and flags[Flag.VIDEO_HEIGHT_KEY].value local preset = '' -- setting x264 preset diff --git a/config.lua.example b/config.lua.example index f0485fc..38c5ffa 100644 --- a/config.lua.example +++ b/config.lua.example @@ -42,27 +42,29 @@ config.flagValueDelimiter = '_' -- some flags in use on the front end. Customize the left part of the table. -- eg `['cropping'] = 'crop'` to use `cropping` instead of the default `c` config.flagVideoMap = { - b = Flag.VIDEO_BACKGROUND_NAME, - c = Flag.VIDEO_CROP_NAME, - dpr = Flag.VIDEO_DPR_NAME, - x = Flag.VIDEO_X_NAME, - y = Flag.VIDEO_Y_NAME, - h = Flag.VIDEO_HEIGHT_NAME, - w = Flag.VIDEO_WIDTH_NAME, + b = Flag.VIDEO_BACKGROUND_KEY, + c = Flag.VIDEO_CROP_KEY, + dpr = Flag.VIDEO_DPR_KEY, + x = Flag.VIDEO_X_KEY, + y = Flag.VIDEO_Y_KEY, + h = Flag.VIDEO_HEIGHT_KEY, + w = Flag.VIDEO_WIDTH_KEY, + r = Flag.VIDEO_RADIUS_KEY, + minp = Flag.VIDEO_MINPAD_KEY, } config.flagImageMap = { - b = Flag.IMAGE_BACKGROUND_NAME, - c = Flag.IMAGE_CROP_NAME, - dpr = Flag.IMAGE_DPR_NAME, - g = Flag.IMAGE_GRAVITY_NAME, - x = Flag.IMAGE_X_NAME, - y = Flag.IMAGE_Y_NAME, - h = Flag.IMAGE_HEIGHT_NAME, - w = Flag.IMAGE_WIDTH_NAME, - r = Flag.IMAGE_RADIUS_NAME, - q = Flag.IMAGE_QUALITY_NAME, - minp = Flag.IMAGE_MINPAD_NAME, + b = Flag.IMAGE_BACKGROUND_KEY, + c = Flag.IMAGE_CROP_KEY, + dpr = Flag.IMAGE_DPR_KEY, + g = Flag.IMAGE_GRAVITY_KEY, + x = Flag.IMAGE_X_KEY, + y = Flag.IMAGE_Y_KEY, + h = Flag.IMAGE_HEIGHT_KEY, + w = Flag.IMAGE_WIDTH_KEY, + r = Flag.IMAGE_RADIUS_KEY, + q = Flag.IMAGE_QUALITY_KEY, + minp = Flag.IMAGE_MINPAD_KEY, } -- override URL flag values. Useful when you migrate from another transcoding solution and already have diff --git a/file.lua b/file.lua index a6df48c..1da2f25 100644 --- a/file.lua +++ b/file.lua @@ -1,4 +1,3 @@ -local utils = require('utils') local File = {} File.IMAGE_TYPE = 'image' diff --git a/flag.lua b/flag.lua index bf4b91d..16ac79a 100644 --- a/flag.lua +++ b/flag.lua @@ -1,56 +1,176 @@ -local Flag = {} - --- IMAGE -Flag.IMAGE_BACKGROUND_NAME = 'background' -Flag.IMAGE_CROP_NAME = 'crop' -Flag.IMAGE_DPR_NAME = 'dpr' -Flag.IMAGE_GRAVITY_NAME = 'gravity' -Flag.IMAGE_X_NAME = 'x' -Flag.IMAGE_Y_NAME = 'y' -Flag.IMAGE_HEIGHT_NAME = 'height' -Flag.IMAGE_WIDTH_NAME = 'width' -Flag.IMAGE_RADIUS_NAME = 'radius' -Flag.IMAGE_QUALITY_NAME = 'quality' -Flag.IMAGE_MINPAD_NAME = 'minpad' - --- VIDEO -Flag.VIDEO_BACKGROUND_NAME = 'background' -Flag.VIDEO_CROP_NAME = 'crop' -Flag.VIDEO_DPR_NAME = 'dpr' -Flag.VIDEO_X_NAME = 'x' -Flag.VIDEO_Y_NAME = 'y' -Flag.VIDEO_HEIGHT_NAME = 'height' -Flag.VIDEO_WIDTH_NAME = 'width' +local Flag = { + -- IMAGE + IMAGE_BACKGROUND_KEY = 'image_background', + IMAGE_CROP_KEY = 'image_crop', + IMAGE_DPR_KEY = 'image_dpr', + IMAGE_GRAVITY_KEY = 'image_gravity', + IMAGE_X_KEY = 'image_x', + IMAGE_Y_KEY = 'image_y', + IMAGE_HEIGHT_KEY = 'image_height', + IMAGE_WIDTH_KEY = 'image_width', + IMAGE_RADIUS_KEY = 'image_radius', + IMAGE_QUALITY_KEY = 'image_quality', + IMAGE_MINPAD_KEY = 'image_minpad', + + -- VIDEO + VIDEO_BACKGROUND_KEY = 'video_background', + VIDEO_CROP_KEY = 'video_crop', + VIDEO_DPR_KEY = 'video_dpr', + VIDEO_X_KEY = 'video_x', + VIDEO_Y_KEY = 'video_y', + VIDEO_HEIGHT_KEY = 'video_height', + VIDEO_WIDTH_KEY = 'video_width', + VIDEO_RADIUS_KEY = 'video_radius', + VIDEO_MINPAD_KEY = 'video_minpad', +} + + +Flag.DEFAULTS = { + [Flag.IMAGE_BACKGROUND_KEY] = { + name = 'background', + value = 'white', + isScalable = false, + makeDir = true, + }, + [Flag.IMAGE_CROP_KEY] = { + name = 'crop', + value = nil, + isScalable = false, + makeDir = true, + }, + [Flag.IMAGE_DPR_KEY] = { + name = 'dpr', + value = nil, + isScalable = false, + makeDir = false, + }, + [Flag.IMAGE_GRAVITY_KEY] = { + name = 'gravity', + value = 'center', + isScalable = false, + makeDir = true, + }, + [Flag.IMAGE_X_KEY] = { + name = 'x', + value = 0, + isScalable = true, + makeDir = true, + }, + [Flag.IMAGE_Y_KEY] = { + name = 'y', + value = 0, + isScalable = true, + makeDir = true, + }, + [Flag.IMAGE_HEIGHT_KEY] = { + name = 'height', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.IMAGE_WIDTH_KEY] = { + name = 'width', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.IMAGE_RADIUS_KEY] = { + name = 'radius', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.IMAGE_QUALITY_KEY] = { + name = 'quality', + value = 80, + isScalable = false, + makeDir = true, + }, + [Flag.IMAGE_MINPAD_KEY] = { + name = 'minpad', + value = nil, + isScalable = true, + makeDir = true, + }, + + -- VIDEO + [Flag.VIDEO_BACKGROUND_KEY] = { + name = 'background', + value = nil, + isScalable = false, + makeDir = true, + }, + [Flag.VIDEO_CROP_KEY] = { + name = 'crop', + value = nil, + isScalable = false, + makeDir = true, + }, + [Flag.VIDEO_DPR_KEY] = { + name = 'dpr', + value = nil, + isScalable = false, + makeDir = false, + }, + [Flag.VIDEO_X_KEY] = { + name = 'x', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.VIDEO_Y_KEY] = { + name = 'y', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.VIDEO_HEIGHT_KEY] = { + name = 'height', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.VIDEO_WIDTH_KEY] = { + name = 'width', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.VIDEO_RADIUS_KEY] = { + name = 'radius', + value = nil, + isScalable = true, + makeDir = true, + }, + [Flag.VIDEO_MINPAD_KEY] = { + name = 'minpad', + value = nil, + isScalable = true, + makeDir = true, + }, +} -- Base class method new -function Flag.new(config, name, value) +function Flag.new(key, value) local self = {} - self.config = config - self.name = name - self.value = value - self.isScalable = false - self.makeDir = true - - if self.name == Flag.IMAGE_HEIGHT_NAME - or self.name == Flag.IMAGE_WIDTH_NAME - or self.name == Flag.IMAGE_X_NAME - or self.name == Flag.IMAGE_Y_NAME - or self.name == Flag.IMAGE_RADIUS_NAME - or self.name == Flag.IMAGE_MINPAD_NAME - or self.name == Flag.VIDEO_HEIGHT_NAME - or self.name == Flag.VIDEO_WIDTH_NAME - or self.name == Flag.VIDEO_X_NAME - or self.name == Flag.VIDEO_Y_NAME - then - self.isScalable = true - end - if self.name == Flag.IMAGE_DPR_NAME or self.name == Flag.VIDEO_DPR_NAME then - self.makeDir = false - end + local defaults = Flag.DEFAULTS[key] + + if defaults then + self.name = defaults.name + self.value = defaults.value + self.key = key + self.isScalable = defaults.isScalable + self.makeDir = defaults.makeDir - setmetatable(self, { __index = Flag }) - return self + -- if value and value ~= '' then + -- -- Check if it is an allowed text flag or cast to a number + -- self.value = valueMapper[value] or tonumber(value) + -- end + + setmetatable(self, { __index = Flag }) + return self + end end -- Derived class method setValue @@ -65,14 +185,14 @@ end -- Scale dimension ---@param dpr number -function Flag:scale(dpr) +function Flag:scale(config, dpr) if self.value and self.value ~= '' then local scaledValue = math.ceil(self.value * (dpr or 1)) - if self.name == Flag.IMAGE_X_NAME - or self.name == Flag.IMAGE_Y_NAME - or self.name == Flag.VIDEO_X_NAME - or self.name == Flag.VIDEO_Y_NAME + if self.key == Flag.IMAGE_X_KEY + or self.key == Flag.IMAGE_Y_KEY + or self.key == Flag.VIDEO_X_KEY + or self.key == Flag.VIDEO_Y_KEY then if self.value >= 1 then self.value = scaledValue @@ -82,32 +202,32 @@ function Flag:scale(dpr) end -- Apply limits - if self.name == Flag.IMAGE_HEIGHT_NAME - and self.config.maxImageHeight - and self.value > self.config.maxImageHeight + if self.key == Flag.IMAGE_HEIGHT_KEY + and config.maxImageHeight + and self.value > config.maxImageHeight then - self.value = self.config.maxImageHeight + self.value = config.maxImageHeight end - if self.name == Flag.IMAGE_WIDTH_NAME - and self.config.maxImageWidth - and self.value > self.config.maxImageWidth + if self.key == Flag.IMAGE_WIDTH_KEY + and config.maxImageWidth + and self.value > config.maxImageWidth then - self.value = self.config.maxImageWidth + self.value = config.maxImageWidth end - if self.name == Flag.VIDEO_HEIGHT_NAME - and self.config.maxVideoHeight - and self.value > self.config.maxVideoHeight + if self.key == Flag.VIDEO_HEIGHT_KEY + and config.maxVideoHeight + and self.value > config.maxVideoHeight then - self.value = self.config.maxVideoHeight + self.value = config.maxVideoHeight end - if self.name == Flag.VIDEO_WIDTH_NAME - and self.config.maxVideoWidth - and self.value > self.config.maxVideoWidth + if self.key == Flag.VIDEO_WIDTH_KEY + and config.maxVideoWidth + and self.value > config.maxVideoWidth then - self.value = self.config.maxVideoWidth + self.value = config.maxVideoWidth end end end @@ -116,7 +236,7 @@ end ---@param dimension number function Flag:coordinateToAbsolute(dimension) if dimension - and (self.name == Flag.VIDEO_X_NAME or self.name == Flag.VIDEO_Y_NAME) + and (self.key == Flag.VIDEO_X_KEY or self.key == Flag.VIDEO_Y_KEY) and self.value and self.value > 0 and self.value < 1 diff --git a/luamp-locations.conf.example b/luamp-locations.conf.example index ac8400c..44ccb33 100644 --- a/luamp-locations.conf.example +++ b/luamp-locations.conf.example @@ -18,7 +18,6 @@ location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|) try_files $uri @luamp_media_processor; } - # image process/transcode location location @luamp_media_processor { set $luamp_original_file ""; diff --git a/media-processor.lua b/media-processor.lua index accee71..9a13989 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -59,6 +59,19 @@ local function main() log('MediaExtension: ' .. mediaExtension) log('MediaType: ' .. file.type) + local flags = {} + local flagMapper = {} + local valueMapper = config.flagValueMap + + if file.type == File.IMAGE_TYPE then + flagMapper = config.flagImageMap + elseif file.type == File.VIDEO_TYPE then + flagMapper = config.flagVideoMap + else + ngx.exit(ngx.HTTP_BAD_REQUEST) + end + + -- Serve original if there are no flags if luampFlags == '' then -- Check if the original file exists if not file:hasOriginal() then @@ -76,25 +89,12 @@ local function main() ngx.exec('/luamp-cache', { luamp_cached_file_path = file.originalFilePath }) end - local flags = {} - local flagMapper = {} - local valueMapper = {} - - if file.type == File.IMAGE_TYPE then - flags = { - [Flag.IMAGE_BACKGROUND_NAME] = Flag.new(config, Flag.IMAGE_BACKGROUND_NAME, 'white'), - [Flag.IMAGE_GRAVITY_NAME] = Flag.new(config, Flag.IMAGE_GRAVITY_NAME, 'center'), - [Flag.IMAGE_X_NAME] = Flag.new(config, Flag.IMAGE_X_NAME, 0), - [Flag.IMAGE_Y_NAME] = Flag.new(config, Flag.IMAGE_Y_NAME, 0), - [Flag.IMAGE_QUALITY_NAME] = Flag.new(config, Flag.IMAGE_QUALITY_NAME, 80), - } - flagMapper = config.flagImageMap - valueMapper = config.flagValueMap - elseif file.type == File.VIDEO_TYPE then - flagMapper = config.flagVideoMap - valueMapper = config.flagValueMap - else - ngx.exit(ngx.HTTP_BAD_REQUEST) + -- Fill flags with defaults + for _, flagKey in pairs(flagMapper) do + local flag = Flag.new(flagKey) + if flag.value then + flags[flagKey] = flag + end end -- Parse flags into a table @@ -104,29 +104,31 @@ local function main() f, v = config.flagPreprocessHook(f, v) end - local flagName = flagMapper[f] - if flagName then - flags[flagName] = Flag.new(config, flagName) - flags[flagName]:setValue(v, valueMapper) + local flagKey = flagMapper[f] + if flagKey then + if not flags[flagKey] then + flags[flagKey] = Flag.new(flagKey) + end + flags[flagKey]:setValue(v, valueMapper) end end -- Scale dimensions with respect to limits - local dpr = flags[Flag.IMAGE_DPR_NAME] or flags[Flag.VIDEO_DPR_NAME] + local dpr = flags[Flag.IMAGE_DPR_KEY] or flags[Flag.VIDEO_DPR_KEY] for flagName, _ in pairs(flags) do local flag = flags[flagName] if flag.isScalable and dpr and dpr.value then log('Scaling a flag: ' .. flagName) - flag:scale(dpr.value) + flag:scale(config, dpr.value) end end -- Calculate absolute x/y for values in (0, 1) range if file.type == File.VIDEO_TYPE then - local videoX = flags[Flag.VIDEO_X_NAME] - local videoY = flags[Flag.VIDEO_Y_NAME] - local videoWidth = flags[Flag.VIDEO_WIDTH_NAME] - local videoHeight = flags[Flag.VIDEO_HEIGHT_NAME] + local videoX = flags[Flag.VIDEO_X_KEY] + local videoY = flags[Flag.VIDEO_Y_KEY] + local videoWidth = flags[Flag.VIDEO_WIDTH_KEY] + local videoHeight = flags[Flag.VIDEO_HEIGHT_KEY] if videoX and videoWidth then videoX.coordinateToAbsolute(videoWidth.value) log('Absolute x: ' .. videoX.value) From 4e68a8d74f32faf1db53ce5c36dbda421bfb4717 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 31 Jan 2024 12:10:08 +0100 Subject: [PATCH 42/53] Fix video blur --- command.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/command.lua b/command.lua index a5654d8..032c4d9 100644 --- a/command.lua +++ b/command.lua @@ -157,6 +157,7 @@ local function buildVideoProcessingCommand(config, file, flags) local y = flags[Flag.VIDEO_Y_KEY] and flags[Flag.VIDEO_Y_KEY].value local width = flags[Flag.VIDEO_WIDTH_KEY] and flags[Flag.VIDEO_WIDTH_KEY].value local height = flags[Flag.VIDEO_HEIGHT_KEY] and flags[Flag.VIDEO_HEIGHT_KEY].value + local minpad = flags[Flag.VIDEO_MINPAD_KEY] and flags[Flag.VIDEO_MINPAD_KEY].value local preset = '' -- setting x264 preset @@ -166,7 +167,7 @@ local function buildVideoProcessingCommand(config, file, flags) local command = '' - if background == 'blur' and crop == 'limited_padding' and width and height then + if background == 'blurred' and crop == 'limited_padding' and width and height then -- scale + padded (no upscale) + blurred bg command = config.ffmpeg .. ' -i ' .. @@ -194,7 +195,7 @@ local function buildVideoProcessingCommand(config, file, flags) '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') .. '" -c:a copy ' .. preset .. file.cachedFilePath - elseif background == 'blur' and crop == 'padding' and width and height then + elseif background == 'blurred' and crop == 'padding' and width and height then -- scale + padded (with upscale) + blurred bg command = config.ffmpeg .. ' -i ' .. From d98bcecc3d4f15f4e81934e57393e45a78849c81 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 31 Jan 2024 18:34:14 +0100 Subject: [PATCH 43/53] Add minpad for limited ppad crop in videos --- TESTING.MD | 4 +- command.lua | 230 ++++++++---------- flag.lua | 2 +- ...name.jpeg => leva_test_image_836x776.jpeg} | Bin ...rename.mp4 => leva_test_video_300x400.mp4} | Bin tests/test.sh | 4 +- 6 files changed, 100 insertions(+), 140 deletions(-) rename tests/{leva_test_image_luamp_do_not_rename.jpeg => leva_test_image_836x776.jpeg} (100%) rename tests/{leva_test_video_luamp_do_not_rename.mp4 => leva_test_video_300x400.mp4} (100%) diff --git a/TESTING.MD b/TESTING.MD index 1bd26e8..5f09c5c 100644 --- a/TESTING.MD +++ b/TESTING.MD @@ -7,8 +7,8 @@ Copy `links_video.example` into `links_video` and set parameters in it. For the script to work correctly, you must place two test files in the directory specified in the **`config.lua`** file under **`config.mediaBaseFilepath`**. -- **`leva_test_video_luamp_do_not_rename.mp4`** - for video tests. -- **`leva_test_image_lua_do_not_rename.jpeg`** - for image tests. +- **`leva_test_video_300x400.mp4`** - for video tests. +- **`leva_test_image_836x776.jpeg`** - for image tests. The script should only be run from the `tests` directory. diff --git a/command.lua b/command.lua index 032c4d9..0045f16 100644 --- a/command.lua +++ b/command.lua @@ -86,13 +86,15 @@ local function buildImageProcessingCommand(config, file, flags) ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. ' \\) -compose over -composite' elseif crop == 'limited_padding' and (width or height) then + local imageWidth = width and (width - 2 * (minpad or 0)) + local imageHeight = height and (height - 2 * (minpad or 0)) command = executorWithPreset .. canvas .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+0+0 ' .. ' \\( ' .. image .. - ' -resize ' .. (width - 2 * (minpad or 0)) .. 'x' .. (height - 2 * (minpad or 0)) .. '\\>' .. + ' -resize ' .. (imageWidth or '') .. 'x' .. (imageHeight or '') .. '\\>' .. ' -set option:origwidth %w' .. ' -set option:origheight %h' .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. @@ -135,7 +137,7 @@ local function buildImageProcessingCommand(config, file, flags) end -- Apply selected color profile - if config.colorProfilePath ~= '' and File.fileExists(config.colorProfilePath) then + if config.colorProfilePath and config.colorProfilePath ~= '' and File.fileExists(config.colorProfilePath) then command = command .. ' -profile ' .. config.colorProfilePath end @@ -158,157 +160,115 @@ local function buildVideoProcessingCommand(config, file, flags) local width = flags[Flag.VIDEO_WIDTH_KEY] and flags[Flag.VIDEO_WIDTH_KEY].value local height = flags[Flag.VIDEO_HEIGHT_KEY] and flags[Flag.VIDEO_HEIGHT_KEY].value local minpad = flags[Flag.VIDEO_MINPAD_KEY] and flags[Flag.VIDEO_MINPAD_KEY].value - local preset = '' - - -- setting x264 preset - if config.ffmpegPreset ~= '' then - preset = ' -preset ' .. config.ffmpegPreset .. ' ' - end + -- Construct a command local command = '' + local filter = '' if background == 'blurred' and crop == 'limited_padding' and width and height then + local videoWidth = width - 2 * (minpad or 0) + local videoHeight = height - 2 * (minpad or 0) -- scale + padded (no upscale) + blurred bg - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. - width .. - '\\,iw*(max(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):max(' .. - height .. - '\\,ih*(max(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. - width .. - ':' .. - height .. - ', setsar=1[background];[second]scale=min(' .. - width .. - '\\,iw):min(' .. - height .. - '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. - (y or '(H-h)/2') .. - ':x=' .. (x or '(W-w)/2') .. '" -c:a copy ' .. preset .. file.cachedFilePath + filter = + 'split [first][second];' .. + -- prepare background + '[first]' .. + 'hue=b=-1,boxblur=20' .. + ',scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':force_original_aspect_ratio=increase' .. + ':force_divisible_by=2' .. + ',crop=' .. width .. ':' .. height .. + ',setsar=1[background];' .. + -- prepare foreground + '[second]' .. + 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. + ':force_original_aspect_ratio=decrease' .. + ':force_divisible_by=2' .. + ',setsar=1[foreground];' .. + -- compose + '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif background == 'blurred' and crop == 'padding' and width and height then -- scale + padded (with upscale) + blurred bg - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "split [first][second];[first]hue=b=-1,boxblur=20, scale=max(' .. - width .. - '\\,iw*(max(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):max(' .. - height .. - '\\,ih*(max(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2, crop=' .. - width .. - ':' .. - height .. - ', setsar=1[background];[second]scale=min(' .. - width .. - '\\,iw*(min(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):min(' .. - height .. - '\\,ih*(min(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1[foreground];[background][foreground]overlay=y=' .. - (y or '(H-h)/2') .. - ':x=' .. (x or '(W-w)/2') .. '" -c:a copy ' .. preset .. file.cachedFilePath + filter = + 'split [first][second];' .. + -- prepare background + '[first]' .. + 'hue=b=-1,boxblur=20' .. + ',scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':force_original_aspect_ratio=increase' .. + ':force_divisible_by=2' .. + ',crop=' .. width .. ':' .. height .. ', setsar=1[background];' .. + -- prepare foreground + '[second]' .. + 'scale=min(' .. width .. '\\,iw*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':min(' .. height .. '\\,ih*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':force_original_aspect_ratio=increase' .. + ':force_divisible_by=2' .. + ',setsar=1[foreground];' .. + -- compose + '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif crop == 'limited_padding' and width and height then + local videoWidth = width - 2 * (minpad or 0) + local videoHeight = height - 2 * (minpad or 0) + local bg = '' + if background then + bg = ':color=' .. background + end -- scale (no upscale) with padding (blackbox) - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "scale=min(' .. - width .. - '\\,iw):min(' .. - height .. - '\\,ih):force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1,pad=' .. - width .. - ':' .. - height .. - ':y=' .. - (y or '-1') .. - ':x=' .. (x or '-1') .. ':color=black" -c:a copy ' .. preset .. file.cachedFilePath + filter = + 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. + ':force_original_aspect_ratio=decrease' .. + ':force_divisible_by=2' .. + ',setsar=1' .. + ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg elseif crop == 'padding' and width and height then + local bg = '' + if background then + bg = ':color=' .. background + end -- scale (with upscale) with padding (blackbox) - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "scale=min(' .. - width .. - '\\,iw*(min(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):min(' .. - height .. - '\\,ih*(min(' .. - width .. - '/iw\\,' .. - height .. - '/ih))):force_original_aspect_ratio=increase:force_divisible_by=2,setsar=1,pad=' .. - width .. - ':' .. - height .. - ':y=' .. - (y or '-1') .. - ':x=' .. (x or '-1') .. ':color=black" -c:a copy ' .. preset .. file.cachedFilePath - elseif width and height then + filter = + 'scale=min(' .. width .. '\\,iw*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':min(' .. height .. '\\,ih*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + ':force_original_aspect_ratio=increase' .. + ':force_divisible_by=2' .. + ',setsar=1' .. + ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg + elseif width or height then + local ratio = 'decrease' + + if width and height then + ratio = 'disable' + end -- simple scale (no aspect ratio) - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "scale=' .. - width .. - ':' .. - height .. - ':force_divisible_by=2:force_original_aspect_ratio=disable,setsar=1" -c:a copy ' .. - preset .. file.cachedFilePath - elseif height then - -- simple one-side scale (h) - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "scale=-1:' .. - height .. - ':force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. - preset .. file.cachedFilePath - elseif width then - -- simple one-side scale (w) - command = config.ffmpeg .. - ' -i ' .. - file.originalFilePath .. - ' -filter_complex "scale=' .. - width .. - ':-1:force_divisible_by=2:force_original_aspect_ratio=decrease,setsar=1" -c:a copy ' .. - preset .. file.cachedFilePath + filter = + 'scale=' .. (width or '-1') .. ':' .. (height or '-1') .. + ':force_original_aspect_ratio=' .. ratio .. + ':force_divisible_by=2' .. + ',setsar=1' end - if command and command ~= '' then - if config.logFfmpegOutput == false then - command = command .. ' ' .. config.ffmpegDevNull + if filter and filter ~= '' then + command = config.ffmpeg .. ' -i ' .. file.originalFilePath .. ' -vf "' .. filter .. '" -c:a copy' + + -- setting x264 preset + if config.ffmpegPreset and config.ffmpegPreset ~= '' then + command = command .. ' -preset ' .. config.ffmpegPreset end + + command = command .. ' ' .. file.cachedFilePath + + -- Set pre-command if config.logTime then command = 'time ' .. command end + + -- Set post-command + if config.logFfmpegOutput == false then + command = command .. ' ' .. config.ffmpegDevNull + end end return command diff --git a/flag.lua b/flag.lua index 16ac79a..7232da0 100644 --- a/flag.lua +++ b/flag.lua @@ -96,7 +96,7 @@ Flag.DEFAULTS = { -- VIDEO [Flag.VIDEO_BACKGROUND_KEY] = { name = 'background', - value = nil, + value = 'black', isScalable = false, makeDir = true, }, diff --git a/tests/leva_test_image_luamp_do_not_rename.jpeg b/tests/leva_test_image_836x776.jpeg similarity index 100% rename from tests/leva_test_image_luamp_do_not_rename.jpeg rename to tests/leva_test_image_836x776.jpeg diff --git a/tests/leva_test_video_luamp_do_not_rename.mp4 b/tests/leva_test_video_300x400.mp4 similarity index 100% rename from tests/leva_test_video_luamp_do_not_rename.mp4 rename to tests/leva_test_video_300x400.mp4 diff --git a/tests/test.sh b/tests/test.sh index aafff93..2a518d5 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -1,7 +1,7 @@ source test.config CURRENT_DIR=$(pwd) -VIDEO_FILE="leva_test_video_luamp_do_not_rename.mp4" -IMAGE_FILE="leva_test_image_luamp_do_not_rename" +VIDEO_FILE="leva_test_video_300x400.mp4" +IMAGE_FILE="leva_test_image_836x776" IMAGE_FORMATS=("jpeg") LINKS_VIDEO=($(cat $CURRENT_DIR/links_video)) LINKS_IMAGES=($(cat $CURRENT_DIR/links_image)) From 59cea6021d96de10604a964d90fa2a14d2168a05 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 31 Jan 2024 19:51:46 +0100 Subject: [PATCH 44/53] Add minpad for a padding cropping of image --- command.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/command.lua b/command.lua index 0045f16..4b00895 100644 --- a/command.lua +++ b/command.lua @@ -100,13 +100,15 @@ local function buildImageProcessingCommand(config, file, flags) ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. ' \\) -compose over -composite' elseif crop == 'padding' and (width or height) then + local imageWidth = width and (width - 2 * (minpad or 0)) + local imageHeight = height and (height - 2 * (minpad or 0)) command = executorWithPreset .. canvas .. ' -resize ' .. dimensions .. '^' .. ' -crop ' .. dimensions .. '+0+0 ' .. ' \\( ' .. image .. - ' -resize ' .. dimensions .. + ' -resize ' .. (imageWidth or '') .. 'x' .. (imageHeight or '') .. ' -set option:origwidth %w' .. ' -set option:origheight %h' .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. From f2806396f0e441986bad611ba019c5e8fc0bc1cb Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 1 Feb 2024 10:23:22 +0100 Subject: [PATCH 45/53] Add minpad on video padding croping --- command.lua | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/command.lua b/command.lua index 4b00895..61d98f1 100644 --- a/command.lua +++ b/command.lua @@ -167,9 +167,14 @@ local function buildVideoProcessingCommand(config, file, flags) local command = '' local filter = '' + local videoWidth = width - 2 * (minpad or 0) + local videoHeight = height - 2 * (minpad or 0) + local bg = '' + if background then + bg = ':color=' .. background + end + if background == 'blurred' and crop == 'limited_padding' and width and height then - local videoWidth = width - 2 * (minpad or 0) - local videoHeight = height - 2 * (minpad or 0) -- scale + padded (no upscale) + blurred bg filter = 'split [first][second];' .. @@ -204,20 +209,14 @@ local function buildVideoProcessingCommand(config, file, flags) ',crop=' .. width .. ':' .. height .. ', setsar=1[background];' .. -- prepare foreground '[second]' .. - 'scale=min(' .. width .. '\\,iw*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. - ':min(' .. height .. '\\,ih*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. + ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. ',setsar=1[foreground];' .. -- compose '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif crop == 'limited_padding' and width and height then - local videoWidth = width - 2 * (minpad or 0) - local videoHeight = height - 2 * (minpad or 0) - local bg = '' - if background then - bg = ':color=' .. background - end -- scale (no upscale) with padding (blackbox) filter = 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. @@ -226,14 +225,10 @@ local function buildVideoProcessingCommand(config, file, flags) ',setsar=1' .. ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg elseif crop == 'padding' and width and height then - local bg = '' - if background then - bg = ':color=' .. background - end -- scale (with upscale) with padding (blackbox) filter = - 'scale=min(' .. width .. '\\,iw*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. - ':min(' .. height .. '\\,ih*(min(' .. width .. '/iw\\,' .. height .. '/ih)))' .. + 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. + ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. ',setsar=1' .. From a964ed9ddb0fab9ee7bdb3a73b44d5103da4dc2e Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 1 Feb 2024 16:39:04 +0100 Subject: [PATCH 46/53] Update nginx confs --- file.lua | 2 +- luamp-locations.conf.example | 15 +++------------ tests/links_image.example | 34 +++++++++++++++++----------------- tests/links_video.example | 30 +++++++++++++++--------------- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/file.lua b/file.lua index 1da2f25..e3318f1 100644 --- a/file.lua +++ b/file.lua @@ -65,7 +65,7 @@ function File.new(config, prefix, postfix, id, extension) self.type = File.TYPE_EXTENSION_MAP[extension] self.extension = extension self.name = id .. '.' .. extension - self.originalDir = config.mediaBaseFilepath .. prefix .. postfix .. id .. '/' .. extension .. '/' + self.originalDir = config.mediaBaseFilepath .. prefix .. postfix self.originalFilePath = self.originalDir .. self.name self.cacheDir = self.originalDir self.cachedFilePath = self.originalFilePath diff --git a/luamp-locations.conf.example b/luamp-locations.conf.example index 44ccb33..ed24504 100644 --- a/luamp-locations.conf.example +++ b/luamp-locations.conf.example @@ -1,17 +1,8 @@ # This file contains the shared location blocks for both HTTP and HTTPS servers -# video location -location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(mp4))$ { - set $luamp_original_file ""; - set $luamp_transcoded_file ""; - # these are needed to be set if you did not use them in regex matching location - # set $luamp_prefix ""; - # set $luamp_postfix ""; - try_files $uri @luamp_media_processor; -} - -# image location -location ~ ^/(?.*?\/upload\/)(?([^\/]+)\/|\/|)(v\d+|)\/(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp))$ { +# media location +location ~ ^/(?.*?\/upload\/)(?([^\/v]+)\/|\/|)(?.*\/|\/|)(?[0-9a-zA-Z_\-\.]+)\.(?(jpe?g|png|gif|bmp|tiff?|svg|pdf|webp|mp4))$ { + root html/media; # these are needed to be set if you did not use them in regex matching location # set $luamp_prefix ""; # set $luamp_postfix ""; diff --git a/tests/links_image.example b/tests/links_image.example index 3a0e1db..f0197fd 100644 --- a/tests/links_image.example +++ b/tests/links_image.example @@ -1,17 +1,17 @@ -amondo-media/image/upload/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_300/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_600,h_1200/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_600,h_1200,c_pad/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_600,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_600,h_1200,c_lpad/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_600,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_1200/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_600,h_600/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_600,c_pad/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_600,c_pad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_600,c_lpad/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_600,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred,r_20/v1680776555/prod/tile/media/ -amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred,r_20/v1680776555/prod/tile/media/ +amondo-media/image/upload/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_300/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_600,h_1200/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_pad/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_pad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_lpad/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_600,h_1200,c_lpad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_1200/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_600,h_600/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_pad/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_pad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_lpad/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_600,c_lpad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_lpad,b_blurred,r_20/v1690798837/dev/tile/media/ +amondo-media/image/upload/w_1200,h_1200,c_pad,b_blurred,r_20/v1690798837/dev/tile/media/ diff --git a/tests/links_video.example b/tests/links_video.example index 0a623fc..d6de1b2 100644 --- a/tests/links_video.example +++ b/tests/links_video.example @@ -1,15 +1,15 @@ -amondo-media/video/upload/v1685920143/prod/tile/media/ -amondo-media/video/upload/w_300/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_600,h_1200/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_600,h_1200,c_pad/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_600,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_600,h_1200,c_lpad/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_600,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_1200/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_600,h_600/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_600,c_pad/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_600,c_pad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_600,c_lpad/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_600,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_1200,c_lpad,b_blurred/v1680776555/prod/tile/media/ -amondo-media/video/upload/w_1200,h_1200,c_pad,b_blurred/v1680776555/prod/tile/media/ +amondo-media/video/upload/v1685920143/dev/tile/media/ +amondo-media/video/upload/w_300/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_600,h_1200/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_pad/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_pad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_lpad/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_600,h_1200,c_lpad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_1200/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_600,h_600/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_pad/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_pad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_lpad/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_600,c_lpad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_1200,c_lpad,b_blurred/v1690798837/dev/tile/media/ +amondo-media/video/upload/w_1200,h_1200,c_pad,b_blurred/v1690798837/dev/tile/media/ From 1278a6d138c8c93820f7df1e0127e3456e93bc63 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 7 Feb 2024 10:42:17 +0100 Subject: [PATCH 47/53] Test implementation of radius for videos --- command.lua | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/command.lua b/command.lua index 61d98f1..97c4bda 100644 --- a/command.lua +++ b/command.lua @@ -161,6 +161,7 @@ local function buildVideoProcessingCommand(config, file, flags) local y = flags[Flag.VIDEO_Y_KEY] and flags[Flag.VIDEO_Y_KEY].value local width = flags[Flag.VIDEO_WIDTH_KEY] and flags[Flag.VIDEO_WIDTH_KEY].value local height = flags[Flag.VIDEO_HEIGHT_KEY] and flags[Flag.VIDEO_HEIGHT_KEY].value + local radius = flags[Flag.VIDEO_RADIUS_KEY] and flags[Flag.VIDEO_RADIUS_KEY].value local minpad = flags[Flag.VIDEO_MINPAD_KEY] and flags[Flag.VIDEO_MINPAD_KEY].value -- Construct a command @@ -169,12 +170,32 @@ local function buildVideoProcessingCommand(config, file, flags) local videoWidth = width - 2 * (minpad or 0) local videoHeight = height - 2 * (minpad or 0) + local bg = '' if background then bg = ':color=' .. background end if background == 'blurred' and crop == 'limited_padding' and width and height then + local foreground = 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. + ':force_original_aspect_ratio=decrease' .. + ':force_divisible_by=2' .. + ',setsar=1' + if radius then + local doubleRadius = 2 * radius + foreground = 'scale=min(' .. 2 * videoWidth .. '\\,2*iw):min(' .. 2 * videoHeight .. '\\,2*ih)' .. + ':force_original_aspect_ratio=decrease' .. + ':force_divisible_by=2' .. + ",geq=lum='p(X,Y)'" .. + ":a='if(gt(abs(W/2-X),W/2-" .. doubleRadius .. ')*gt(abs(H/2-Y),H/2-' .. doubleRadius .. ')' .. + ',if(lte(hypot(' .. + doubleRadius .. '-(W/2-abs(W/2-X)),' .. + doubleRadius .. '-(H/2-abs(H/2-Y))),' .. + doubleRadius .. "),255,0),255)'" .. + ',format=yuva420p' .. + ',scale=iw/2:ih/2' .. + ',setsar=1' + end -- scale + padded (no upscale) + blurred bg filter = 'split [first][second];' .. @@ -186,13 +207,12 @@ local function buildVideoProcessingCommand(config, file, flags) ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. ',crop=' .. width .. ':' .. height .. - ',setsar=1[background];' .. + ',setsar=1' .. + '[background];' .. -- prepare foreground '[second]' .. - 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. - ':force_original_aspect_ratio=decrease' .. - ':force_divisible_by=2' .. - ',setsar=1[foreground];' .. + foreground .. + '[foreground];' .. -- compose '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif background == 'blurred' and crop == 'padding' and width and height then @@ -206,14 +226,17 @@ local function buildVideoProcessingCommand(config, file, flags) ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. - ',crop=' .. width .. ':' .. height .. ', setsar=1[background];' .. + ',crop=' .. width .. ':' .. height .. + ',setsar=1' .. + '[background];' .. -- prepare foreground '[second]' .. 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. - ',setsar=1[foreground];' .. + ',setsar=1' .. + '[foreground];' .. -- compose '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif crop == 'limited_padding' and width and height then From 7fb5934da9e99b3a8743663418c0fa5e462cf999 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 14 Feb 2024 15:19:33 +0100 Subject: [PATCH 48/53] Change scaling logic --- command.lua | 15 +++++++--- flag.lua | 67 +++++++++++++---------------------------- media-processor.lua | 73 ++++++++++++++++++++++++++++++++++----------- 3 files changed, 86 insertions(+), 69 deletions(-) diff --git a/command.lua b/command.lua index 97c4bda..fc51147 100644 --- a/command.lua +++ b/command.lua @@ -6,7 +6,7 @@ local Command = {} local function getCanvas(config, file, flags) local background = flags[Flag.IMAGE_BACKGROUND_KEY] and flags[Flag.IMAGE_BACKGROUND_KEY].value - local canvas = '' + local canvas if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' @@ -22,7 +22,7 @@ local function getCanvas(config, file, flags) canvas = file.originalFilePath .. ' -size %wx%h xc:' .. (background or '') .. ' -delete 0 ' end - return canvas + return canvas or '' end local function getMask(radius) @@ -168,8 +168,8 @@ local function buildVideoProcessingCommand(config, file, flags) local command = '' local filter = '' - local videoWidth = width - 2 * (minpad or 0) - local videoHeight = height - 2 * (minpad or 0) + local videoWidth = width and (width - 2 * (minpad or 0)) + local videoHeight = height and (height - 2 * (minpad or 0)) local bg = '' if background then @@ -279,6 +279,10 @@ local function buildVideoProcessingCommand(config, file, flags) end command = command .. ' ' .. file.cachedFilePath + -- TODO + -- ' -movflags +frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov ' .. file.cachedFilePath + -- ' -movflags +faststart ' .. file.cachedFilePath + -- ' -f ismv ' .. file.cachedFilePath -- Set pre-command if config.logTime then @@ -329,6 +333,9 @@ end ---@return boolean? function Command:execute() if self.isValid then + -- TODO (?) + -- local ok = shell.run(self.command) + -- return ok return os.execute(self.command) end return false diff --git a/flag.lua b/flag.lua index 7232da0..0fe9fff 100644 --- a/flag.lua +++ b/flag.lua @@ -40,7 +40,7 @@ Flag.DEFAULTS = { }, [Flag.IMAGE_DPR_KEY] = { name = 'dpr', - value = nil, + value = 1, isScalable = false, makeDir = false, }, @@ -108,7 +108,7 @@ Flag.DEFAULTS = { }, [Flag.VIDEO_DPR_KEY] = { name = 'dpr', - value = nil, + value = 1, isScalable = false, makeDir = false, }, @@ -175,60 +175,33 @@ end -- Derived class method setValue ---@param value string | number ----@param valueMapper string | number +---@param valueMapper table function Flag:setValue(value, valueMapper) if value and value ~= '' then -- Check if it is an allowed text flag or cast to a number - self.value = valueMapper[value] or tonumber(value) + self.value = (valueMapper and valueMapper[value]) or tonumber(value) end end -- Scale dimension ----@param dpr number -function Flag:scale(config, dpr) - if self.value and self.value ~= '' then - local scaledValue = math.ceil(self.value * (dpr or 1)) - - if self.key == Flag.IMAGE_X_KEY - or self.key == Flag.IMAGE_Y_KEY - or self.key == Flag.VIDEO_X_KEY - or self.key == Flag.VIDEO_Y_KEY - then - if self.value >= 1 then - self.value = scaledValue - end - else - self.value = scaledValue - end - - -- Apply limits - if self.key == Flag.IMAGE_HEIGHT_KEY - and config.maxImageHeight - and self.value > config.maxImageHeight - then - self.value = config.maxImageHeight - end - - if self.key == Flag.IMAGE_WIDTH_KEY - and config.maxImageWidth - and self.value > config.maxImageWidth - then - self.value = config.maxImageWidth - end +---@param multiplier number +function Flag:scale(multiplier) + if not self.value or self.value == '' then + return + end - if self.key == Flag.VIDEO_HEIGHT_KEY - and config.maxVideoHeight - and self.value > config.maxVideoHeight - then - self.value = config.maxVideoHeight - end + local scaledValue = math.ceil(self.value * (multiplier or 1)) - if self.key == Flag.VIDEO_WIDTH_KEY - and config.maxVideoWidth - and self.value > config.maxVideoWidth - then - self.value = config.maxVideoWidth + if self.key == Flag.IMAGE_X_KEY + or self.key == Flag.IMAGE_Y_KEY + or self.key == Flag.VIDEO_X_KEY + or self.key == Flag.VIDEO_Y_KEY + then + if self.value >= 1 then + self.value = scaledValue end + else + self.value = scaledValue end end @@ -236,7 +209,7 @@ end ---@param dimension number function Flag:coordinateToAbsolute(dimension) if dimension - and (self.key == Flag.VIDEO_X_KEY or self.key == Flag.VIDEO_Y_KEY) + -- and (self.key == Flag.VIDEO_X_KEY or self.key == Flag.VIDEO_Y_KEY) and self.value and self.value > 0 and self.value < 1 diff --git a/media-processor.lua b/media-processor.lua index 9a13989..683e102 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -4,6 +4,7 @@ local File = require('file') local Command = require('command') local log = require('log') local utils = require('utils') +local ngx = ngx ---Download original form upstream ---@param file table @@ -98,7 +99,12 @@ local function main() end -- Parse flags into a table - for f, v in string.gmatch(luampFlags, '(%w+)' .. config.flagValueDelimiter .. '([^' .. config.flagsDelimiter .. '\\/]+)' .. config.flagsDelimiter .. '*') do + for f, v in string.gmatch(luampFlags, + '(%w+)' .. config.flagValueDelimiter + .. '([^' .. config.flagsDelimiter + .. '\\/]+)' .. config.flagsDelimiter + .. '*' + ) do -- Preprocess the flag and value if necessary if config.flagPreprocessHook then f, v = config.flagPreprocessHook(f, v) @@ -113,32 +119,63 @@ local function main() end end - -- Scale dimensions with respect to limits - local dpr = flags[Flag.IMAGE_DPR_KEY] or flags[Flag.VIDEO_DPR_KEY] - for flagName, _ in pairs(flags) do + local dprFlag = flags[Flag.IMAGE_DPR_KEY] or flags[Flag.VIDEO_DPR_KEY] + local widthFlag = flags[Flag.IMAGE_WIDTH_KEY] or flags[Flag.VIDEO_WIDTH_KEY] + local heightFlag = flags[Flag.IMAGE_HEIGHT_KEY] or flags[Flag.VIDEO_HEIGHT_KEY] + local xFlag = flags[Flag.IMAGE_X_KEY] or flags[Flag.VIDEO_X_KEY] + local yFlag = flags[Flag.IMAGE_Y_KEY] or flags[Flag.VIDEO_Y_KEY] + + local dpr = dprFlag and dprFlag.value or 1 + local width = widthFlag and widthFlag.value or 0 + local height = heightFlag and heightFlag.value or 0 + local x = xFlag and xFlag.value + local y = yFlag and yFlag.value + + -- Scale dimensions with dpr + for flagName in pairs(flags) do local flag = flags[flagName] - if flag.isScalable and dpr and dpr.value then + if flag.isScalable then log('Scaling a flag: ' .. flagName) - flag:scale(config, dpr.value) + flag:scale(dpr) end end - -- Calculate absolute x/y for values in (0, 1) range - if file.type == File.VIDEO_TYPE then - local videoX = flags[Flag.VIDEO_X_KEY] - local videoY = flags[Flag.VIDEO_Y_KEY] - local videoWidth = flags[Flag.VIDEO_WIDTH_KEY] - local videoHeight = flags[Flag.VIDEO_HEIGHT_KEY] - if videoX and videoWidth then - videoX.coordinateToAbsolute(videoWidth.value) - log('Absolute x: ' .. videoX.value) + -- Apply limits and scale + local aspectRatio = 1 + local maxWidth = (file.type == File.VIDEO_TYPE and config.maxVideoWidth) or config.maxImageWidth or 0 + local maxHeight = (file.type == File.VIDEO_TYPE and config.maxVideoHeight) or config.maxImageHeight or 0 + local wAr = maxWidth / width + local hAr = maxHeight / height + + if wAr < 1 then + if hAr < 1 then + aspectRatio = math.min(wAr, hAr) + else + aspectRatio = wAr end - if videoY and videoWidth then - videoY.coordinateToAbsolute(videoHeight.value) - log('Absolute y: ' .. videoY.value) + elseif hAr < 1 then + aspectRatio = hAr + end + + for flagName in pairs(flags) do + local flag = flags[flagName] + if flag.isScalable then + log('Applying AR ' .. aspectRatio .. ' to a flag: ' .. flagName) + flag:scale(aspectRatio) end end + -- Calculate absolute x/y for values in (0, 1) range + if x and 0 < x and x < 1 and width then + xFlag:coordinateToAbsolute(width) + log('Absolute x: ' .. xFlag.value) + end + + if y and 0 < y and y < 1 and height then + yFlag:coordinateToAbsolute(height) + log('Absolute x: ' .. yFlag.value) + end + -- Recalculate cache dir path file:updateCacheDirPath(flags) From 07b4f00b09e108f8e7e14f8b269215e0f7639acc Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Wed, 14 Feb 2024 16:59:41 +0100 Subject: [PATCH 49/53] Scale fixes --- media-processor.lua | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/media-processor.lua b/media-processor.lua index 683e102..6b8593a 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -119,19 +119,9 @@ local function main() end end + -- Scale dimensions with dpr local dprFlag = flags[Flag.IMAGE_DPR_KEY] or flags[Flag.VIDEO_DPR_KEY] - local widthFlag = flags[Flag.IMAGE_WIDTH_KEY] or flags[Flag.VIDEO_WIDTH_KEY] - local heightFlag = flags[Flag.IMAGE_HEIGHT_KEY] or flags[Flag.VIDEO_HEIGHT_KEY] - local xFlag = flags[Flag.IMAGE_X_KEY] or flags[Flag.VIDEO_X_KEY] - local yFlag = flags[Flag.IMAGE_Y_KEY] or flags[Flag.VIDEO_Y_KEY] - local dpr = dprFlag and dprFlag.value or 1 - local width = widthFlag and widthFlag.value or 0 - local height = heightFlag and heightFlag.value or 0 - local x = xFlag and xFlag.value - local y = yFlag and yFlag.value - - -- Scale dimensions with dpr for flagName in pairs(flags) do local flag = flags[flagName] if flag.isScalable then @@ -141,6 +131,14 @@ local function main() end -- Apply limits and scale + local widthFlag = flags[Flag.IMAGE_WIDTH_KEY] or flags[Flag.VIDEO_WIDTH_KEY] + local heightFlag = flags[Flag.IMAGE_HEIGHT_KEY] or flags[Flag.VIDEO_HEIGHT_KEY] + local xFlag = flags[Flag.IMAGE_X_KEY] or flags[Flag.VIDEO_X_KEY] + local yFlag = flags[Flag.IMAGE_Y_KEY] or flags[Flag.VIDEO_Y_KEY] + local width = widthFlag and widthFlag.value or 0 + local height = heightFlag and heightFlag.value or 0 + local x = xFlag and xFlag.value + local y = yFlag and yFlag.value local aspectRatio = 1 local maxWidth = (file.type == File.VIDEO_TYPE and config.maxVideoWidth) or config.maxImageWidth or 0 local maxHeight = (file.type == File.VIDEO_TYPE and config.maxVideoHeight) or config.maxImageHeight or 0 From e55df39d840d486e09523f4ba260b9ea70bccce6 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Mon, 26 Feb 2024 11:06:15 +0100 Subject: [PATCH 50/53] Move flags --- command.lua | 236 ++++++++++++++++++++++++++-------------------------- 1 file changed, 117 insertions(+), 119 deletions(-) diff --git a/command.lua b/command.lua index fc51147..e73dbd3 100644 --- a/command.lua +++ b/command.lua @@ -10,8 +10,8 @@ local function getCanvas(config, file, flags) if background == 'auto' then -- Get 2 dominant colors in format 'x000000-x000000' - local cmd = config.magick .. ' ' .. file.originalFilePath .. - ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' + local cmd = config.magick .. ' ' .. file.originalFilePath + .. ' -resize 50x50 -colors 2 -format "%c" histogram:info: | awk \'{ORS=(NR%2? "-":""); print $3}\'' local dominantColors = utils.captureCommandOutput(cmd) @@ -26,10 +26,7 @@ local function getCanvas(config, file, flags) end local function getMask(radius) - local mask = - ' -size %[origwidth]x%[origheight]' .. - ' xc:black' .. - ' -fill white' + local mask = ' -size %[origwidth]x%[origheight] xc:black -fill white' if radius then mask = mask .. ' -draw "roundrectangle 0,0,%[origwidth],%[origheight],' .. radius .. ',' .. radius .. '"' @@ -73,59 +70,59 @@ local function buildImageProcessingCommand(config, file, flags) end if crop == 'fill' and (width or height) then - command = executorWithPreset .. - canvas .. - ' -resize ' .. dimensions .. '^' .. - ' -crop ' .. dimensions .. '+' .. x .. '+' .. y .. - ' \\( ' .. - image .. - ' -resize ' .. dimensions .. '^' .. - ' -crop ' .. dimensions .. '+' .. x .. '+' .. y .. - ' -set option:origwidth %w' .. - ' -set option:origheight %h' .. - ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. - ' \\) -compose over -composite' + command = executorWithPreset + .. canvas + .. ' -resize ' .. dimensions .. '^' + .. ' -crop ' .. dimensions .. '+' .. x .. '+' .. y + .. ' \\( ' + .. image + .. ' -resize ' .. dimensions .. '^' + .. ' -crop ' .. dimensions .. '+' .. x .. '+' .. y + .. ' -set option:origwidth %w' + .. ' -set option:origheight %h' + .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' + .. ' \\) -compose over -composite' elseif crop == 'limited_padding' and (width or height) then local imageWidth = width and (width - 2 * (minpad or 0)) local imageHeight = height and (height - 2 * (minpad or 0)) - command = executorWithPreset .. - canvas .. - ' -resize ' .. dimensions .. '^' .. - ' -crop ' .. dimensions .. '+0+0 ' .. - ' \\( ' .. - image .. - ' -resize ' .. (imageWidth or '') .. 'x' .. (imageHeight or '') .. '\\>' .. - ' -set option:origwidth %w' .. - ' -set option:origheight %h' .. - ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. - ' \\) -compose over -composite' + command = executorWithPreset + .. canvas + .. ' -resize ' .. dimensions .. '^' + .. ' -crop ' .. dimensions .. '+0+0 ' + .. ' \\( ' + .. image + .. ' -resize ' .. (imageWidth or '') .. 'x' .. (imageHeight or '') .. '\\>' + .. ' -set option:origwidth %w' + .. ' -set option:origheight %h' + .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' + .. ' \\) -compose over -composite' elseif crop == 'padding' and (width or height) then local imageWidth = width and (width - 2 * (minpad or 0)) local imageHeight = height and (height - 2 * (minpad or 0)) - command = executorWithPreset .. - canvas .. - ' -resize ' .. dimensions .. '^' .. - ' -crop ' .. dimensions .. '+0+0 ' .. - ' \\( ' .. - image .. - ' -resize ' .. (imageWidth or '') .. 'x' .. (imageHeight or '') .. - ' -set option:origwidth %w' .. - ' -set option:origheight %h' .. - ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. - ' \\) -compose over -composite' + command = executorWithPreset + .. canvas + .. ' -resize ' .. dimensions .. '^' + .. ' -crop ' .. dimensions .. '+0+0 ' + .. ' \\( ' + .. image + .. ' -resize ' .. (imageWidth or '') .. 'x' .. (imageHeight or '') + .. ' -set option:origwidth %w' + .. ' -set option:origheight %h' + .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' + .. ' \\) -compose over -composite' elseif width or height then local forceResizeFlag = (width and height and '! ') or '' - command = executorWithPreset .. - canvas .. - ' -resize ' .. dimensions .. forceResizeFlag .. - ' \\( ' .. - image .. - ' -resize ' .. dimensions .. forceResizeFlag .. - ' -set option:origwidth %w' .. - ' -set option:origheight %h' .. - ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' .. - ' \\) -compose over -composite' + command = executorWithPreset + .. canvas + .. ' -resize ' .. dimensions .. forceResizeFlag + .. ' \\( ' + .. image + .. ' -resize ' .. dimensions .. forceResizeFlag + .. ' -set option:origwidth %w' + .. ' -set option:origheight %h' + .. ' \\( ' .. mask .. ' \\) -compose CopyOpacity -composite' + .. ' \\) -compose over -composite' end if command and command ~= '' then @@ -177,101 +174,106 @@ local function buildVideoProcessingCommand(config, file, flags) end if background == 'blurred' and crop == 'limited_padding' and width and height then - local foreground = 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. - ':force_original_aspect_ratio=decrease' .. - ':force_divisible_by=2' .. - ',setsar=1' + local foreground = + 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' + .. ':force_original_aspect_ratio=decrease' + .. ':force_divisible_by=2' + .. ',setsar=1' if radius then local doubleRadius = 2 * radius - foreground = 'scale=min(' .. 2 * videoWidth .. '\\,2*iw):min(' .. 2 * videoHeight .. '\\,2*ih)' .. - ':force_original_aspect_ratio=decrease' .. - ':force_divisible_by=2' .. - ",geq=lum='p(X,Y)'" .. - ":a='if(gt(abs(W/2-X),W/2-" .. doubleRadius .. ')*gt(abs(H/2-Y),H/2-' .. doubleRadius .. ')' .. - ',if(lte(hypot(' .. - doubleRadius .. '-(W/2-abs(W/2-X)),' .. - doubleRadius .. '-(H/2-abs(H/2-Y))),' .. - doubleRadius .. "),255,0),255)'" .. - ',format=yuva420p' .. - ',scale=iw/2:ih/2' .. - ',setsar=1' + foreground = + 'scale=min(' .. 2 * videoWidth .. '\\,2*iw):min(' .. 2 * videoHeight .. '\\,2*ih)' + .. ':force_original_aspect_ratio=decrease' + .. ':force_divisible_by=2' + .. ",geq=lum='p(X,Y)'" + .. ":a='if(gt(abs(W/2-X),W/2-" .. doubleRadius .. ')*gt(abs(H/2-Y),H/2-' .. doubleRadius .. ')' + .. ',if(lte(hypot(' + .. doubleRadius .. '-(W/2-abs(W/2-X)),' + .. doubleRadius .. '-(H/2-abs(H/2-Y))),' + .. doubleRadius .. "),255,0),255)'" + .. ',format=yuva420p' + .. ',scale=iw/2:ih/2' + .. ',setsar=1' end -- scale + padded (no upscale) + blurred bg filter = - 'split [first][second];' .. + '[0]split [first][second];' -- prepare background - '[first]' .. - 'hue=b=-1,boxblur=20' .. - ',scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. - ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. - ':force_original_aspect_ratio=increase' .. - ':force_divisible_by=2' .. - ',crop=' .. width .. ':' .. height .. - ',setsar=1' .. - '[background];' .. + .. '[first]' + .. 'hue=b=-1,boxblur=20' + .. ',scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' + .. ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' + .. ':force_original_aspect_ratio=increase' + .. ':force_divisible_by=2' + .. ',crop=' .. width .. ':' .. height + .. ',setsar=1' + .. '[background];' -- prepare foreground - '[second]' .. - foreground .. - '[foreground];' .. + .. '[second]' + .. foreground + .. '[foreground];' -- compose - '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') + .. '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif background == 'blurred' and crop == 'padding' and width and height then -- scale + padded (with upscale) + blurred bg filter = - 'split [first][second];' .. + 'split [first][second];' -- prepare background - '[first]' .. - 'hue=b=-1,boxblur=20' .. - ',scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. - ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' .. - ':force_original_aspect_ratio=increase' .. - ':force_divisible_by=2' .. - ',crop=' .. width .. ':' .. height .. - ',setsar=1' .. - '[background];' .. + .. '[first]' + .. 'hue=b=-1,boxblur=20' + .. ',scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' + .. ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' + .. ':force_original_aspect_ratio=increase' + .. ':force_divisible_by=2' + .. ',crop=' .. width .. ':' .. height + .. ',setsar=1' + .. '[background];' -- prepare foreground - '[second]' .. - 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. - ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. - ':force_original_aspect_ratio=increase' .. - ':force_divisible_by=2' .. - ',setsar=1' .. - '[foreground];' .. + .. '[second]' + .. 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' + .. ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' + .. ':force_original_aspect_ratio=increase' + .. ':force_divisible_by=2' + .. ',setsar=1' + .. '[foreground];' -- compose - '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') + .. '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif crop == 'limited_padding' and width and height then -- scale (no upscale) with padding (blackbox) filter = - 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. - ':force_original_aspect_ratio=decrease' .. - ':force_divisible_by=2' .. - ',setsar=1' .. - ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg + 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' + .. ':force_original_aspect_ratio=decrease' + .. ':force_divisible_by=2' + .. ',setsar=1' + .. ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg elseif crop == 'padding' and width and height then -- scale (with upscale) with padding (blackbox) filter = - 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. - ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. - ':force_original_aspect_ratio=increase' .. - ':force_divisible_by=2' .. - ',setsar=1' .. - ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg + 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' + .. ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' + .. ':force_original_aspect_ratio=increase' + .. ':force_divisible_by=2' + .. ',setsar=1' + .. ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg elseif width or height then + -- simple scale local ratio = 'decrease' if width and height then ratio = 'disable' end - -- simple scale (no aspect ratio) filter = - 'scale=' .. (width or '-1') .. ':' .. (height or '-1') .. - ':force_original_aspect_ratio=' .. ratio .. - ':force_divisible_by=2' .. - ',setsar=1' + 'scale=' .. (width or '-1') .. ':' .. (height or '-1') + .. ':force_original_aspect_ratio=' .. ratio + .. ':force_divisible_by=2' + .. ',setsar=1' end if filter and filter ~= '' then - command = config.ffmpeg .. ' -i ' .. file.originalFilePath .. ' -vf "' .. filter .. '" -c:a copy' + command = config.ffmpeg .. ' -i ' .. file.originalFilePath + .. ' -filter_complex "' .. filter + .. '" -c:a copy' + .. ' -movflags +faststart' -- setting x264 preset if config.ffmpegPreset and config.ffmpegPreset ~= '' then @@ -279,10 +281,6 @@ local function buildVideoProcessingCommand(config, file, flags) end command = command .. ' ' .. file.cachedFilePath - -- TODO - -- ' -movflags +frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov ' .. file.cachedFilePath - -- ' -movflags +faststart ' .. file.cachedFilePath - -- ' -f ismv ' .. file.cachedFilePath -- Set pre-command if config.logTime then From 58018b631a7100d29dbe96f4e06af02546c84546 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 5 Mar 2024 16:41:54 +0100 Subject: [PATCH 51/53] Add rounded corners for videos --- command.lua | 149 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 99 insertions(+), 50 deletions(-) diff --git a/command.lua b/command.lua index e73dbd3..e1f2416 100644 --- a/command.lua +++ b/command.lua @@ -162,42 +162,14 @@ local function buildVideoProcessingCommand(config, file, flags) local minpad = flags[Flag.VIDEO_MINPAD_KEY] and flags[Flag.VIDEO_MINPAD_KEY].value -- Construct a command - local command = '' local filter = '' local videoWidth = width and (width - 2 * (minpad or 0)) local videoHeight = height and (height - 2 * (minpad or 0)) - local bg = '' - if background then - bg = ':color=' .. background - end - if background == 'blurred' and crop == 'limited_padding' and width and height then - local foreground = - 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' - .. ':force_original_aspect_ratio=decrease' - .. ':force_divisible_by=2' - .. ',setsar=1' - if radius then - local doubleRadius = 2 * radius - foreground = - 'scale=min(' .. 2 * videoWidth .. '\\,2*iw):min(' .. 2 * videoHeight .. '\\,2*ih)' - .. ':force_original_aspect_ratio=decrease' - .. ':force_divisible_by=2' - .. ",geq=lum='p(X,Y)'" - .. ":a='if(gt(abs(W/2-X),W/2-" .. doubleRadius .. ')*gt(abs(H/2-Y),H/2-' .. doubleRadius .. ')' - .. ',if(lte(hypot(' - .. doubleRadius .. '-(W/2-abs(W/2-X)),' - .. doubleRadius .. '-(H/2-abs(H/2-Y))),' - .. doubleRadius .. "),255,0),255)'" - .. ',format=yuva420p' - .. ',scale=iw/2:ih/2' - .. ',setsar=1' - end -- scale + padded (no upscale) + blurred bg - filter = - '[0]split [first][second];' + filter = '[0]split [first][second];' -- prepare background .. '[first]' .. 'hue=b=-1,boxblur=20' @@ -207,17 +179,33 @@ local function buildVideoProcessingCommand(config, file, flags) .. ':force_divisible_by=2' .. ',crop=' .. width .. ':' .. height .. ',setsar=1' - .. '[background];' + .. '[bg];' -- prepare foreground .. '[second]' - .. foreground - .. '[foreground];' - -- compose - .. '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') + .. 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' + .. ':force_original_aspect_ratio=decrease' + .. ':force_divisible_by=2' + .. ',setsar=1' + if radius then + filter = filter + -- prepare mask + .. '[v];' + .. '[1][v]scale2ref[image out][v out];' + .. '[image out]' + .. 'format=yuva420p' + .. ",geq=lum='p(X,Y)'" + .. ":a='if(gt(abs(W/2-X),W/2-" .. radius .. ")*gt(abs(H/2-Y),H/2-" .. radius .. ")" + .. ",if(lte(hypot(" .. + radius .. "-(W/2-abs(W/2-X))," .. radius .. "-(H/2-abs(H/2-Y)))," .. radius .. "),255,0),255)'" + .. '[a];' + .. '[a]alphaextract[mask];' + .. '[v out][mask]alphamerge' + end + -- compose + filter = filter .. '[fg];[bg][fg]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif background == 'blurred' and crop == 'padding' and width and height then -- scale + padded (with upscale) + blurred bg - filter = - 'split [first][second];' + filter = '[0]split [first][second];' -- prepare background .. '[first]' .. 'hue=b=-1,boxblur=20' @@ -227,7 +215,7 @@ local function buildVideoProcessingCommand(config, file, flags) .. ':force_divisible_by=2' .. ',crop=' .. width .. ':' .. height .. ',setsar=1' - .. '[background];' + .. '[bg];' -- prepare foreground .. '[second]' .. 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' @@ -235,26 +223,82 @@ local function buildVideoProcessingCommand(config, file, flags) .. ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. ',setsar=1' - .. '[foreground];' - -- compose - .. '[background][foreground]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') + if radius then + filter = filter + -- prepare mask + .. '[v];' + .. '[1][v]scale2ref[image out][v out];' + .. '[image out]' + .. 'format=yuva420p' + .. ",geq=lum='p(X,Y)'" + .. ":a='if(gt(abs(W/2-X),W/2-" .. radius .. ")*gt(abs(H/2-Y),H/2-" .. radius .. ")" + .. ",if(lte(hypot(" .. + radius .. "-(W/2-abs(W/2-X))," .. radius .. "-(H/2-abs(H/2-Y)))," .. radius .. "),255,0),255)'" + .. '[a];' + .. '[a]alphaextract[mask];' + .. '[v out][mask]alphamerge' + end + -- compose + filter = filter .. '[fg];[bg][fg]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif crop == 'limited_padding' and width and height then -- scale (no upscale) with padding (blackbox) filter = - 'scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' + -- prepare background + '[1]scale=' .. width .. ':' .. height + .. ',setsar=1' + .. '[bg];' + -- prepare foreground + .. '[0]scale=min(' .. videoWidth .. '\\,iw):min(' .. videoHeight .. '\\,ih)' .. ':force_original_aspect_ratio=decrease' .. ':force_divisible_by=2' .. ',setsar=1' - .. ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg + if radius then + filter = filter + -- prepare mask + .. '[v];' + .. '[1][v]scale2ref[bg out][v out];' + .. '[bg out]' + .. 'format=yuva420p' + .. ",geq=lum='p(X,Y)'" + .. ":a='if(gt(abs(W/2-X),W/2-" .. radius .. ")*gt(abs(H/2-Y),H/2-" .. radius .. ")" + .. ",if(lte(hypot(" .. + radius .. "-(W/2-abs(W/2-X))," .. radius .. "-(H/2-abs(H/2-Y)))," .. radius .. "),255,0),255)'" + .. '[a];' + .. '[a]alphaextract[mask];' + .. '[v out][mask]alphamerge' + end + -- compose + filter = filter .. '[fg];[bg][fg]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif crop == 'padding' and width and height then -- scale (with upscale) with padding (blackbox) filter = - 'scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' + -- prepare background + '[1]scale=' .. width .. ':' .. height + .. ',setsar=1' + .. '[bg];' + -- prepare foreground + .. '[0]scale=min(' .. videoWidth .. '\\,iw*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. ':min(' .. videoHeight .. '\\,ih*(min(' .. videoWidth .. '/iw\\,' .. videoHeight .. '/ih)))' .. ':force_original_aspect_ratio=increase' .. ':force_divisible_by=2' .. ',setsar=1' - .. ',pad=' .. width .. ':' .. height .. ':y=' .. (y or '-1') .. ':x=' .. (x or '-1') .. bg + if radius then + filter = filter + -- prepare mask + .. '[v];' + .. '[1][v]scale2ref[bg out][v out];' + .. '[bg out]' + .. 'format=yuva420p' + .. ",geq=lum='p(X,Y)'" + .. ":a='if(gt(abs(W/2-X),W/2-" .. radius .. ")*gt(abs(H/2-Y),H/2-" .. radius .. ")" + .. ",if(lte(hypot(" .. + radius .. "-(W/2-abs(W/2-X))," .. radius .. "-(H/2-abs(H/2-Y)))," .. radius .. "),255,0),255)'" + .. '[a];' + .. '[a]alphaextract[mask];' + .. '[v out][mask]alphamerge' + end + -- compose + filter = filter .. '[fg];[bg][fg]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') elseif width or height then -- simple scale local ratio = 'decrease' @@ -262,15 +306,21 @@ local function buildVideoProcessingCommand(config, file, flags) if width and height then ratio = 'disable' end + filter = - 'scale=' .. (width or '-1') .. ':' .. (height or '-1') + -- prepare foreground + '[0]scale=' .. (width or '-1') .. ':' .. (height or '-1') .. ':force_original_aspect_ratio=' .. ratio .. ':force_divisible_by=2' .. ',setsar=1' end if filter and filter ~= '' then - command = config.ffmpeg .. ' -i ' .. file.originalFilePath + if background == 'blurred' then + background = 'black' + end + local command = config.ffmpeg .. + ' -i ' .. file.originalFilePath .. ' -f lavfi -i color=c=' .. background .. ':s=10x10:d=1 ' .. ' -filter_complex "' .. filter .. '" -c:a copy' .. ' -movflags +faststart' @@ -291,9 +341,11 @@ local function buildVideoProcessingCommand(config, file, flags) if config.logFfmpegOutput == false then command = command .. ' ' .. config.ffmpegDevNull end + + return command end - return command + return '' end -- Build command @@ -331,9 +383,6 @@ end ---@return boolean? function Command:execute() if self.isValid then - -- TODO (?) - -- local ok = shell.run(self.command) - -- return ok return os.execute(self.command) end return false From fe73a50f953feb684dd3cb60923a6f68ea37718b Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Tue, 5 Mar 2024 18:08:11 +0100 Subject: [PATCH 52/53] Add c_fill for videos --- command.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/command.lua b/command.lua index e1f2416..76a0328 100644 --- a/command.lua +++ b/command.lua @@ -299,6 +299,14 @@ local function buildVideoProcessingCommand(config, file, flags) end -- compose filter = filter .. '[fg];[bg][fg]overlay=y=' .. (y or '(H-h)/2') .. ':x=' .. (x or '(W-w)/2') + elseif crop == 'fill' and width and height then + filter = + '[0]scale=max(' .. width .. '\\,iw*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' + .. ':max(' .. height .. '\\,ih*(max(' .. width .. '/iw\\,' .. height .. '/ih)))' + .. ':force_original_aspect_ratio=increase' + .. ':force_divisible_by=2' + .. ',crop=' .. width .. ':' .. height + .. ',setsar=1' elseif width or height then -- simple scale local ratio = 'decrease' From 8feffd45358fb99db09f72aff7ed0042c5e46395 Mon Sep 17 00:00:00 2001 From: Anatoliy Khomyakov Date: Thu, 7 Mar 2024 16:16:54 +0100 Subject: [PATCH 53/53] Remove ar scling for radius and minpad --- flag.lua | 63 ++++++++++++++++++++++++++++++--------------- media-processor.lua | 22 +++++++++------- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/flag.lua b/flag.lua index 0fe9fff..d568a68 100644 --- a/flag.lua +++ b/flag.lua @@ -29,67 +29,78 @@ Flag.DEFAULTS = { [Flag.IMAGE_BACKGROUND_KEY] = { name = 'background', value = 'white', - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = true, }, [Flag.IMAGE_CROP_KEY] = { name = 'crop', value = nil, - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = true, }, [Flag.IMAGE_DPR_KEY] = { name = 'dpr', value = 1, - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = false, }, [Flag.IMAGE_GRAVITY_KEY] = { name = 'gravity', value = 'center', - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = true, }, [Flag.IMAGE_X_KEY] = { name = 'x', value = 0, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.IMAGE_Y_KEY] = { name = 'y', value = 0, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.IMAGE_HEIGHT_KEY] = { name = 'height', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.IMAGE_WIDTH_KEY] = { name = 'width', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.IMAGE_RADIUS_KEY] = { name = 'radius', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = false, makeDir = true, }, [Flag.IMAGE_QUALITY_KEY] = { name = 'quality', value = 80, - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = true, }, [Flag.IMAGE_MINPAD_KEY] = { name = 'minpad', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = false, makeDir = true, }, @@ -97,55 +108,64 @@ Flag.DEFAULTS = { [Flag.VIDEO_BACKGROUND_KEY] = { name = 'background', value = 'black', - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = true, }, [Flag.VIDEO_CROP_KEY] = { name = 'crop', value = nil, - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = true, }, [Flag.VIDEO_DPR_KEY] = { name = 'dpr', value = 1, - isScalable = false, + isDprDependent = false, + isArDependent = false, makeDir = false, }, [Flag.VIDEO_X_KEY] = { name = 'x', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.VIDEO_Y_KEY] = { name = 'y', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.VIDEO_HEIGHT_KEY] = { name = 'height', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.VIDEO_WIDTH_KEY] = { name = 'width', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = true, makeDir = true, }, [Flag.VIDEO_RADIUS_KEY] = { name = 'radius', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = false, makeDir = true, }, [Flag.VIDEO_MINPAD_KEY] = { name = 'minpad', value = nil, - isScalable = true, + isDprDependent = true, + isArDependent = false, makeDir = true, }, } @@ -160,7 +180,8 @@ function Flag.new(key, value) self.name = defaults.name self.value = defaults.value self.key = key - self.isScalable = defaults.isScalable + self.isDprDependent = defaults.isDprDependent + self.isArDependent = defaults.isArDependent self.makeDir = defaults.makeDir -- if value and value ~= '' then diff --git a/media-processor.lua b/media-processor.lua index 6b8593a..c293029 100644 --- a/media-processor.lua +++ b/media-processor.lua @@ -119,26 +119,26 @@ local function main() end end - -- Scale dimensions with dpr + -- Apply limits and scale local dprFlag = flags[Flag.IMAGE_DPR_KEY] or flags[Flag.VIDEO_DPR_KEY] + local widthFlag = flags[Flag.IMAGE_WIDTH_KEY] or flags[Flag.VIDEO_WIDTH_KEY] + local heightFlag = flags[Flag.IMAGE_HEIGHT_KEY] or flags[Flag.VIDEO_HEIGHT_KEY] + local xFlag = flags[Flag.IMAGE_X_KEY] or flags[Flag.VIDEO_X_KEY] + local yFlag = flags[Flag.IMAGE_Y_KEY] or flags[Flag.VIDEO_Y_KEY] + + -- Scaling with DPR local dpr = dprFlag and dprFlag.value or 1 for flagName in pairs(flags) do local flag = flags[flagName] - if flag.isScalable then + if flag.isDprDependent then log('Scaling a flag: ' .. flagName) flag:scale(dpr) end end - -- Apply limits and scale - local widthFlag = flags[Flag.IMAGE_WIDTH_KEY] or flags[Flag.VIDEO_WIDTH_KEY] - local heightFlag = flags[Flag.IMAGE_HEIGHT_KEY] or flags[Flag.VIDEO_HEIGHT_KEY] - local xFlag = flags[Flag.IMAGE_X_KEY] or flags[Flag.VIDEO_X_KEY] - local yFlag = flags[Flag.IMAGE_Y_KEY] or flags[Flag.VIDEO_Y_KEY] + -- Scaling with AR local width = widthFlag and widthFlag.value or 0 local height = heightFlag and heightFlag.value or 0 - local x = xFlag and xFlag.value - local y = yFlag and yFlag.value local aspectRatio = 1 local maxWidth = (file.type == File.VIDEO_TYPE and config.maxVideoWidth) or config.maxImageWidth or 0 local maxHeight = (file.type == File.VIDEO_TYPE and config.maxVideoHeight) or config.maxImageHeight or 0 @@ -157,13 +157,15 @@ local function main() for flagName in pairs(flags) do local flag = flags[flagName] - if flag.isScalable then + if flag.isArDependent then log('Applying AR ' .. aspectRatio .. ' to a flag: ' .. flagName) flag:scale(aspectRatio) end end -- Calculate absolute x/y for values in (0, 1) range + local x = xFlag and xFlag.value + local y = yFlag and yFlag.value if x and 0 < x and x < 1 and width then xFlag:coordinateToAbsolute(width) log('Absolute x: ' .. xFlag.value)