2017/07/17

mpvで部分再生やループ再生の情報をLuaスクリプトに記述して実行

メディアプレーヤのmpvでは、オプション指定により動画や音声のファイルの一部だけを再生することができ、この範囲の情報をファイルに複数記述して順に再生したい場合にはシェルスクリプトを記述して実行していたが、あまりいい形の方法ではないと考えていた。

今回、同プレーヤの内部の機能をLuaスクリプトから呼び出す仕組みを用いて、再生範囲などの情報を読み書きしやすい形で複数記述したものをプレイリストのように扱って順に再生することができるかを試していたのだが、最終的に意図した動作をするものが作れたので、Luaのコードと使い方についてをここで扱う。

また、今回作成したスクリプトでは、ファイルの特定範囲のループ再生も行えるようになっている。

  1. ソフトウェア要件
  2. コード (mpv-playparts.lua)
  3. 使い方
    1. プレイリストファイルの記述
      1. BGMループにおける使用例
      2. 書庫内ファイル再生における使用例
    2. スクリプト用の設定項目
  4. 内部の処理などに関するメモ

ソフトウェア要件

mpvでLuaを使う機能はビルド時に有効化するかどうかが選択可能で、これを有効に設定してビルドした同プレーヤのパッケージが必要。Debian/UbuntuのパッケージではLuaは有効になっている。

コード (mpv-playparts.lua)

このプログラムは単体で指定しても動作しない。後述するように、別途プレイリストとしての情報を記述したLuaスクリプトを用意し、各スクリプトからLuaのdofile()関数によりこのプログラムが呼ばれるようにした上で各プレイリストのLuaスクリプトのほうを指定して実行する形となる。

[任意]ファイル名:mpv-playparts.lua ライセンス:MIT
--[[--  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --
mpv-playparts.lua - Lua/mpv script to play parts of media file(s)
20170720
(C) 2017 kakurasan
Licensed under MIT

Usage:

----- Your script file -----
-- Default file (optional)
default_file = "/path/to/file1.ext"

-- Playlist
--   "startpos", "endpos", "loopstart", and "loopend" are absolute times
--   ab-loop is enabled if "loopstart" or "loopend" is specified (manual loop)
--                      or "autoloop" is set to true (auto loop)
--                      [vorbis-tools is required to use auto loop]
--   Defaults:
--     file = default_file, ("file" or "default_file" must be specified)
--     startpos = '0', endpos = [end of file],
--     loopstart = startpos, loopend = [end of file]
playlist = {
  -- Partial playback
  { file = "/path/to/file1.ext", startpos = "5", endpos = "8", title = "Title 1" },
  { file = "/path/to/file2.ext", startpos = "1:2", endpos = "1:5", title = "Title 2" },
  -- Manual loop
  { file = "/path/to/file3.ext", startpos = "1:2:3", loopend = "1:2:9", title = "Title 3" },
  { file = "/path/to/file4.ext", startpos = "1:3:5", loopstart = "1:3:7", loopend = "1:3:9", title = "Title 4" },
  -- Auto loop (Vorbis files which have "LOOPSTART" and optional "LOOPLENGTH")
  { file = "/path/to/file5.ogg", autoloop = true, title = "Title 5" },
  -- Files in archive files (unar is used to extract files)
  { file = "path/to/file6.ext", archive = "/path/to/archive.zip", title = "Title 6" },
}

-- Execute this file
dofile ("/path/to/mpv-playparts.lua")

----- How to run mpv -----
$ mpv --script=/path/to/your_script.lua -

----- Configuration (lua-settings/playparts.conf) -----
-- "${part-title}" expands to the title in "playlist"
-- "${part-range}" expands to the playback range
osd_font_size=[OSD font size]
osd_msg3=[OSD level3 message]
window_title=[Window title]
term_playing_msg=[Terminal message (term-playing-msg)]
term_status_msg=[Terminal message (term-status-msg)]
--  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --]]

require 'mp.options'

function secs_from_abstime (str_abstime)
  local idx_dot = str_abstime:find ('%.')
  if idx_dot then
    if str_abstime:find ('%.', idx_dot + 1) then
      -- The string has multiple dots
      return -1
    end
  end

  local idx_colon1 = str_abstime:find (':')
  if not idx_colon1 then
    -- Format: "sec"
    return tonumber (str_abstime)
  else
    local idx_colon2 = str_abstime:find (':', idx_colon1 + 1)
    if not idx_colon2 then
      -- Format: "min : sec"
      return str_abstime:sub (1, idx_colon1 - 1) * 60
             + str_abstime:sub (idx_colon1 + 1)
    else
      if not str_abstime:find (':', idx_colon2 + 1) then
        -- Format: "hour : min : sec"
        return str_abstime:sub (1, idx_colon1 - 1) * 3600
               + str_abstime:sub (idx_colon1 + 1, idx_colon2 - 1) * 60
               + str_abstime:sub (idx_colon2 + 1)
      else
        -- Format: "a : b : c : d ..." (invalid)
        return -1
      end
    end
  end
