2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OBSの動画タイムスタンプ用Luaスクリプト作成時に直面した課題

2
Posted at

はじめに

動画を見返しながらメモを取る作業は、意外と手間がかかります。
特に、長時間の録画や作業配信では、

  • 「この発言、後で見返したい」
  • 「ここでミスした」
  • 「このタイミングで重要な処理が入った」
    といったポイントを、動画の時間と紐づけて記録する必要が出てきます。

しかし、OBS 標準の機能では「録画中にキーでタイムスタンプを書き残す」といった用途を、そのまま満たすことはできません。また、録画後に動画とメモを行き来しながら作業するのも、地味にストレスになります。

そこで今回、「録画中に特定のキーを押すだけでタイムスタンプを記録し、録画終了後には自動で動画とメモを紐づけて確認できる」
という一連の流れを、OBS のLuaスクリプトとして実装しました。

本記事では、作成したスクリプトの概要と設計意図に加えて、実装の中で特に詰まったポイント(文字列エンコーディング、多重化完了検知、ブラウザ操作)についてまとめます。

制作物

今回作成したものは、"録画中に特定のキーを押すと、自動でタイムスタンプが記録される機能"を持つLuaスクリプトです。

image.png

また、追加機能として、"録画終了時にChromeのメディアプレイヤーに自動でアクセスする機能"を実装しました。これにより、録画からタイムスタンプへのメモ書きまでがスムーズに行えるようなパイプラインを用意しました。

Luaスクリプトを用いた経緯

OBSには、プラグインとスクリプトという大きく分けて2つの拡張性があります。
その中でも、スクリプトではLuaとPythonの2つの言語を使用することができます。
今回は、Lua言語を用いたスクリプトを実装しました。

選定理由

  • 実装機能のシンプルさ
    今回実装した機能は極めてシンプルであるため、スクリプトで十分であると判断しました。
  • 導入手順の少なさ
    Pythonを用いた方が、Webの操作やAIの利活用がしやすいというメリットがありますが、別途Python実行ファイルのパスを追加する必要があります。そのため、今回はLuaを用いることで一つのスクリプトファイルを読み込ませるだけで、実現可能なLuaスクリプトを用いることにしました。

Lua実装で困った点

文字列のエンコーディング

OBSのLuaスクリプトを実装する際、苦労した点の一つが文字列のエンコーディングでした。

Lua の文字列は内部的には単なるバイト列であり、OBS Lua環境では UTF-8を前提として扱われています。一方で、Windowsの各種API(CreateFileW、WinHttp*、ShellExecuteW など)は UTF-16(WCHAR)を前提としています。この前提の違いによって、日本語を含むファイルパスやフォルダパスを扱う場面でエラーになるという課題に直面しました。

この問題を回避するため、LuaからWindows APIを呼び出す際に、UTF-8からUTF-16への変換を行う方針を取りました。

