August 3, 2023

MPV Pro — fork for professionals

MPV is the best lightweight video player that I want to improve for video professionals. The current version is to geeky for non-programmers who are not familiar with CLI. It hard to install and default config is not optimal.

What I want to change in MPV

Easy installation for macOS/Windows

Right not the only way to install MPV is to deal with CLI. I want to create a regular .app package that can be install just my drag-n-drop like any other applications on macOS or even upload it to App Store and Windows Store.

Installation from CLI is too hard for non-programmers

Valid signature

All modern OS require a signature. I can provide a signature for macOS and Windows from my company to sign the binaries for macOS and WIndows

Current mpv is not signed

Convert any video to H264 or DHNxHD for editing?

A simple option in GUI to convert any strange video format to most popular video codec ready to publish to the web or social media. Or decompress to the best editing codec.

Built-in cut feature

Easy to use cut feature to cut any part of video and export it to source codec without re-encoding or h264. Maybe this plugin https://github.com/familyfriendlymikey/mpv-cut

Suggestion for default settings

Default settings is not optimal for simple and fast operating. I suggest to change it to this:

.mpv/config

# resize window in case it's larger than screen
autofit-larger=90%x90%

# keep the player open when a file's end is reached
keep-open=yes

# disable interlace
deinterlace=no

# much faster fullscreen on macOS than native
no-native-fs

# correct window resizing with mouse wheel on hidpi macbooks
no-hidpi-window-scale

# improve playing performance at the cost of video quality
vd-lavc-fast
vd-lavc-skiploopfilter=all

.mpv/input.conf

🐛 The keyboard shortcuts works only with English keyboard layout. This bug should be fixed.

# Resize window with mouse scroll 
WHEEL_UP	add window-scale +0.0625
WHEEL_DOWN	add window-scale -0.0625

# Move back/forward by one frame
, frame-back-step ;show-text "${playback-time/full} / ${duration} (${percent-pos}%)\nframe: ${estimated-frame-number} / ${estimated-frame-count}"
. frame-step ; show-text "${playback-time/full} / ${duration} (${percent-pos}%)\nframe: ${estimated-frame-number} / ${estimated-frame-count}"

# Apply color filter in cycle
c-1 vf set "format=gamma=v-log:colorlevels=full:primaries=v-gamut" ; show-text "Panasonic V-Log"
c-2 set contrast 58 ; set brightness 0 ; set gamma -12 ; set saturation 10 ; show-text "My BMPCC4K"
c-0 vf clr "" ; set contrast 0 ; set brightness 0 ; set gamma 0 ; set saturation 0 ; show-text "Color cleared"

# Duplicate russian layout hotkeys because of MPV bug
# This bug should be fixed
й keypress "q"
ц keypress "w"
у keypress "e"
к keypress "r"
е keypress "t"
н keypress "y"
г keypress "u"
ш keypress "i"
щ keypress "o"
з keypress "p"
х keypress "["
ъ keypress "]"
ф keypress "a"
ы keypress "s"
в keypress "d"
а keypress "f"
п keypress "g"
р keypress "h"
о keypress "j"
л keypress "k"
д keypress "l"
ж keypress ";"
э keypress "'"
я keypress "z"
ч keypress "x"
с keypress "c"
м keypress "v"
и keypress "b"
т keypress "n"
ь keypress "m"
б keypress ","
ю keypress "."

./scripts/control.lua

-- Control 1.0.5
-- https://github.com/oe-d/control
-- See control.conf for settings and key binds

options = require 'mp.options'
u = require 'mp.utils'

o = {
    audio_device = 0,
    pause_minimized = 'no',
    play_restored = 'no',
    show_info = 'yes',
    info_duration = 1000,
    step_method = 'seek',
    step_delay = -1,
    step_rate = 0,
    step_mute = 'auto',
    htp_speed = 2.5,
    htp_keep_dir = 'no',
    end_rewind = 'file',
    end_exit_fs = 'no',
    audio_symbol='🔊 ',
    audio_muted_symbol='🔈 ',
    image_symbol='🖼 ',
    music_symbol='🎵 ',
    video_symbol='🎞 '
}