end

function reformat_abstime (str_abstime)
  local decimal = ''
  local idx_dot = str_abstime:find ('%.')
  local abstime_secs = str_abstime
  if idx_dot then
    decimal = str_abstime:sub (idx_dot)
    abstime_secs = str_abstime:sub (1, idx_dot - 1)
    if str_abstime:find ('%.', idx_dot + 1) then
      return '(invalid time)'
    end
  end

  local idx_colon1 = abstime_secs:find (':')
  if not idx_colon1 then
    if tonumber (abstime_secs) < 60 then
      return ('00:00:%02d%s'):format (abstime_secs, decimal)
    else
      return ('%02d:%02d:%02d%s'):format (abstime_secs / 3600, abstime_secs / 60 % 60, abstime_secs % 60, decimal)
    end
  else
    local idx_colon2 = abstime_secs:find (':', idx_colon1 + 1)
    if not idx_colon2 then
      return ('00:%02d:%02d%s'):format (abstime_secs:sub (1, idx_colon1 - 1), abstime_secs:sub (idx_colon1 + 1), decimal)
    else
      if not abstime_secs:find (':', idx_colon2 + 1) then
        return ('%02d:%02d:%02d%s'):format (abstime_secs:sub (1, idx_colon1 - 1), abstime_secs:sub (idx_colon1 + 1, idx_colon2 - 1), abstime_secs:sub (idx_colon2 + 1), decimal)
      else
        return '(invalid time)'
      end
    end
  end
end

function get_looptimes_from_vorbis (file)
  local rate
  local values = {}
  local f = io.popen (('ogginfo "%s"'):format (file))
  if not f then
    return '0', nil
  end
  for l in f:lines () do
    if not rate and l:sub (1, 6) == 'Rate: ' then
      rate = tonumber (l:sub (7))
    else
      for k, v in l:gmatch ('(%w+)=(%w+)') do
        values[k] = v
      end
    end
  end
  f:close ()

  local loopstart = '0'
  local loopend
  if rate then
    if values.LOOPSTART then
      loopstart = ('%.2f'):format (values.LOOPSTART / rate)
      if values.LOOPLENGTH then
        loopend = ('%.2f'):format (values.LOOPSTART / rate + values.LOOPLENGTH / rate)
      end
    end
  end

  return loopstart, loopend
end

function get_script_opts ()
  local script_opts = {
    osd_font_size = mp.get_property_number ('options/osd-font-size') * 0.65,
    osd_msg3 = '${osd-sym-cc} ${time-pos} / ${duration} (${percent-pos}%)\n[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range})${!ab-loop-a==no: <}${!ab-loop-a==no:${ab-loop-a}-${!ab-loop-b==no:${ab-loop-b}}${!ab-loop-a==no:>}}',
    window_title = '[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range}) - ${filename}',
    term_playing_msg = '[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range}) - ${filename}',
    term_status_msg = mp.get_property ('term-status-msg'),
  }
  read_options (script_opts, 'playparts')

  return script_opts
end

function make_tempdir ()
  local tempdir = nil
  local f_tempdir = io.popen ('mktemp --tmpdir -d playparts.XXXXXX')
  if f_tempdir then
    tempdir = f_tempdir:read ()
    f_tempdir:close ()
  end
  if tempdir then
    tempdir = tempdir .. package.config:sub (1, 1)
    mp.register_event ('shutdown', function (_event)
      if tempdir:match ('playparts%.') then
        print (('Deleting temporary directory "%s"'):format (tempdir))
        os.execute (('rm -fr "%s"'):format (tempdir))
      end
    end)
  end

  return tempdir
end