-- UTF-8パス対応でテキストをファイル末尾に追記する(WinAPI CreateFileW+WriteFile使用)
local function write_utf8_append(path_utf8, text_utf8)
  local C = ffi.C
  local CP_UTF8 = 65001
  local FILE_APPEND_DATA = 0x00000004
  local FILE_SHARE_READ  = 0x00000001
  local FILE_SHARE_WRITE = 0x00000002
  local OPEN_ALWAYS = 4
  local FILE_ATTRIBUTE_NORMAL = 0x00000080
  local INVALID_HANDLE_VALUE = ffi.cast("HANDLE", -1)

  local function utf8_to_wide(s)
    local n = C.MultiByteToWideChar(CP_UTF8, 0, s, #s, nil, 0)
    if n == 0 then return nil end
    local buf = ffi.new("WCHAR[?]", n + 1)
    C.MultiByteToWideChar(CP_UTF8, 0, s, #s, buf, n)
    buf[n] = 0
    return buf
  end

  local wpath = utf8_to_wide(path_utf8)
  if not wpath then return false, "MultiByteToWideChar(path) failed" end

  local h = C.CreateFileW(
    wpath,
    FILE_APPEND_DATA,
    bit.bor(FILE_SHARE_READ, FILE_SHARE_WRITE),
    nil,
    OPEN_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    nil
  )
  if h == INVALID_HANDLE_VALUE then
    return false, "CreateFileW failed"
  end

  local written = ffi.new("DWORD[1]")
  local ok = C.WriteFile(h, text_utf8, #text_utf8, written, nil) ~= 0
  C.CloseHandle(h)
  if not ok or tonumber(written[0]) ~= #text_utf8 then
    return false, "WriteFile failed"
  end
  return true
end

-- UTF-8文字列をWindows API用のUTF-16配列に変換する関数
local function W(s)
    local CP_UTF8 = 65001
    local n = ffi.C.MultiByteToWideChar(CP_UTF8, 0, s, #s, nil, 0)
    local buf = ffi.new("unsigned short[?]", n + 1)
    ffi.C.MultiByteToWideChar(CP_UTF8, 0, s, #s, buf, n)
    buf[n] = 0
    return buf
end

local function file_exists_utf8(path_utf8)
    local w = W(path_utf8); if not w then return false end
    local a = ffi.C.GetFileAttributesW(w)
    local INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
    return a ~= INVALID_FILE_ATTRIBUTES
end

Remux(再多重化)処理の終了検知

追加機能として録画した動画とタイムスタンプ付きのテキストファイルをChromeのメディアプレイヤーに自動で読み込む機能をつけた際に生じた問題です。

OBSで録画を行った場合、出力形式によっては録画停止後に Remux(再多重化) が自動で実行されます。例えば、MKV形式で録画し、停止後にMP4に変換する設定にしている場合、録画停止イベントが発生した時点では、最終的なMP4ファイルはまだ完成していません。もちろんmkvを処理することも可能ではあるのですが、以下のような観点でMP4の出力を用いたいと考えました。

MKV の利点

  • 録画中に OBS や OS がクラッシュしても ファイルが壊れにくい
  • ファイル構造がストリーム指向で、途中まででも再生可能

MKV の欠点

  • ブラウザや一部プレーヤーで直接再生できない
  • 最終成果物としては扱いにくい

この挙動が問題になるのは、「録画停止(event = obs.OBS_FRONTEND_EVENT_RECORDING_STOPPED)をトリガーにして、直後に動画ファイルを別処理に渡したい」場合です。本実装では、録画停止後に自動でメディアプレーヤーを起動し、生成された動画ファイルを読み込ませる処理を行っていますが、Remux が完了する前にファイルを開こうとすると、正しく動画を読み込めない問題がありました。

OBSのLua API には、「Remux 完了」を直接通知してくれるイベントは用意されていないため、終了検知を自前で実装する必要がありました。

そこで本実装では、Remux 後に生成されるMP4ファイルを対象に、ファイルサイズの変化をポーリングで監視する方法を採用しています。具体的には、以下のような流れです。

録画停止イベントを受け取る
↓
出力ファイル名(`obs.obs_frontend_get_last_recording()`)から Remux 後の MP4 パスを推定する
↓
一定間隔でファイルサイズを取得する
↓
ファイルサイズが連続して変化しなくなった時点で「完成」とみなす

ファイルサイズの取得には GetFileAttributesExW を使用し、日本語パスにも対応できるよう UTF-16変換を行っています。

サイズが数回連続して変化しなかった場合に完了と判断することで、処理途中の一時的な停止や、OS のファイルキャッシュの影響をある程度吸収できるようにしています。この方法は厳密な同期ではありませんが、実用上は十分に安定して動作しました。

-- ====== Remux(再多重化) 完了待ち(非同期ポーリング、タイムアウトなし) ======
local remux_watch = nil  -- { mp4_path=..., last_size=nil, stable_count=0, ready_after=... }


local function remux_poll()
  if not remux_watch then
    obs.timer_remove(remux_poll)
    return
  end

  -- 初期0.5s待ち
  if os.clock() < remux_watch.ready_after then
    return
  end

  local sz = get_file_size(remux_watch.mp4_path)
  if not sz then
    -- まだファイルがない
    return
  end

  if remux_watch.last_size and sz == remux_watch.last_size then
    remux_watch.stable_count = remux_watch.stable_count + 1
  else
    remux_watch.stable_count = 0
    remux_watch.last_size = sz
  end

  -- 連続3回(約1.5s)サイズ不変 => 完成とみなす
  if remux_watch.stable_count >= 3 then
    log(string.format("mp4完成を検知: %s", remux_watch.mp4_path))
    obs.timer_remove(remux_poll)
    local target = remux_watch.mp4_path
    recent_video_path = target
    if g_settings then
        script_save(g_settings)
    end
    remux_watch = nil

    if open_player_on_stop then open_mediaplayer_with_recent() end
  end
end

local function wait_mp4_async(original_path)
    local mp4_path = original_path:gsub("%.[^\\/.]+$", ".mp4")
    remux_watch = {
      mp4_path    = mp4_path,
      last_size   = nil,
      stable_count= 0,
      ready_after = os.clock() + 0.5,  -- 初回は0.5s待ち
    }
    obs.timer_add(remux_poll, 500)  -- 500msごとにチェック
end

ブラウザ操作

今回の実装では、録画終了後に Web 上のメディアプレーヤーやアップロードページを自動で操作する必要がありました。一見すると、JavaScript を実行すれば簡単に実現できそうに見えますが、Lua から直接ブラウザ内の JavaScript を実行することはできません。

Lua はあくまで OBS 上で動作するスクリプト言語であり、ブラウザの JavaScript 実行環境とは完全に分離されています。そのため、

  • 既存のブラウザ操作用JavaScriptをそのまま流用する

  • DOMを直接触るJavaScriptをLuaから呼び出す

といったことは不可能でした。

この制約の結果、「外部からブラウザを操作する仕組み」を使わざるを得なくなり、最終的に Chrome DevTools Protocol(CDP)を利用する設計に行き着きました。

Chrome DevTools Protocol は、本来は開発者ツールが内部で使っているプロトコルであり、WebSocket 経由で Chrome を遠隔操作するための仕組みです。CDP を使うことで、以下の操作が可能になります。

  • ページ遷移
  • DOM 要素の取得・操作

特に今回の実装では、ローカルの動画ファイルやテキストファイルを Web ページに読み込ませる必要がありました。この操作は、通常の JavaScript ではセキュリティ上の理由から実行できませんが、CDP を介した操作であれば実現できます。


local function open_mediaplayer_with_recent()
  local video, txt = get_recent_video_and_saved_txt()
  if not video and not txt then
    warn("最近使用した動画/テキストが見つかりません")
    return
  end
  if not start_chrome_if_needed() then err("ChromeのCDPポートに接続できません"); return end

  local enc = urlencode(mediaplayer_url)
  local new_json = http_req("PUT","127.0.0.1",cdp_port,"/json/new?"..enc,nil,nil)
  if not new_json or new_json=="" then
    new_json = http_req("PUT","127.0.0.1",cdp_port,"/json/new?url="..enc,nil,nil)
  end

  -- page の WS を優先して拾う
  local ws_url = new_json and pick_page_ws(new_json)
  if not ws_url then
        warn("newでWS取れず。/jsonから既存タブを検索")
        local list_json = http_req("GET","127.0.0.1",cdp_port,"/json",nil,nil)
        if not list_json or list_json=="" then
          err("new/json 取得失敗。Chromeの --remote-debugging-port="..cdp_port.." を確認してね。")
          return
        end
        ws_url = pick_page_ws(list_json)
        if not ws_url then
          err("page ターゲットが見つかりません。/json 応答の一部: "..string.sub(list_json,1,200))
          return
        end
  end

  local path_ws = ws_url:match("ws://127%.0%.0%.1:"..cdp_port.."(.*)") or ws_url:match("ws://localhost:"..cdp_port.."(.*)")
  if not path_ws then err("WSパス抽出失敗: "..tostring(ws_url)); return end

  local ws, ee = ws_connect("127.0.0.1",cdp_port,path_ws); if not ws then err("WS接続失敗: "..(ee or "?")); return end
  if not (cdp_send(ws,"Page.enable") and cdp_send(ws,"Network.enable") and cdp_send(ws,"DOM.enable") and cdp_send(ws,"Runtime.enable")) then
    err("CDP enable失敗"); ws_close(ws); return
  end

  cdp_send(ws, "Page.navigate", string.format('{"url":"%s"}', mediaplayer_url))
  for _=1,100 do
    local r = cdp_send(ws,"Runtime.evaluate",'{"expression":"document.readyState","returnByValue":true}')
    local st = r and r:match([["value"%s*:%s*"([^"]+)"]])
    if st=="complete" or st=="interactive" then break end
    sleep(0.1)
  end

  local doc = cdp_send(ws, "DOM.getDocument", '{"depth":-1,"pierce":true}')
  local rootId = doc and doc:match([["nodeId"%s*:%s*(%d+)]])
  if not rootId then err("root nodeId取得失敗"); ws_close(ws); return end

  local function qsel(selector)
    local q = cdp_send(ws, "DOM.querySelector", string.format('{"nodeId":%s,"selector":%q}', rootId, selector))
    return q and q:match([["nodeId"%s*:%s*(%d+)]])
  end
  local videoNode = qsel("#video-file")
  local annoNode  = qsel("#annotation-file")
  local videoNode2 = qsel("#videoFile")

  if video and videoNode then
    cdp_send(ws, "DOM.setFileInputFiles", string.format('{"nodeId":%s,"files":["%s"]}', videoNode, json_escape(video)))
  end
  if txt and annoNode then
    cdp_send(ws, "DOM.setFileInputFiles", string.format('{"nodeId":%s,"files":["%s"]}', annoNode, json_escape(txt)))
  end

  if videoNode2 then
    if video then
      cdp_send(ws, "DOM.setFileInputFiles", string.format('{"nodeId":%s,"files":["%s"]}', videoNode2, json_escape(video)))
    end
    if txt then
      cdp_send(ws, "DOM.setFileInputFiles", string.format('{"nodeId":%s,"files":["%s"]}', videoNode2, json_escape(txt)))
    end
  end

  ws_close(ws)
end

まとめ

今回の Lua スクリプトは、機能としては「タイムスタンプを記録する」「録画後にブラウザを開く」という比較的シンプルなものですが、実装してみると OBS・Windows・ブラウザという複数の境界をまたぐ必要があり、想像以上に考えることが多いものでした。

Lua は「簡単なスクリプト言語」という印象とは裏腹に、OS やブラウザと深く連携しようとすると、かなり低レイヤーの知識が要求される言語だと感じました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?