function init()
    options.read_options(o, 'control')
    if o.step_delay == -1 then o.step_delay = get('input-ar-delay') end
    if o.step_rate == -1 then o.step_rate = get('input-ar-rate') end
    if o.end_rewind == 'file' then mp.set_property('keep-open', 'always') end
    if o.show_info == 'start' then
        o.show_info = 'yes'
        osd:toggle()
    end
    osd.default_msg = function()
        if media.type == 'image' then
            return o.image_symbol
        elseif media.type == 'audio' then
            return o.music_symbol
        else
            local frame = get('frame')
            local frames = get('frames')
            if not frame or not frames then return o.video_symbol
            else frame = frame + 1 end
            local progress = math.floor(frame / frames * 100)
            return o.video_symbol..math.min(frame, frames)..' / '..frames..' ('..progress..'%)\n'
                ..format(math.max(get('pos') or 0, 0))..'\n'
                ..round(fps.fps, 3)..' fps ('..round(get('speed'), 2)..'x)'
        end
    end
    osd.msg_timer:kill()
    osd.osd_timer:kill()
    step.delay_timer:kill()
    step.delay_timer.timeout = o.step_delay / 1000
    step.hwdec_timer:kill()
    if o.audio_device > 0 then audio:set(o.audio_device) end
    mp.register_event('file-loaded', function() media:get_type() end)
    mp.observe_property('window-minimized', 'bool', function(_, v)
        if o.pause_minimized == 'yes' or o.pause_minimized == media:get_type() then
            if v then media.playback:on_minimize()
            elseif o.play_restored == 'yes' then media.playback:on_restore() end
        end
    end)
    mp.observe_property('playback-time', 'number', function(_, _)
        if osd.show then
            fps:tick()
            osd:set(nil, o.info_duration / 1000)
        end
    end)
    mp.observe_property('play-dir', 'string', function(_, v)
        if v == 'forward' and step.prev_hwdec then
            step.dir_frame = get('frame')
            step.hwdec_timer:resume()
        end
    end)
    mp.observe_property('eof-reached', 'bool', function(_, v)
        media.playback.eof = v
        if v and not step.played then
            if o.end_rewind ~= 'no' then
                local pos = tonumber(o.end_rewind)
                if pos then mp.set_property('playlist-pos-1', math.min(pos, get('playlist-count'))) end
                mp.add_timeout(0.01, function() media.playback.rewind(true) end)
            end
            if o.end_exit_fs == 'yes' then mp.command('set fullscreen no') end
        end
    end)
end

function split(string, pattern)
    local str = {}
    for i in string.gmatch(string, pattern) do
        table.insert(str, i)
    end
    return str
end

function round(number, decimals)
    decimals = decimals or 0
    return math.floor(number * 10 ^ decimals + 0.5) / 10 ^ decimals
end

function format(time)
    time = time or 0
    local h = math.floor(time / 3600)
    local m = math.floor(time % 3600 / 60)
    local s = time % 60
    return string.format('%02d:%02d:%06.03f', h, m, s)
end

function get(property)
    local props = {
        drops = 'frame-drop-count',
        e_fps = 'estimated-vf-fps',
        fps = 'container-fps',
        frame = 'estimated-frame-number',
        frames = 'estimated-frame-count',
        pos = 'playback-time'
    }
    for k, v in pairs(props) do
        if k == property then property = v end
    end
    return mp.get_property_native(property)
end

media = {
    type = nil,
    get_type = function(self)
        if get('track-list/0/type') == 'video' and get('frames') == 0 then
            self.type = 'image'
        elseif get('track-list/0/type') == 'audio' or get('track-list/0/albumart') == 'yes' then
            self.type = 'audio'
        else
            self.type = 'video'
        end
        return self.type
    end,
    playback = {
        eof = false,
        prev_pause = false,
        pause = function(self)
            if self.eof then
                self.rewind()
            else
                if get('pause') and step.stepped then
                    mp.commandv('seek', 0, 'relative+exact')
                    step.stepped = false
                end
                mp.command('set pause '..(get('pause') and 'no' or 'yes'))
            end
        end,
        rewind = function(pause)
            mp.commandv('seek', 0, 'absolute')
            mp.command('set pause '..(pause and 'yes' or 'no'))
        end,
        on_minimize = function(self)
            self.prev_pause = get('pause')
            mp.command('set pause yes')
        end,
        on_restore = function(self)
            if not self.prev_pause then mp.command('set pause no') end
            self.prev_pause = false
        end
    }
}