function load_files ()
  local need_tempdir = false
  local tempdir
  for _, item in ipairs (playlist) do
    if item.archive then
      need_tempdir = true
    end
  end
  if need_tempdir then
    tempdir = make_tempdir ()
    if not tempdir then
      print ('Error: Could not create temporary directory')
      mp.command ('quit')
      return
    end
  end

  local script_opts = get_script_opts ()

  local unar_encoding_opt = ''
  if archive_encoding then
    -- Check invalid chars in encoding name
    local match_format = archive_encoding:match ('^[0-9a-zA-Z%-]+$')
    if match_format then
      unar_encoding_opt = ('-e "%s"'):format (archive_encoding)
    end
  end

  for i, item in ipairs (playlist) do
    local do_loop = false
    if item.loopstart or item.loopend then
      if item.endpos then
        print (('Warning in #%d: endpos is specified, disabling loop'):format (i))
      else
        do_loop = true
      end
    end
    if not item.loopstart then
      if item.startpos then
        item.loopstart = item.startpos
      else
        item.loopstart = '0'
      end
    end
    if not item.startpos then
      item.startpos = '0'
    end

    if not item.file and not default_file then
      print (('Error in #%d: No file specified'):format (i))
      mp.command ('quit')
      return
    elseif secs_from_abstime (item.startpos) == -1 then
      print (('Error in #%d: startpos (%s) is invalid'):format (i, item.startpos))
      mp.command ('quit')
      return
    elseif item.endpos and secs_from_abstime (item.endpos) == -1 then
      print (('Error in #%d: endpos (%s) is invalid'):format (i, item.endpos))
      mp.command ('quit')
      return
    elseif not item.autoloop and item.loopend and secs_from_abstime (item.loopend) == -1 then
      print (('Error in #%d: loopend (%s) is invalid'):format (i, item.loopend))
      mp.command ('quit')
      return
    elseif item.endpos and secs_from_abstime (item.endpos) < secs_from_abstime (item.startpos) then
      print (('Error in #%d: endpos (%s) < startpos (%s)'):format (i, item.endpos, item.startpos))
      mp.command ('quit')
      return
    elseif not item.autoloop and secs_from_abstime (item.loopstart) < secs_from_abstime (item.startpos) then
      print (('Error in #%d: loopstart (%s) < startpos (%s)'):format (i, item.loopstart, item.startpos))
      mp.command ('quit')
      return
    elseif not item.autoloop and item.loopend and secs_from_abstime (item.loopend) < secs_from_abstime (item.startpos) then
      print (('Error in #%d: loopend (%s) < startpos (%s)'):format (i, item.loopend, item.startpos))
      mp.command ('quit')
      return
    else
      if not item.file then
        item.file = default_file
      end

      -- Media files in archive files
      if item.archive then
        item.file_in_archive = item.file
        item.file = tempdir .. item.file
        local extract_file = function ()
          if os.execute (('unar -q %s -D -f -o "%s" "%s" "%s"'):format (unar_encoding_opt, tempdir, item.archive, item.file_in_archive)) then
          else
            print (('Error in #%d: unar failed (%s)'):format (i, item.archive))
            mp.command ('quit')
          end
        end
        if i == 1 then
          extract_file ()
        else
          -- Extract 2nd ... last file asynchronously
          mp.add_timeout (0.01, extract_file)
        end
      end

      if item.autoloop then
        item.loopstart, item.loopend = get_looptimes_from_vorbis (item.file)
        do_loop = true
      end
      local opts = ('osd-font-size="%d",start="%s"'):format (script_opts.osd_font_size, item.startpos)
      if do_loop then
        opts = opts .. (',ab-loop-a="%s"'):format (item.loopstart)
        if item.loopend then
          opts = opts .. (',ab-loop-b="%s",end="%s"'):format (item.loopend, item.loopend)
        end
      elseif item.endpos then
        opts = opts .. (',end="%s"'):format (item.endpos)
      end
      local range = ('%s-'):format (reformat_abstime (item.startpos))
      if do_loop and item.loopend then
        range = range .. reformat_abstime (item.loopend)
      elseif item.endpos then
        range = range .. reformat_abstime (item.endpos)
      end
      local window_title = script_opts.window_title:gsub ('${part%-title}', item.title and item.title or '')
      window_title = window_title:gsub ('${part%-range}', range)
      local osd_msg3 = script_opts.osd_msg3:gsub ('${part%-title}', item.title and item.title or '')
      osd_msg3 = osd_msg3:gsub ('${part%-range}', range)
      local term_playing_msg = script_opts.term_playing_msg:gsub ('${part%-title}', item.title and item.title or '')
      term_playing_msg = term_playing_msg:gsub ('${part%-range}', range)
      opts = opts .. (',title="%s",osd-msg3="%s",term-playing-msg="%s"'):format (window_title, osd_msg3, term_playing_msg)
      if script_opts.term_status_msg ~= '' then
        local term_status_msg = script_opts.term_status_msg:gsub ('${part%-title}', item.title and item.title or '')
        term_status_msg = term_status_msg:gsub ('${part%-range}', range)
        opts = opts .. (',term-status-msg="%s"'):format (term_status_msg)
      end
      mp.commandv ('loadfile', item.file, mp.get_property ('playlist-pos') and 'append' or 'replace', opts)
    end
  end
end

do
  if playlist then
    load_files ()
  else
    print ('Do not specify this file directly.')
  end
