[mpv脚本系统] (四) 脚本加载与事件循环系统

想象一下:你在 mpv 里写了一个 Lua 脚本,只需要 mp.register_event("start-file", fn),播放新文件时 fn 就自动被调用。你不知道是谁在监听事件、不知道回调是怎么被调度的、甚至不需要写主循环——但它就是能工作。

这背后,是 mp 模块提供的一整套运行时环境在默默运转。本文用"操作系统"类比,从设计层面讲清楚这套环境做了什么、怎么实现的。



1. 把 mp 模块理解为一个"操作系统"

如果你写过嵌入式程序或者 Node.js,这个类比应该很亲切:

硬件(mpv 核心)

内核(C 层 mp 模块表)

操作系统服务(defaults.lua)

用户程序(Lua 脚本)

osc.lua
屏幕控制器

stats.lua
性能面板

ytdl_hook.lua
YouTube 加载器

~/.config/mpv/scripts/
用户自定义脚本

事件循环
dispatch_events()

定时器系统
add_timeout / add_periodic_timer

事件分发
register_event → call_event_handlers

属性观察
observe_property

底层系统调用
raw_observe_property / request_event
wait_event / add_timeout 的 C 实现

播放引擎
demux / decode / vo / ao

对应 mpv 实际组件职责
用户程序所有 Lua 脚本(OSC、stats、你的脚本…)写业务逻辑,调用 API
操作系统defaults.lua(~600 行 Lua 代码)事件循环、定时器、回调分发、属性观察
内核C 层的 mp 模块表(main_fns 数组中的函数)底层系统调用:读写属性、等待事件、请求通知
硬件mpv 核心(player/core.c 等)解码、渲染、播放控制

关键洞察:你写的 Lua 脚本不是在"裸机"上运行——mp 模块的 C 函数是系统调用,defaults.lua 用这些系统调用搭了一套"应用框架",你只需要注册回调就行了。


2. “内核”(C 层 mp 模块)提供了什么?

run_lua 初始化之后,每个脚本的 Lua 虚拟机里,mp 这个全局表有近 30 个 C 函数可用。按功能分为五类:

2.1 读取/写入播放器状态

-- 读当前音量
local vol = mp.get_property_number("volume")      -- → 75

-- 设置属性
mp.set_property("volume", 50)                     -- 音量设为 50%
mp.set_property_bool("pause", true)               -- 暂停

-- 读取原生(native)格式——返回 Lua table 而非字符串
local tracks = mp.get_property_native("track-list")
-- tracks = {
--   {type="video", selected=true, ...},
--   {type="audio", selected=true, lang="eng", ...},
-- }

C 侧实现:script_get_property_numberscript_set_property 等函数,通过 mpv_get_property / mpv_set_property 与核心通信。

2.2 接收播放器事件

-- 告诉 mpv:"我对 start-file 事件感兴趣"
mp.request_event("start-file", true)

-- 阻塞等待下一个事件(通常在事件循环中调用)
local event = mp.wait_event(1.0)   -- 最多等 1 秒
-- event = { event = "start-file", ... }

C 侧实现:script_request_eventmpv_request_eventscript_wait_eventmpv_wait_event。这是事件驱动模型的基础。

2.3 发送命令

mp.command("playlist-next")                        -- 跳到下一个文件
mp.commandv("seek", "30", "absolute")              -- 跳转到 30 秒

2.4 观察属性变化(底层)

-- raw_ 前缀表示这是底层 API,通常不直接使用
-- 高层封装在 defaults.lua 的 mp.observe_property 中
local id = 1
mp.raw_observe_property(id, "pause", "bool")       -- "当 pause 变化时通知我"

C 侧实现:script_raw_observe_propertympv_observe_property。当属性变化时,mpv 核心会发送 property-change 事件,携带 id 字段来标识是哪个观察。

2.5 日志输出

mp.log("info", "当前音量: " .. vol)
mp.msg.warn("文件即将结束")                        -- defaults.lua 封装的高层 API

2.6 五类系统调用一览

mp 模块表的 C 函数(~30个)
├── 属性读写 ─── get_property, set_property, get_property_native, ...
├── 事件机制 ─── wait_event, request_event, ...
├── 命令执行 ─── command, commandv, command_native, ...
├── 属性观察 ─── raw_observe_property, raw_unobserve_property
├── 日志输出 ─── log, enable_messages
├── 定时器 ─── (无——定时器纯 Lua 实现,见下文)
└── 工具函数 ─── get_time, format_time, find_config_file, ...

关键:C 层没有提供 add_timeoutregister_eventobserve_property——这些是 defaults.lua 用底层 API 拼装出来的。


3. “操作系统”(defaults.lua)在系统调用之上做了什么?

defaults.lua 读起来像一个小型操作系统内核。它在 C 层"系统调用"之上,构建了四个核心服务:

3.1 服务一:事件分发系统

问题:C 层 mp.wait_event() 返回一个原始事件,脚本需要自己判断事件类型、自己写 if/elseif 分发。每个脚本都写一遍这些分支?