audio = {
    osd = true,
    prev_list = '',
    i = 0,
    set_prev_vol = false,
    prev_mute = false,
    prev_vol = 0,
    valid = true,
    get = function(self, index)
        local list = get('audio-device-list')
        if index and (index < 1 or index > table.getn(list)) then
            self.valid = false
            list[1].name = 'Invalid device index ('..index..')'
            list[1].description = list[1].name
            index = 1
        end
        return index and list[index] or list
    end,
    set = function(self, index)
        local name = self:get(index).name
        if self.valid then mp.command('no-osd set audio-device '..name) end
    end,
    list = function(self, list, show_index, duration)
        local msg = ''
        for i, v in ipairs(list) do
            local symbol = ''
            if v.name == get('audio-device') then
                symbol = (get('mute') or get('volume') == 0) and o.audio_muted_symbol or o.audio_symbol
            end
            i = show_index and i..': ' or ''
            msg = msg..i..symbol..string.gsub(v.description, 'Autoselect', 'Default')..'\n'
        end
        if self.osd then osd:set(msg, duration) end
    end,
    cycle = function(self, list)
        if u.to_string(list) ~= self.prev_list then self.i = 0 end
        self.prev_list = u.to_string(list)
        self.i = self.i == table.getn(list) and 1 or self.i + 1
        local remember_vol = false
        local index = 0
        local set_vol = false
        local vol = 0
        for i, v in ipairs(list) do
            local iv = split(v, '%d+')
            if i == (self.i > 1 and self.i - 1 or table.getn(list)) and string.find(v, 'r') then
                self.set_prev_vol = true
                remember_vol = true
            end
            if i == self.i then
                index = tonumber(iv[1])
                if iv[2] then
                    set_vol = true
                    vol = iv[2]
                end
            end
            list[i] = self:get(tonumber(iv[1]))
        end
        if remember_vol then
            self.prev_mute = get('mute')
            self.prev_vol = get('volume')
        end
        self.valid = true
        self:set(index)
        if set_vol then
            mp.command('no-osd set mute no')
            mp.command('no-osd set volume '..vol)
        elseif self.set_prev_vol then
            mp.command('no-osd set mute '..(self.prev_mute and 'yes' or 'no'))
            mp.command('no-osd set volume '..self.prev_vol)
            self.set_prev_vol = false
        end
        self:list(list, false, 2)
    end,
    msg_handler = function(self, cmd, ...)
        if cmd == 'list' then
            self.osd = true
            self:list(self:get(), true, 4)
        elseif cmd == 'cycle' then
            local args = {...}
            if args[1] == 'no-osd' then
                table.remove(args, 1)
                self.osd = false
            else
                self.osd = true
            end
            self:cycle(args)
        end
    end
}

fps = {
    interval = 0.5,
    fps = 0,
    prev_time = 0,
    prev_pos = 0,
    prev_drops = 0,
    prev_vop_dur = 0,
    vop_dur = 0,
    frames = 0,
    tick = function(self)
        local vop = get('vo-passes') or {fresh = {}}
        for _, v in ipairs(vop.fresh) do
            self.vop_dur = self.vop_dur + v.last
        end
        if self.vop_dur ~= self.prev_vop_dur then self.frames = self.frames + 1 end
        self.prev_vop_dur = self.vop_dur
        self.vop_dur = 0
        local fps = get('e_fps')
        local t_delta = mp.get_time() - self.prev_time
        if not fps or t_delta < self.interval then return end
        local spd = get('speed')
        local pos_delta = math.abs((get('pos') or 0) - (self.prev_pos or 0))
        local drops = (get('drops') or 0) - (self.prev_drops or 0)
        local mult = self.interval / t_delta
        local function hot_mess(speed)
            if drops > 0 and self.frames * mult < fps * speed / math.max(fps / 30, 1) * self.interval * 0.95 then
                self.fps = round(self.frames * mult, 2)
            else
                self.fps = fps * spd
            end
        end
        if spd > 1 then
            if drops > 0 and (pos_delta * mult > 2 or pos_delta * mult / self.interval > spd * 0.95 and self.frames * mult > 18 * self.interval) then
                self.fps = round(fps * pos_delta * mult / self.interval, 2)
            else
                hot_mess(1)
            end
        else
            hot_mess(spd)
        end
        self.prev_time = mp.get_time()
        self.prev_pos = get('pos')
        self.prev_drops = get('drops')
        self.frames = 0
    end
}