end

(2017/7/18)--term-status-msgオプションに相当する設定の変更を行う設定項目を追加

(2017/7/20)書庫内のファイルの再生機能を追加(unarが必要)

使い方

上のスクリプトを任意の場所に配置し、プレイリストとなるLuaスクリプトを適切に記述した上で、mpvから--scriptオプションで各プレイリストのLuaスクリプトの場所を指定して実行する。

再生対象ファイルの場所はプレイリストとしての各スクリプトに記述することになるが、mpv--scriptオプションだけを付けて*引数がない形で実行しても同プレーヤの実行方法が表示されるだけで終了してしまう仕様なので、何か引数を付けて実行する必要がある。*下の例では標準入力の “-” を指定しているが、実際には使われない。

(標準入力を引数に指定した実行例)
$ mpv --script=/path/to/your_script.lua -

Luaスクリプト内に記述したプレイリスト項目はプレーヤ内部のプレイリストに登録されるため、*再生中に<>で再生項目を切り替えることができる。*映像のあるファイルでは “OSC” と呼ばれる画面内コントローラの三角矢印(“🞀” と “🞂”)を押してもよい。

OSDをレベル3で表示している(--osd-level=3)場合、映像を含んだファイルの再生時には通常、再生状態を示すマークの横に再生時間,全体時間,パーセンテージが表示されるが、このスクリプトで再生中はこれらに加えてプレイリスト項目の現在値と全体数(角括弧内),プレイリストに入力したタイトル,再生時間の範囲(丸括弧内),ループ時の範囲(ループ時にのみ不等号括弧に表示)といった情報を表示する。

スクリプト使用時のスクリーンショット

範囲ループ再生時はずっと再生が続くため、<>で項目を切り替えるか、もしくはqでプレーヤを終了する。再生中にlを一度押すことでもループが解除できる(“Clear A-B loop” と短時間表示されてループ範囲が消える)。この方法でループの解除を行った場合、元々のループ終了時間まで再生が進むとその項目の再生が終了する。

範囲ループ再生の解除

OSDの文字の大きさは設定ファイルや--osd-font-sizeオプションでも指定できる仕組みとなっているが、表示文字数が多くなる関係で元々の指定サイズと比べて小さめになるようにスクリプト側で倍率調整している。文字の大きさなどが好みでない場合には専用の設定ファイルやオプション指定によりカスタマイズできるようにしている(後述)。

プレイリストファイルの記述

繰り返しになるが、個別のプレイリストファイルとしてのLuaスクリプトを記述し、mpvからはこれを指定することになる。

プレイリストファイルの内容は下のような形になる。

プレイリストファイルの記述例
-- 下の "playlist" 内で "file" を記述しない場合に再生されるファイル
-- 同じファイルの部分再生を複数行う場合に便利
-- 記述しない場合は全プレイリスト項目に "file = [ファイルの場所]" が必要
default_file = "/path/to/file1.ext"

-- ファイルと再生範囲の情報
-- 項目の説明は後述
playlist = {
  -- 部分再生では "startpos" と "endpos" を必要に応じて指定する
  { file = "/path/to/file1.ext", startpos = "5", endpos = "8", title = "タイトル 1" },
  { file = "/path/to/file2.ext", startpos = "1:2", endpos = "1:5", title = "タイトル 2" },
  -- 手動ループでは "loopstart" (記述しない場合は "startpos")から
  -- "loopend" がループ範囲となる
  -- "loopstart" か "loopend" が記述されて
  -- "endpos" が記述されていない場合にループが有効になる
  { file = "/path/to/file3.ext", startpos = "1:2:3", loopend = "1:2:9", title = "タイトル 3" },
  { file = "/path/to/file4.ext", startpos = "1:3:5", loopstart = "1:3:7", loopend = "1:3:9", title = "タイトル 4" },
  -- "LOOPSTART" と追加の "LOOPLENGTH" がコメントに入っているVorbisファイルで
  -- "autoloop = true" を記述するとvorbis-toolsの外部コマンドにより
  -- 範囲情報を取得・計算してループする
  { file = "/path/to/file5.ogg", autoloop = true, title = "タイトル 5" },
}

-- 必ず上のスクリプトの場所を引数に指定して記述する
dofile ("/path/to/mpv-playparts.lua")

以下は(Luaのテーブル型の集まりである) “playlist” の中に記述する各プレイリスト項目における各種指定を行うキー名とその値の一覧となる。

