はじめに
動画を見返しながらメモを取る作業は、意外と手間がかかります。
特に、長時間の録画や作業配信では、
- 「この発言、後で見返したい」
- 「ここでミスした」
- 「このタイミングで重要な処理が入った」
といったポイントを、動画の時間と紐づけて記録する必要が出てきます。
しかし、OBS 標準の機能では「録画中にキーでタイムスタンプを書き残す」といった用途を、そのまま満たすことはできません。また、録画後に動画とメモを行き来しながら作業するのも、地味にストレスになります。
そこで今回、「録画中に特定のキーを押すだけでタイムスタンプを記録し、録画終了後には自動で動画とメモを紐づけて確認できる」
という一連の流れを、OBS のLuaスクリプトとして実装しました。
本記事では、作成したスクリプトの概要と設計意図に加えて、実装の中で特に詰まったポイント(文字列エンコーディング、多重化完了検知、ブラウザ操作)についてまとめます。
制作物
今回作成したものは、"録画中に特定のキーを押すと、自動でタイムスタンプが記録される機能"を持つLuaスクリプトです。
また、追加機能として、"録画終了時に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 やブラウザと深く連携しようとすると、かなり低レイヤーの知識が要求される言語だと感じました。