osd = {
    default_msg = nil,
    msg = '',
    show = false,
    toggled = false,
    osd_timer = mp.add_timeout(1e8, function() mp.set_property('osd-msg1', '') end),
    msg_timer = mp.add_timeout(1e8, function() osd.msg = osd.default_msg() end),
    set = function(self, msg, duration)
        if msg or not self.toggled or (self.toggled and self.osd_timer.timeout ~= 1e8) then
            self.osd_timer:kill()
            self.osd_timer.timeout = self.toggled and 1e8 or duration
            self.osd_timer:resume()
            mp.set_property('osd-level', 1)
        end
        if msg then
            self.msg = msg
            self.msg_timer:kill()
            self.msg_timer.timeout = duration
            self.msg_timer:resume()
            mp.add_timeout(0.1, function() mp.set_property('osd-msg1', self.msg) end)
        elseif not self.msg_timer:is_enabled() then
            self.msg = self.default_msg()
            mp.set_property('osd-msg1', self.msg)
        else
            mp.set_property('osd-msg1', self.msg)
        end
    end,
    toggle = function(self)
        self.toggled = not self.toggled
        self.show = self.toggled
        self:set(nil, 0)
    end
}

fullscreen = {
    prev_time = 0,
    clicks = 0,
    x = 0,
    click = function(self)
        if mp.get_time() - self.prev_time > 0.3 then self.clicks = 0 end
        if self.clicks == 1 and mp.get_time() - self.prev_time < 0.3 and math.abs(mp.get_mouse_pos() - self.x) < 5 then
            self.clicks = 2
        else
            self.x = mp.get_mouse_pos()
            self.clicks = 1
        end
        self.prev_time = mp.get_time()
    end,
    cycle = function(self, e)
        if self.clicks == 2 and mp.get_time() - self.prev_time < 0.3 then
            if (e == 'down' and get('fs')) or (e == 'up' and not get('fs')) then
                mp.command('cycle fullscreen')
                self.clicks = 0
            end
        end
    end,
    key_handler = function(self, e)
        if e.key_name == 'MBTN_LEFT_DBL' then
            osd:set('Bind to MBTN_LEFT. Not MBTN_LEFT_DBL.', 4)
        elseif e.event == 'press' then
            osd:set('Received a key press event.\n'
                ..'Key down/up events are required.\n'
                ..'Make sure nothing else is bound to the key.', 4)
        elseif e.event == 'down' then
            self:click()
            self:cycle(e.event)
        elseif e.event == 'up' then
            self:cycle(e.event)
        end
    end
}