項目 (連想配列のキー)
file再生するファイルの場所 (default_fileがない場合は必須で、ある場合はそれが省略時のファイルとなる)・書庫内のファイルを再生する場合は書庫内のパス名
archive書庫内のファイルを再生する場合に書庫ファイル自体の場所を指定
startpos再生開始時間 (省略時は先頭)
endpos再生終了時間 (省略時は末尾)
loopstartループ開始時間 (loopendがある場合、省略時はstartposがあればその時間・なければ先頭)
loopendループ終了時間 (loopstartがある場合、省略時は末尾となるが、endposも同時に記述されていれば無効な指定と扱いループを行わない)
autoloopVorbisファイルのループ情報を自動取得後に範囲を計算してループする場合にtrue
titleOSD,タイトルバー,端末に表示される文字列内のタイトル名部分

手動のループ再生(自分で具体的な開始・終了時間を指定する場合)では

  • loopstart” もしくは “loopend” が記述されている
  • endpos” が記述されていない

の両方を満たしているときにそのプレイリスト項目に対してループが有効になる。

RPGツクールシリーズのループBGMなどの “LOOPSTART” と “LOOPLENGTH” のコメントを含むVorbisファイルでは “autoloop = true” を付けることでvorbis-toolsのogginfoでこれらの情報(サンプル数単位)とサンプリングレートを取得して自動的にループ時間を計算してループを有効にする。ただし、情報取得処理にはコマンドからの出力の書式に依存している部分があるため、将来の同コマンドでうまく動かなくなる可能性は考えられる。現時点では、Vorbis以外のコーデックのファイルに同様のタグ情報が入っているものはループ処理されないが、将来的にループ情報コメントが埋め込まれたOpusファイルが普及すればopusinfoを用いて同様に対応する可能性はある。

なお、*プレーヤ側の問題点として、バージョン0.24.0時点では範囲ループ(ab-loop)について、ループ開始時間へのシークが正確なタイミングで行われないことがある(ループで移動する部分の再生が安定しない)。*ループ情報を含んだBGMをループ再生させる際のタイミングは1/100秒レベルの指定がされており、プレーヤへの精度の要求がシビアになっているため、今後のバージョンで改善されることを期待する。

BGMループにおける使用例

下は自動ループの例として、RPGツクールVX AceのRTPに含まれるBGMの一部をループ再生する。前述したプレイリストやループ再生に関係するキー操作を行わないと最初の曲がずっと再生されるだけなので注意。

[任意]ファイル名:vxa-rtp-bgm.lua
-- BGMファイルのディレクトリは実際の階層に合わせる
local dir = "/home/user/.wine/drive_c/Program Files/Common Files/Enterbrain/RGSS3/RPGVXAce/Audio/BGM/"
-- "file" で各BGMファイルの場所を指定し、他は "autoloop = true" のみを記述する
playlist = {
  { file = dir .. "Battle1.ogg", autoloop = true },
  { file = dir .. "Dungeon3.ogg", autoloop = true },
  { file = dir .. "Dungeon5.ogg", autoloop = true },
  { file = dir .. "Dungeon7.ogg", autoloop = true },
  { file = dir .. "Field4.ogg", autoloop = true },
  { file = dir .. "Town2.ogg", autoloop = true },
}
-- スクリプトの場所も実際の配置場所に合わせる
dofile ("/path/to/mpv-playparts.lua")

下は手動ループの例として、島白氏が公開していた曲の一部に独自にループ情報を付けたもの(調整が不十分なものがあるかもしれない)。こちらもBGMとスクリプトの場所は実際の配置場所に合わせる必要がある。

[任意]ファイル名:shima26-bgm.lua
local dir = "/path/to/bgm/"
playlist = {
  { file = dir .. "action008.mp3", loopstart = "3.76", loopend = "5:15.79", title = "シルエットの群像" },
  { file = dir .. "battle002.mp3", loopstart = "23.95", loopend = "5:26", title = "打鍵狂想曲" },
  { file = dir .. "bgm001.mp3", loopstart = "3.2", loopend = "3:5", title = "平原を西へ" },
  { file = dir .. "bgm038.mp3", loopstart = "32.7", loopend = "4:16.7", title = "思惑の螺旋" },
  { file = dir .. "bgm039.mp3", loopstart = "28.34", loopend = "6:31.61", title = "窓に降る朝の光" },
  { file = dir .. "hoshinokoe001.mp3", loopstart = "16.3", loopend = "3:7", title = "ほしのこえ" },
  { file = dir .. "Operation City.mp3", loopstart = "15.59", loopend = "3:42.1", title = "Operation city" },
  { file = dir .. "space001.mp3", loopstart = "4.1", loopend = "2:35", title = "展開メガパーセク" },
  { file = dir .. "wadatumi.mp3", loopstart = "1:7.63", loopend = "4:32.27", title = "ワダツミニトフ" },
  -- 以下、ループ曲ではないっぽいものを含めてループさせてみるシリーズ
  { file = dir .. "vellion001.mp3", loopstart = "1:19.2", loopend = "4:12.64", title = "VELLION" },
  { file = dir .. "ganz.mp3", loopstart = "25.99", loopend = "4:3.63", title = "GANZ complete" },
  { file = dir .. "blast.mp3", loopstart = "1:33.3", loopend = "4:18.6", title = "BLAST" },
  { file = dir .. "moonchildren.mp3", loopstart = "1:6.48", loopend = "4:50.8", title = "ムーンチルドレン" },
}
dofile ("/path/to/mpv-playparts.lua")