defaults.lua 的解决方案:一个全局注册表 + 回调分发。

-- 全局注册表:事件名 → 回调函数列表
local event_handlers = {
    ["start-file"]    = { osc.request_init, stats.on_new_file, ... },
    ["end-file"]      = { ... },
    ["property-change"] = { defaults.property_change },
    ["shutdown"]      = { defaults.exit },
    ["client-message"] = { defaults.message_dispatch },
}

-- 注册 API:脚本只需要关心"我要什么事件 + 干什么"
function mp.register_event(name, cb)
    local list = event_handlers[name]
    if not list then
        list = {}
        event_handlers[name] = list
    end
    list[#list + 1] = cb
    return mp.request_event(name, true)   -- 告诉 C 层"我需要这个事件"
end

-- 事件循环中收到事件后的分发逻辑
local function call_event_handlers(e)
    local handlers = event_handlers[e.event]
    if handlers then
        for _, handler in ipairs(handlers) do
            handler(e)
        end
    end
end

使用效果

-- 你的脚本只需三行,不需要理解事件循环内部怎么运转:
mp.register_event("start-file", function(e)
    mp.msg.info("新文件开始播放!")
end)

3.2 服务二:属性观察系统

问题:C 层的 raw_observe_property 只做了"告诉核心我要观察"这一步。当属性变化时,核心发来的是 {event="property-change", id=1, data=...}——脚本还得自己维护 id → 回调 的映射。

defaults.lua 的解决方案:自动分配 id、自动映射、对脚本完全透明。

local property_id = 0
local properties = {}   -- { [id] = callback }

function mp.observe_property(name, t, cb)
    local id = property_id + 1
    property_id = id
    properties[id] = cb                       -- 记住 id → 回调
    mp.raw_observe_property(id, name, t)      -- 调用 C 层系统调用
end

-- 事件循环中收到 property-change 事件时的自动分发:
mp.register_event("property-change", function(ev)
    local prop = properties[ev.id]
    if prop then
        prop(ev.name, ev.data)    -- 自动找到正确的回调并调用
    end
end)

使用效果

-- 一行代码,属性变化时自动通知你
mp.observe_property("volume", "number", function(name, val)
    print("音量变为: " .. val)
end)

3.3 服务三:纯 Lua 实现的定时器系统

问题:C 层没有 setTimeout。怎么让脚本能"1 秒后执行某操作"?

defaults.lua 的解决方案:利用 mp.wait_event(timeout) 的阻塞超时机制,在事件循环中插入定时器检查。

local timers = {}   -- 所有活跃定时器

function mp.add_timeout(seconds, cb, disabled)
    -- 创建 oneshot 定时器对象,塞入 timers 表
    local t = mp.add_periodic_timer(seconds, cb, disabled)
    t.oneshot = true
    return t
end

function mp.add_periodic_timer(seconds, cb, disabled)
    local t = {
        timeout = seconds,
        cb = cb,
        oneshot = false,
        next_deadline = mp.get_time() + seconds,
    }
    timers[t] = t   -- 注册进全局表
    return t
end

-- 事件循环中每次迭代前执行:
local function process_timers()
    while true do
        local timer = get_next_timer()      -- 找最近到期的
        if not timer then return end

        local wait = timer.next_deadline - mp.get_time()
        if wait > 0 then return wait end    -- 还没到期,返回"要等多久"

        -- 到期了:执行回调
        if timer.oneshot then
            timer:kill()
        else
            timer.next_deadline = mp.get_time() + timer.timeout
        end
        timer.cb()
    end
end

定时器不是操作系统中断——是事件循环中"插空"执行的。每次进入 dispatch_events 的循环体时,先检查有没有到期的定时器,有就先执行。

使用效果

-- 3 秒后弹出提示
mp.add_timeout(3, function()
    mp.osd_message("欢迎使用 mpv!")
end)

-- 每 0.1 秒刷新一次 UI(osc.lua 的核心驱动方式)
local tick_timer = mp.add_periodic_timer(0.1, function()
    render_ui()
end)

3.4 服务四:主事件循环

-- 这就是所有脚本共享的主循环:
function mp.dispatch_events(allow_wait)
    while mp.keep_running do
        -- ❶ 检查并执行到期的定时器
        local wait = process_timers() or 1e20

        -- ❷ 阻塞等待 mpv 核心发事件(或定时器超时)
        local e = mp.wait_event(wait)

        -- ❸ 分发给所有注册了此事件的脚本回调
        if e.event ~= "none" then
            call_event_handlers(e)
        end
    end
end

_G.mp_event_loop = function()
    mp.dispatch_events(true)
end

一个循环周期 = 检查定时器 → 等待事件 → 分发回调。整个过程在 while mp.keep_running 中无限循环,直到收到 shutdown 事件(exit() 设置 mp.keep_running = false)。


4. 一个脚本的完整生命周期

现在我们把前面各篇的知识串起来,看一个脚本从"零"到"运行"的完整过程。

4.1 启动阶段

你的脚本 defaults.lua Lua 虚拟机 lua.c 你的脚本 defaults.lua Lua 虚拟机 lua.c run_lua() 启动 load_scripts() 开始加载 定义 event_handlers, properties, timers 定义 3 个注册 API(register_event 等) 注册 3 个默认事件处理器 定义 mp_event_loop = dispatch_events 业务代码开始注册: mp.register_event(...) mp.observe_property(...) mp.add_timeout(...) 进入事件循环 add_functions() 注册 mp.xxx C 函数 _G.mp = mp 模块表 package.preload["your_script"] = load_builtin require("mp.defaults") 执行 defaults.lua require("your_script") 执行你的脚本 mp_event_loop()

4.2 运行阶段——具体例子

假设你写了一个脚本,功能是"每次切换文件时打印文件名"。完整过程:

你的脚本(只有 3 行)

mp.register_event("start-file", function()
    local path = mp.get_property("path")
    mp.msg.info("正在播放: " .. path)
end)

运行时发生了什么

1. 注册阶段
   register_event("start-file", cb)
     → event_handlers["start-file"] = {..., cb}   (塞入全局表)
     → mp.request_event("start-file", true)         (告诉 C 层)

2. 事件循环中
   mp.wait_event(1e20)  阻塞...
     ↓ mpv 核心加载了新文件
   event = { event = "start-file", ... }
     ↓
   call_event_handlers(event)
     → 遍历 event_handlers["start-file"]
     → 调用你的 cb()
       → cb 内部调用 mp.get_property("path")
         → C 函数 script_get_property → mpv_get_property
         → 返回文件路径
       → mp.msg.info(...) 输出日志

3. 无限循环
   回到 while mp.keep_running,等待下一个事件

整个过程中你没有写的代码

  • 事件循环(while 循环)
  • 事件分发(if event == "start-file" then ...
  • 定时器检查
  • 退出处理

全部由 defaults.lua 提供。


5. 为什么叫"操作系统"而不是"框架"?

框架通常需要你遵循它的结构——Django 需要你写 urls.py,React 需要你写 render()。但 mpv 的 defaults.lua 更像是操作系统:

  1. 脚本可以不依赖它。你可以完全不用 register_event,自己写 while true do mp.wait_event() end。就像程序可以不调 libc,直接调 syscall

  2. 它是可替换的。如果你不喜欢 defaults.lua 的事件循环,你可以在自己的脚本里重新定义 _G.mp_event_loop——load_scripts 永远取最后定义的版本。

-- 你的脚本覆盖默认事件循环
_G.mp_event_loop = function()
    -- 你完全自己控制循环逻辑
    while true do
        local e = mp.wait_event(-1)  -- 永久阻塞
        if e.event == "shutdown" then break end
        my_dispatch(e)
    end
end
  1. 它提供的是"服务"而非"约束"。你用 register_event 也好、observe_property 也好、add_timeout 也好,都是按需调用,不需要继承任何基类、实现任何接口。

6. 串联四篇:从 C 函数到界面渲染

④ 运行时环境(本篇)

③ 模块注册

② autofree 包装

① 闭包原理

lua_pushcclosure(L, fn, n)
栈顶 n 个值 → 上值

af_pushcclosure 三层闭包
trampoline → autofree_call → 业务函数
talloc_free 保证资源安全

push_module_table → package.loaded
register_package_fns → 灌入函数

mp 模块表(C 层)
─── 系统调用层

defaults.lua
─── 操作系统层

你的脚本
─── 用户程序层

一条完整的调用链(以读取属性为例)

用户脚本: mp.get_property("volume")
  → C 层: script_get_property(② af_pushcclosure 包装过的闭包)
    → trampoline: talloc_new → pcall → talloc_free(② 自动资源管理)
    → autofree_call: 取出上值中的函数指针
    → 实际执行: mpv_get_property(client, "volume", ...)
  ← 返回值沿调用栈返回

7. 总结

概念mpv 中的实际组件提供的核心价值
内核(系统调用)C 层 mp 模块表(~30个函数)属性读写、事件等待、命令执行、属性观察
操作系统(服务层)defaults.lua(~600行)事件分发、属性观察封装、定时器系统、主事件循环
进程(用户程序)每个 Lua 脚本通过 register_event/observe_property/add_timeout 注册回调
启动器lua.cload_scriptsrequire("mp.defaults")require(脚本)mp_event_loop()

三个关键设计原则

  1. 分层清晰:C 层只提供原子操作,Lua 层负责编排——改动事件循环不需要重编译 C 代码。
  2. 注册而非继承:脚本通过"注册回调"融入系统,不继承任何基类——灵活且无侵入。
  3. 可替换默认实现_G.mp_event_loop 可以被任何脚本覆盖——满足特殊需求,不强迫所有人接受同一套设计。

系列目录
Lua 闭包与上值 — 从概念到 C API
mpv 的 af_pushcclosure — 三层闭包与自动资源管理
mpv 模块注册:C 函数如何变成 Lua 模块
④ 本文 — mpv 脚本的"操作系统":事件循环与运行时环境

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值