step = {
    e_msg = false,
    direction = nil,
    prev_hwdec = nil,
    dir_frame = 0,
    paused = false,
    muted = false,
    prev_speed = 1,
    prev_pos = 0,
    play_speed = 1,
    stepped = false,
    played = false,
    delay_timer = mp.add_timeout(1e8, function() step:play() end),
    hwdec_timer = mp.add_periodic_timer(1 / 60, function()
        if get('play-dir') == 'forward' and not get('pause') and get('frame') ~= step.dir_frame then
            mp.command('no-osd set hwdec '..step.prev_hwdec)
            step.hwdec_timer:kill()
            step.prev_hwdec = nil
        end
    end),
    play = function(self)
        self.played = true
        if self.direction == 'backward' then mp.command('no-osd set hwdec no') end
        mp.command('no-osd set play-dir '..self.direction)
        mp.command('no-osd set speed '..self.play_speed)
        if o.step_mute == 'auto' and not self.muted then mp.command('no-osd set mute no')
        elseif o.step_mute == 'hold' then mp.command('no-osd set mute yes') end
        mp.commandv('seek', 0, 'relative+exact')
        mp.command('set pause no')
    end,
    start = function(self, dir, htp)
        self.direction = dir
        self.prev_hwdec = self.prev_hwdec or get('hwdec')
        self.paused = get('pause')
        self.muted = get('mute')
        self.prev_speed = get('speed')
        self.prev_pos = get('pos')
        if o.show_info == 'yes' then osd.show = true end
        if htp then
            self.play_speed = o.htp_speed
            self:play()
        else
            self.play_speed = o.step_rate == 0 and 1 or o.step_rate / get('fps')
            self.delay_timer:resume()
            mp.command('set pause yes')
            if dir == 'forward' and o.step_method == 'step' then
                if o.step_mute ~= 'no' then mp.command('no-osd set mute yes') end
                mp.command('frame-step')
                self.stepped = true
            elseif dir == 'backward' or get('time-pos') < get('duration') then
                mp.commandv('seek', (dir == 'forward' and 1 or -1) / get('fps'), 'relative+exact')
            end
        end
    end,
    stop = function(self, dir, htp)
        self.delay_timer:kill()
        if dir == 'backward' and get('frame') > 0 and not self.played and get('pos') == self.prev_pos then
            mp.command('frame-back-step')
            print('Backward seek failed. Reverted to backstep.')
        end
        if not htp or o.htp_keep_dir == 'no' then mp.command('no-osd set play-dir forward') end
        mp.command('no-osd set speed '..self.prev_speed)
        if not self.muted then mp.command('no-osd set mute no') end
        mp.command('set pause yes')
        if self.played then mp.commandv('seek', 0, 'relative+exact') end
        if htp and not self.paused and not (media.playback.eof and get('keep-open-pause')) then mp.command('set pause no') end
        self.played = false
        if not osd.toggled then osd.show = false end
    end,
    on_press = function(self, dir, htp)
        local msg = 'Received a key press event.\n'
            ..(htp and 'Key down/up events are required.\n'
            or 'Only single frame steps will work.\n')
            ..'Make sure nothing else is bound to the key.'
        if htp then
            osd:set(msg, 4)
            return
        else
            if not self.e_msg then
                print(msg)
                self.e_msg = true
            end
            self:start(dir, false)
            mp.add_timeout(0.1, function() self:stop(dir, false) end)
        end
    end,
    key_handler = function(self, e, dir, htp)
        if media.type ~= 'video' then return
        elseif e.event == 'press' then self:on_press(dir, htp)
        elseif e.event == 'down' then self:start(dir, htp)
        elseif e.event == 'up' then self:stop(dir, htp) end
    end
}

init()

mp.register_script_message('list-audio-devices', function() audio:msg_handler('list') end)
mp.register_script_message('set-audio-device', function(...) audio:msg_handler('cycle', ...) end)
mp.register_script_message('cycle-audio-devices', function(...) audio:msg_handler('cycle', ...) end)
mp.add_key_binding(nil, 'toggle-info', function() osd:toggle() end)
mp.add_key_binding(nil, 'cycle-pause', function() media.playback:pause() end)
mp.add_key_binding(nil, 'cycle-fullscreen', function(e) fullscreen:key_handler(e) end, {complex = true})
mp.add_key_binding(nil, 'step', function(e) step:key_handler(e, 'forward') end, {complex = true})
mp.add_key_binding(nil, 'step-back', function(e) step:key_handler(e, 'backward') end, {complex = true})
mp.add_key_binding(nil, 'htp', function(e) step:key_handler(e, 'forward', true) end, {complex = true})
mp.add_key_binding(nil, 'htp-back', function(e) step:key_handler(e, 'backward', true) end, {complex = true})