書庫内ファイル再生における使用例

20170720版から書庫内のファイルをプレイリストに記述したものをunarで一時ディレクトリ(環境変数TMPDIRもしくは/tmpの中に作成)に展開して再生できるようにした。

例として、Webで公開されている “Cresteaju” のMP3形式のBGM集(4つのZIPファイル群から成る)の全ての曲をそのまま再生するものを貼り付ける。これを指定して実行すると、終了するまでの間、一時ディレクトリを261MiB程度使用する。

[任意]ファイル名:cresteaju-mp3-bgm.lua エンコーディング:UTF-8
-- 下はZIPファイルのあるディレクトリの場所に置き換える
local dir = "/path/to/cresteaju_bgm/"

-- 日本語ファイル名のファイルを含んだZIPファイルを展開する際にエンコーディングが
-- 自動検出されるが、間違ったエンコーディングが検出された場合は
-- 文字化けが起こるため、必要であればエンコーディング名
-- (unarの-eオプションの値)を記述する
-- このファイル群では自動検出で正しく動作するので指定は不要
archive_encoding = "cp932"

-- "file" は再生したいファイルの書庫内のパス名を指定して
-- "archive" に書庫ファイル自体の場所を指定する
playlist = {
  { file = "1_01_風が吹きはじめるとき.mp3", archive = dir .. "cres_01.zip", title = "風が吹きはじめるとき" },
  { file = "1_02_Dragon Breath.mp3", archive = dir .. "cres_01.zip", title = "Dragon Breath" },
  { file = "1_03_Generator.mp3", archive = dir .. "cres_01.zip", title = "Generator" },
  { file = "1_04_Endless Way.mp3", archive = dir .. "cres_01.zip", title = "Endless Way" },
  { file = "1_05_戦いの唄.mp3", archive = dir .. "cres_01.zip", title = "戦いの唄" },
  { file = "1_06_route 134.mp3", archive = dir .. "cres_01.zip", title = "route 134" },
  { file = "1_07_following the wind.mp3", archive = dir .. "cres_01.zip", title = "following the wind" },
  { file = "1_08_さかさかば.mp3", archive = dir .. "cres_01.zip", title = "さかさかば" },
  -- ファイル名に綴りミスあり
  { file = "1_09_Cloudy Streat.mp3", archive = dir .. "cres_01.zip", title = "Cloudy Street" },
  { file = "1_10_三等星の流れ星.mp3", archive = dir .. "cres_01.zip", title = "三等星の流れ星" },
  { file = "1_11_一陣の風.mp3", archive = dir .. "cres_02.zip", title = "一陣の風" },
  { file = "1_12_Lunnar Road.mp3", archive = dir .. "cres_02.zip", title = "Lunnar Road" },
  { file = "1_13_It's time you had a rest.mp3", archive = dir .. "cres_02.zip", title = "It's time you had a rest" },
  { file = "1_14_トマトかじって、ひるはすぎ.mp3", archive = dir .. "cres_02.zip", title = "トマトかじって、ひるはすぎ" },
  -- "The" がファイル名に含まれない
  { file = "1_15_end of long dreaming.mp3", archive = dir .. "cres_02.zip", title = "The end of long dreaming" },
  { file = "1_16_永き幸せの下で.mp3", archive = dir .. "cres_02.zip", title = "永き幸せの下で" },
  { file = "1_17_The Eternal.mp3", archive = dir .. "cres_02.zip", title = "The Eternal" },
  { file = "1_18_Heat a Heart.mp3", archive = dir .. "cres_02.zip", title = "Heat a Heart" },
  { file = "1_19_seek a way.mp3", archive = dir .. "cres_02.zip", title = "seek a way" },
  { file = "1_20_霧深き森.mp3", archive = dir .. "cres_02.zip", title = "霧深き森" },
  { file = "1_21_遠き日と遠き風と.mp3", archive = dir .. "cres_02.zip", title = "遠き日と遠き風と" },
  { file = "2_01_雲の湧く場所.mp3", archive = dir .. "cres_03.zip", title = "雲の湧く場所" },
  { file = "2_02_Sunday^2.mp3", archive = dir .. "cres_03.zip", title = "Sunday^2" },
  { file = "2_03_全ウ連本部のテーマ.mp3", archive = dir .. "cres_03.zip", title = "全ウ連本部のテーマ" },
  { file = "2_04_置き去りし過去.mp3", archive = dir .. "cres_03.zip", title = "置き去りし過去" },
  { file = "2_05_なつ.mp3", archive = dir .. "cres_03.zip", title = "なつ" },
  { file = "2_06_sunset.mp3", archive = dir .. "cres_03.zip", title = "sunset" },
  -- ファイル名と曲名に表記の違いあり
  { file = "2_07_endless way_piano_version.mp3", archive = dir .. "cres_03.zip", title = "Endless Way piano version" },
  { file = "2_08_ゆりかごに翼をつけて.mp3", archive = dir .. "cres_03.zip", title = "ゆりかごに翼をつけて" },
  { file = "2_09_BREAK A FORTUNE.mp3", archive = dir .. "cres_03.zip", title = "BREAK A FORTUNE" },
  { file = "2_10_since that day.mp3", archive = dir .. "cres_03.zip", title = "since that day" },
  { file = "2_11_時の障壁.mp3", archive = dir .. "cres_04.zip", title = "時の障壁" },
  { file = "2_12_Silver Night.mp3", archive = dir .. "cres_04.zip", title = "Silver Night" },
  { file = "2_13_月夜のサミット.mp3", archive = dir .. "cres_04.zip", title = "月夜のサミット" },
  { file = "2_14_FATED FORCE.mp3", archive = dir .. "cres_04.zip", title = "FATED FORCE" },
  { file = "2_15_彗星雨.mp3", archive = dir .. "cres_04.zip", title = "彗星雨" },
  { file = "2_16_望みしその果てに.mp3", archive = dir .. "cres_04.zip", title = "望みしその果てに" },
  { file = "2_17_Never be awaken.mp3", archive = dir .. "cres_04.zip", title = "Never be awaken" },
  { file = "2_18_IN THE STREAM.mp3", archive = dir .. "cres_04.zip", title = "IN THE STREAM" },
  { file = "2_19_世界のかけら.mp3", archive = dir .. "cres_04.zip", title = "世界のかけら" },
  { file = "2_20_風は時を越えて.mp3", archive = dir .. "cres_04.zip", title = "風は時を越えて" },
}
-- スクリプトの場所も実際の場所に置き換える
dofile ("/path/to/mpv-playparts.lua")

スクリプト用の設定項目

カスタマイズのために、本スクリプト専用の幾つかの設定項目を用意している。

名前説明
osd_font_sizeOSDのフォントサイズ
osd_msg3OSDレベル3用のメッセージ
window_title映像のあるファイルを再生している際のウィンドウタイトル
term_playing_msgファイル再生時に端末の時間表示の上の行に表示される文字列
term_status_msgファイル再生時に端末の時間表示の行に表示される文字列

項目の中で “osd_font_size” 以外の項目については、以下の記述で内容を展開することができる。これらを他のプロパティ展開(後述)とともに用いることで、OSDの表示文字列などの書式を自由にカスタマイズできる。

記述展開される内容
${part-title}プレイリスト内に記述した各タイトル
${part-range}開始時間と終了時間(例:“01:23:45-01:43:21”)

下は設定ファイルの記述例。lua-settingsというディレクトリがない場合は作成し、その中にファイルを作成する。

ファイル名:${XDG_CONFIG_HOME}/mpv/lua-settings/playparts.conf もしくは [ホームディレクトリ]/.config/mpv/lua-settings/playparts.conf
# OSDのフォントサイズを36にする場合
osd_font_size=36

# 下の行は本記事のスクリプトにおける既定のOSDレベル3用メッセージ
#osd_msg3=${osd-sym-cc} ${time-pos} / ${duration} (${percent-pos}%)\n[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range})${!ab-loop-a==no: <}${!ab-loop-a==no:${ab-loop-a}-${!ab-loop-b==no:${ab-loop-b}}${!ab-loop-a==no:>}}
# 下の設定では全体の長さやパーセンテージの表示をなくして1行にする
# (フォントサイズによっては複数行になるのでosd_font_sizeや本項目の調整が必要)
osd_msg3=${time-pos} ${osd-sym-cc} [${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range})${!ab-loop-a==no: <}${!ab-loop-a==no:${ab-loop-a}-${!ab-loop-b==no:${ab-loop-b}}${!ab-loop-a==no:>}}

# 下の行は本記事のスクリプトにおける既定のウィンドウタイトル
#window_title=[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range}) - ${filename}
# 下の設定ではループ有効時にループ範囲表示を付け加える
window_title=[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range}) - ${filename}${!ab-loop-a==no: <}${!ab-loop-a==no:${ab-loop-a}-${!ab-loop-b==no:${ab-loop-b}}${!ab-loop-a==no:>}}

# 下の行は本記事のスクリプトにおける端末への既定の表示文字列
#term_playing_msg=[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range}) - ${filename}
# 下の設定ではファイル名を表示しない
# また、最初に改行を入れており、タグ情報表示によりずれるのを防ぐ
term_playing_msg=\n[${playlist-pos-1}/${playlist/count}] ${part-title} (${part-range})

# 端末の再生時間表示部分をカスタマイズする場合
#term_status_msg=${?pause==yes:(Paused) }${?audio-codec:A}${?video-codec:V}: ${time-pos} / ${duration} (${percent-pos}%)${!ab-loop-a==no: <}${!ab-loop-a==no:${ab-loop-a}-${!ab-loop-b==no:${ab-loop-b}}${!ab-loop-a==no:>}}

コマンド行オプションによる指定は--script-opts=playparts-[項目名1]=[値1],playparts-[項目名2]=[値2],playparts-[項目名3]=[値3]といった形になり、設定ファイルよりも高優先度となる。

(フォントサイズを48にする場合)
$ mpv --script=/path/to/your_script.lua --script-opts=playparts-osd_font_size=48 -

上の設定ファイル例の “osd_msg3” を適用した場合の表示は下のようになる。タイトル部分が長くなったりループ範囲が表示されたりすると表示が複数行になる可能性があるため、1行に収めたい場合はフォントサイズにも注意が必要。

OSDレベル3の文字列をコンパクトにしたもの

OSDのフォントサイズについては、解像度の小さな動画をウィンドウ(全画面ではない表示)で再生すると

解像度の小さな動画をウィンドウで再生

上のように文字が小さくなるため、ウィンドウでファイルを再生する場合は再生したいファイルの解像度で文字が読みづらくならないかを考えて調整する。

内部の処理などに関するメモ

  • Luaスクリプトからは “mp.[関数名]” 形式の関数を呼び出すことでmpv内部の機能を用いるのだが、今回のスクリプトでは主に内部コマンド呼び出しのmp.commandv()しか使っていない
  • mpv内部の各種情報が “プロパティ” として読み書きでき、mp.get_property()系関数で読み込みが、mp.set_property()系関数で書き込みがそれぞれ可能
  • 部分再生やループ再生の情報はファイル再生を行う “loadfile” という内部コマンドへのオプションとして渡しており、このオプション渡しは本スクリプトの中で特に重要な役割を担っている(“loadfile” へ渡す際には先頭のハイフン2つは省く)
    • --start: 再生開始時間を指定
    • --end: 再生終了時間を指定
    • --ab-loop-a: 範囲ループを有効化してループ開始時間を指定
    • --ab-loop-b: 範囲ループを有効化してループ終了時間を指定
    • --title: 映像を含んだファイルでウィンドウタイトルを指定
    • --osd-msg3: レベル3のOSDの表示内容を指定
    • --term-playing-msg: 再生時の端末の時間表示の上の行への表示文字列を指定
    • --term-status-msg: 再生時の端末の時間表示の行への表示文字列を指定
  • --osd-msg3などのオプションに渡している文字列の中で “${time-pos}” のような表記の(一部を除いてリアルタイムで内容が変更される)プロパティ展開を用いている
  • 内部コマンドの “loadfile” では読み込んだファイルをどのタイミングで再生するかを2番目の引数で制御でき、mpvへの引数があっても “replace” を指定することで別のファイルを読み込ませることで元々の入力(本記事の実行例では標準入力)を閉じてこれを置き換える形で再生を開始することができるが、複数のファイルを続けて再生したい場合は “append” でプレーヤ内プレイリストに追加しておかないと(再生対象の置き換え処理が連続した結果として)いきなり最後のファイルが再生されてしまうため、今回は “playlist-pos” のプロパティを参照して場合分けした
  • 再生に関する時間(位置)指定の形式は、 “loadfile” に渡す際にmpv側の受け付ける形であれば問題ないが、スクリプト側で無効な指定がないかチェックするようにしたかったので、Luaの文字列処理機能を用いて色々な処理を行っており、OSDなどの時間表示の整形処理も行っている
  • Luaスクリプト用のオプション(や設定ファイル項目)を受け取る仕組みと実装例はマニュアルのoptions.read_options()の項目に書かれており、今回はスクリプトの識別名として “playparts” という名前を使用した
使用したバージョン:
  • mpv 0.24.0
  • liblua 5.2.4
  • vorbis-tools 1.4.0