Compare commits
9 Commits
295bd18719
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13276d00ee | ||
|
|
06f9013090 | ||
|
|
f7acd8ce9a | ||
|
|
cc513092ca | ||
|
|
e8aba71e70 | ||
|
|
50a74935f1 | ||
|
|
13ec00766e | ||
|
|
5a6fa242dd | ||
|
|
64573e29ef |
83
README.md
83
README.md
@@ -1,3 +1,82 @@
|
||||
# computer_craft_video_play
|
||||
|
||||
一个用于cc:t 和 Tom's Peripherals 播放视频的程序
|
||||
这是一个专为 **ComputerCraft (CC: Tweaked)** 设计的高性能视频播放器客户端。它利用 **Tom's Peripherals** 外设进行硬件加速渲染,结合远程服务器转码,实现了在 Minecraft 中流畅播放网络视频的功能。
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
* **在线转码**:直接输入视频 URL(如 `.mp4` 链接),服务器自动提取帧和音频。
|
||||
* **硬件加速**:利用 `tm_gpu` 外设进行高效的图像解码和绘制。
|
||||
* **音频同步**:支持立体声音频播放(需要扬声器外设),并带有自动追帧/跳帧逻辑以保持音画同步。
|
||||
* **异步缓冲**:使用多线程(协程)并发下载技术,边播边下,极大减少卡顿。
|
||||
* **断点重试**:内置自动重试机制,应对网络波动。
|
||||
* **性能监控**:提供 Debug 模式,实时显示 FPS、渲染耗时和缓冲状态。
|
||||
* **快速重播**:播放结束后生成 Task ID,1小时内可直接凭 ID 再次播放,无需重新转码。
|
||||
|
||||
## 🛠️ 环境要求
|
||||
|
||||
在运行此程序之前,请确保你的游戏环境满足以下条件:
|
||||
|
||||
1. **Mod 要求**:
|
||||
* CC: Tweaked >= 1.105.0
|
||||
* Tom's Peripherals
|
||||
2. **硬件搭建**:
|
||||
* **高级电脑**(金电脑)。
|
||||
* **TM GPU**:连接到电脑(名为 `tm_gpu_0` 或自动搜索)。
|
||||
* **显示器**:连接到 GPU 的显示器。
|
||||
* **扬声器 (Speaker)**:用于播放音频(程序依赖 `speakerlib.lua`)。
|
||||
|
||||
## 📥 安装
|
||||
|
||||
```bash
|
||||
wget https://git.liulikeji.cn/xingluo/computer_craft_video_play/raw/branch/main/play.lua
|
||||
```
|
||||
|
||||
*注:程序首次运行时会自动检查并下载依赖库 `speakerlib.lua`*
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 1. 播放新视频
|
||||
直接在命令行后跟视频的网络链接地址:
|
||||
|
||||
```bash
|
||||
player https://example.com/video.mp4
|
||||
```
|
||||
|
||||
程序将发送请求到服务器,等待转码后自动开始播放。
|
||||
|
||||
### 2. 通过 ID 重播
|
||||
如果视频在过去一小时内播放过,可以使用任务 ID 直接播放,跳过转码等待:
|
||||
|
||||
```bash
|
||||
player 20116713
|
||||
```
|
||||
*(注:ID 会在视频播放结束后显示在屏幕上)*
|
||||
|
||||
### 3. Debug 调试模式
|
||||
在 URL 或 ID 后添加 `debug` 参数,可在屏幕左上角显示实时性能数据:
|
||||
|
||||
```bash
|
||||
player https://example.com/video.mp4 debug
|
||||
```
|
||||
|
||||
**Debug 信息说明:**
|
||||
* `100/5000`: 当前帧/总帧数
|
||||
* `Avg:20(50ms)`: 平均 FPS(平均每帧耗时)
|
||||
* `Low:15(66ms)`: 最低 FPS(当前秒内最大单帧耗时)
|
||||
|
||||
## ⚙️ 技术细节
|
||||
|
||||
* **API 服务端**:默认连接至 `https://newgmapi.liulikeji.cn`。 服务器项目`https://git.liulikeji.cn/xingluo/GMapiServer`
|
||||
* **分辨率**:程序会自动将 GPU 设置为 `64x` 分辨率模式,并请求服务器将视频缩放至 GPU 的实际宽高 (`w, h`)。
|
||||
* **帧率**:目标帧率为 **20 FPS**(Minecraft 逻辑刻速度)只能是此大小。
|
||||
* **缓存机制**:
|
||||
* **预加载**:播放前预先下载前 20 秒(约 400 帧)的数据。
|
||||
* **动态缓冲**:播放过程中,后台协程会持续预取后续大约 20 秒的帧数据包。
|
||||
* **数据格式**:使用定制的 `FramePack` 二进制格式批量传输帧数据,减少 HTTP 请求开销。
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
**Q: 画面很流畅,但声音卡顿/不同步?**
|
||||
A: 这是 CC 扬声器的常见限制。程序内置了追帧逻辑,如果画面落后音频超过 3 帧,会自动跳帧追赶。如果服务器 TPS 过低,可能会导致音频本身播放缓慢。
|
||||
|
||||
**Q: 如何获取任务 ID?**
|
||||
A: 视频播放完毕后,控制台会输出:`The ID for playing the video is XXXXX`。
|
||||
|
||||
226
play.lua
226
play.lua
@@ -1,9 +1,72 @@
|
||||
-- 简化版视频播放器
|
||||
local gpu = peripheral.wrap("tm_gpu_0")
|
||||
local gpu_Size_Auto = true --自动分辨率设置
|
||||
local gpu_Size = 64 --默认屏幕分辨率
|
||||
|
||||
if not gpu then gpu = peripheral.find("tm_gpu") end
|
||||
gpu.refreshSize()
|
||||
gpu.setSize(64)
|
||||
local w, h = gpu.getSize()
|
||||
gpu.setSize(gpu_Size)
|
||||
local w, h, w_black, h_black, Size = gpu.getSize()
|
||||
|
||||
|
||||
-- 分批下载(每批 10 帧)
|
||||
local BATCH_SIZE = 10
|
||||
|
||||
|
||||
local function computeOptimalSize(w_black, h_black)
|
||||
local w_target, h_target = 640, 360
|
||||
local max_size = 64
|
||||
|
||||
-- 避免除零
|
||||
if w_black <= 0 or h_black <= 0 then
|
||||
return 1
|
||||
end
|
||||
|
||||
-- 计算理想缩放比例(不能让任一边超过目标)
|
||||
local size_by_w = w_target / w_black
|
||||
local size_by_h = h_target / h_black
|
||||
local ideal_size = math.min(size_by_w, size_by_h)
|
||||
|
||||
-- 如果理想值 >= 64,直接用 64
|
||||
if ideal_size >= max_size then
|
||||
return max_size
|
||||
end
|
||||
|
||||
-- 否则,在 floor 和 ceil 中选更优的(但不能超过 64)
|
||||
local size_floor = math.floor(ideal_size)
|
||||
local size_ceil = math.ceil(ideal_size)
|
||||
|
||||
-- 确保不越界
|
||||
if size_floor < 1 then size_floor = 1 end
|
||||
if size_ceil > max_size then size_ceil = max_size end
|
||||
|
||||
-- 计算两个候选的误差(欧氏距离或曼哈顿距离均可,这里用平方和)
|
||||
local function error(size)
|
||||
local w = w_black * size
|
||||
local h = h_black * size
|
||||
return (w - w_target)^2 + (h - h_target)^2
|
||||
end
|
||||
|
||||
local err_floor = error(size_floor)
|
||||
local err_ceil = error(size_ceil)
|
||||
|
||||
if err_floor <= err_ceil then
|
||||
return size_floor
|
||||
else
|
||||
return size_ceil
|
||||
end
|
||||
end
|
||||
|
||||
if gpu_Size_Auto then
|
||||
local optimal_Size = computeOptimalSize(w_black, h_black)
|
||||
print("[I] gpu_Size: "..optimal_Size)
|
||||
gpu.refreshSize()
|
||||
gpu.setSize(optimal_Size)
|
||||
end
|
||||
local w, h, w_black, h_black, Size = gpu.getSize()
|
||||
|
||||
|
||||
|
||||
local mypath = "/"..fs.getDir(shell.getRunningProgram())
|
||||
if not fs.exists(mypath.."/speakerlib.lua") then shell.run("wget https://git.liulikeji.cn/xingluo/computer_craft_video_play/raw/branch/main/speakerlib.lua") end
|
||||
|
||||
@@ -74,7 +137,8 @@ print("task_id: " .. task_id)
|
||||
|
||||
-- 用于记录已打印的日志数量,避免重复打印
|
||||
local total_logs_printed = 0
|
||||
speakerrun = true
|
||||
local speakerrun = true
|
||||
|
||||
while true do
|
||||
|
||||
|
||||
@@ -105,10 +169,17 @@ while true do
|
||||
end
|
||||
total_logs_printed = total_logs_printed + #task_info.new_logs
|
||||
end
|
||||
|
||||
if task_info.result.audio_urls and speakerrun then
|
||||
local main_audio_url = server_url .. task_info.result.audio_urls.audio_dfpwm_url
|
||||
local left_audio_url = server_url .. task_info.result.audio_urls.audio_dfpwm_left_url
|
||||
local right_audio_url = server_url .. task_info.result.audio_urls.audio_dfpwm_right_url
|
||||
|
||||
speakerrun = false
|
||||
shell.run("bg speakerlib play ".. server_url .. task_info.result.audio_urls.audio_dfpwm_url .." ".. server_url .. task_info.result.audio_urls.audio_dfpwm_left_url .." ".. server_url .. task_info.result.audio_urls.audio_dfpwm_right_url)
|
||||
local new_tab_id = shell.openTab(mypath.."/speakerlib","play",main_audio_url,left_audio_url,right_audio_url)
|
||||
local tab_id = multishell.getCurrent()
|
||||
-- 切换一次进程,让其可以自然关闭
|
||||
multishell.setFocus(new_tab_id)
|
||||
multishell.setFocus(tab_id)
|
||||
end
|
||||
|
||||
-- 检查是否完成
|
||||
@@ -132,18 +203,11 @@ videoInfo.fps = 20
|
||||
|
||||
videoInfo.frame_urls = {}
|
||||
|
||||
-- /frames/20116713/frame_000001.png
|
||||
|
||||
for i = 1, videoInfo.total_frames - 10 do
|
||||
videoInfo.frame_urls[i] = "/frames/"..task_id.."/frame_" .. string.format("%06d", i) .. ".png"
|
||||
end
|
||||
|
||||
-- 播放音频 单通道
|
||||
--shell.run("bg speaker play", server_url .. videoInfo.audio_dfpwm_url)
|
||||
|
||||
-- server_url .. videoInfo.audio_urls.audio_dfpwm_url -- 音频单通道文件 URL
|
||||
-- server_url .. videoInfo.audio_urls.audio_dfpwm_right_url -- 音频右通道文件 URL
|
||||
-- server_url .. videoInfo.audio_urls.audio_dfpwm_left_url -- 音频左通道文件 URL
|
||||
|
||||
-- 下载和播放函数
|
||||
frames = {}
|
||||
local frameCount = 0
|
||||
@@ -185,13 +249,11 @@ local function unpackFramePack(data)
|
||||
return frames
|
||||
end
|
||||
|
||||
-- 分批下载(每批 50 帧)
|
||||
local BATCH_SIZE = 20
|
||||
-- local totalFrames = #videoInfo.frame_urls
|
||||
local totalFrames = videoInfo.fps * 20 -- 仅下载前10秒以节省时间
|
||||
local totalFrames = videoInfo.fps * 20 -- 仅下载前20秒以节省时间
|
||||
local allFrameData = {}
|
||||
|
||||
-- 第一步:构建所有需要下载的批次(仅前10秒)
|
||||
-- 第一步:构建所有需要下载的批次(仅前20秒)
|
||||
local initBatches = {}
|
||||
for startIdx = 1, totalFrames, BATCH_SIZE do
|
||||
local endIdx = math.min(startIdx + BATCH_SIZE - 1, totalFrames)
|
||||
@@ -212,11 +274,11 @@ local initTasks = {}
|
||||
for _, batch in ipairs(initBatches) do
|
||||
table.insert(initTasks, function()
|
||||
while true do
|
||||
local resp = http.post({
|
||||
local resp,http_error = http.post({
|
||||
url = server_url .. "/api/framepack?" .. batch.urls[1],
|
||||
headers = { ["Content-Type"] = "application/json" },
|
||||
body = textutils.serializeJSON({ urls = batch.urls }),
|
||||
timeout = 3,
|
||||
timeout = 2,
|
||||
binary = true
|
||||
})
|
||||
|
||||
@@ -230,10 +292,15 @@ for _, batch in ipairs(initBatches) do
|
||||
allFrameData[globalIdx] = batchFrames[idx]
|
||||
end
|
||||
|
||||
print("Cached init batch: " .. batch.start .. " - " .. (batch.start + #batchFrames - 1))
|
||||
print("[V] Cached init batch: " .. batch.start .. " - " .. (batch.start + #batchFrames - 1))
|
||||
break
|
||||
else
|
||||
print("Retry init batch starting at " .. batch.start)
|
||||
print("[E] " .. batch.start .." "..http_error)
|
||||
|
||||
if http_error == "Response is too large" then
|
||||
error("\n[E] Frame packet size exceeds 16MB. \nPlease try adjusting the screen size or resolution.")
|
||||
end
|
||||
|
||||
sleep(0.5)
|
||||
end
|
||||
end
|
||||
@@ -257,7 +324,7 @@ until _G.audio_ready
|
||||
local starttime1 = os.clock()
|
||||
|
||||
|
||||
-- 播放前已缓存 10 秒
|
||||
|
||||
local totalFramesToPlay = #videoInfo.frame_urls
|
||||
|
||||
-- 标志:是否还在播放
|
||||
@@ -333,23 +400,42 @@ local function httpResponseHandler()
|
||||
local batch = pendingRequests[url]
|
||||
if batch then
|
||||
pendingRequests[url] = nil
|
||||
local binData = handleOrErr.readAll()
|
||||
handleOrErr.close()
|
||||
|
||||
local success, batchFrames = pcall(unpackFramePack, binData)
|
||||
if success then
|
||||
for idx = 1, #batchFrames do
|
||||
local globalIdx = batch.start + idx - 1
|
||||
if not allFrameData[globalIdx] then
|
||||
allFrameData[globalIdx] = batchFrames[idx]
|
||||
end
|
||||
end
|
||||
print("[V] Cached batch: " .. batch.start .. " - " .. (batch.start + #batchFrames - 1))
|
||||
-- [优化] 检查整个批次是否已经完全过期待(最晚的一帧都小于 frameIndex - 10)
|
||||
-- 虽然 unpacking 很快,但在极端延迟下可以直接跳过解包
|
||||
local batchEndIdx = batch.start + #batch.urls - 1
|
||||
if batchEndIdx < frameIndex - 10 then
|
||||
-- 批次过旧,直接丢弃,仅释放句柄
|
||||
handleOrErr.close()
|
||||
print("[X] Drop old batch (too late): " .. batch.start)
|
||||
else
|
||||
print("[R] Unpack failed for batch " .. batch.start .. ": " .. tostring(batchFrames))
|
||||
-- 标记这些帧可重试
|
||||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||||
downloadedFrames[j] = nil
|
||||
local binData = handleOrErr.readAll()
|
||||
handleOrErr.close()
|
||||
|
||||
local success, batchFrames = pcall(unpackFramePack, binData)
|
||||
if success then
|
||||
for idx = 1, #batchFrames do
|
||||
local globalIdx = batch.start + idx - 1
|
||||
|
||||
-- [核心修改] 只有当帧号 >= 当前帧 - 10 时才存入内存
|
||||
-- 防止旧数据占用内存或被错误地重新缓存
|
||||
if globalIdx >= frameIndex - 10 then
|
||||
if not allFrameData[globalIdx] then
|
||||
allFrameData[globalIdx] = batchFrames[idx]
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 仅计算大致大小用于日志,逻辑不变
|
||||
local sizeInMB = #binData / (1024 * 1024)
|
||||
print("[V] Cached batch: " .. batch.start .. " - " .. (batch.start + #batchFrames - 1) .." ("..tonumber(string.format("%.1f", sizeInMB)).." Mb)")
|
||||
else
|
||||
print("[R] Unpack failed for batch " .. batch.start .. ": " .. tostring(batchFrames))
|
||||
-- 这里如果解包失败,如果数据还算新才许重试
|
||||
if batchEndIdx >= frameIndex - 10 then
|
||||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||||
downloadedFrames[j] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -358,23 +444,43 @@ local function httpResponseHandler()
|
||||
local batch = pendingRequests[url]
|
||||
if batch then
|
||||
pendingRequests[url] = nil
|
||||
batch.retry = (batch.retry or 0) + 1
|
||||
if batch.retry < 3 then
|
||||
print("[R] Retrying batch " .. batch.start .. " (attempt " .. (batch.retry + 1) .. ")")
|
||||
-- 允许重试
|
||||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||||
downloadedFrames[j] = nil
|
||||
end
|
||||
-- 重新触发 cacheAhead 下一轮会重发
|
||||
else
|
||||
print("[X] Giving up on batch " .. batch.start)
|
||||
-- 永久失败,不再重试
|
||||
|
||||
-- [核心修改] 检查批次时效性
|
||||
-- 如果该批次最晚的一帧都比 (frameIndex - 10) 还旧,直接丢弃,不再重试
|
||||
local batchEndIdx = batch.start + #batch.urls - 1
|
||||
|
||||
if batchEndIdx < frameIndex - 10 then
|
||||
-- 这里的 print 可以注释掉,或者保留用于调试
|
||||
print("[X] Drop old batch (too late): " .. batch.start)
|
||||
|
||||
-- 标记已下载(尽管是失败),防止 cacheAhead 再次死循环请求它
|
||||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||||
downloadedFrames[j] = true
|
||||
end
|
||||
else
|
||||
-- 只有数据还比较“新鲜”时,才进行重试逻辑
|
||||
batch.retry = (batch.retry or 0) + 1
|
||||
if batch.retry < 3 then
|
||||
print("[R] Retrying batch " .. batch.start .. " (attempt " .. (batch.retry + 1) .. ") Error: "..tostring(handleOrErr))
|
||||
-- 允许重试
|
||||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||||
downloadedFrames[j] = nil
|
||||
end
|
||||
-- 重新触发 cacheAhead 下一轮会重发
|
||||
else
|
||||
print("[X] Giving up on batch " .. batch.start)
|
||||
print("[E] " .. batch.start.." "..tostring(handleOrErr))
|
||||
|
||||
-- 永久失败,不再重试
|
||||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||||
downloadedFrames[j] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
elseif event == "timer" then
|
||||
os.queueEvent("time",url, handleOrErr)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -390,6 +496,7 @@ local function renderVideo()
|
||||
local fps = videoInfo.fps
|
||||
local frameDelay = 1 / fps
|
||||
local startTime = os.clock()
|
||||
local nil_e = 0
|
||||
|
||||
os.queueEvent("audio_start") -- 通知音频开始播放
|
||||
|
||||
@@ -409,15 +516,16 @@ local function renderVideo()
|
||||
local frameStart = frameEnd
|
||||
|
||||
-- 1. 内存清理
|
||||
for i = 1, math.max(0, frameIndex - 5) do
|
||||
for i = 1, math.max(0, frameIndex - 10) do
|
||||
allFrameData[i] = nil
|
||||
end
|
||||
|
||||
local data = allFrameData[frameIndex]
|
||||
|
||||
-- === 如果 data 存在才进行解码和渲染 ===
|
||||
-- === 如果 data 存在才进行解码和渲染 否则跳帧 ===
|
||||
if data then
|
||||
-- 将字符串转换为字节表
|
||||
-- 感谢来自 https://center.mcmod.cn/1288558/ 提供高性能解码方案
|
||||
local imgBin = { data:byte(1, #data) }
|
||||
data = nil
|
||||
|
||||
@@ -432,15 +540,16 @@ local function renderVideo()
|
||||
|
||||
if debug then
|
||||
-- 格式:帧进度 | 平均FPS(平均耗时) | 最低FPS(波峰耗时)
|
||||
local statusText = string.format("%d/%d Avg:%d(%dms) Low:%d(%dms)",
|
||||
local statusText = string.format("%d/%d Avg:%d(%dms) Low:%d(%dms) %dx%d gpuSize:%d",
|
||||
frameIndex,
|
||||
#videoInfo.frame_urls,
|
||||
math.floor(ui_avgFps),
|
||||
math.floor(ui_avgMs),
|
||||
math.floor(ui_lowFps),
|
||||
math.floor(ui_lowMs)
|
||||
math.floor(ui_lowMs),
|
||||
w,h,Size
|
||||
)
|
||||
gpu.drawText(1, 1, statusText, 0xFFFFFF)
|
||||
gpu.drawText(1, 1, statusText, 0xFD452A)
|
||||
end
|
||||
|
||||
-- 这里的 Timer 逻辑移出去了,确保跳帧时也能同步
|
||||
@@ -457,16 +566,17 @@ local function renderVideo()
|
||||
image:free()
|
||||
-- === 修改点:只有成功渲染并同步后,才更新 frameEnd ===
|
||||
frameEnd = os.clock()
|
||||
nil_e = 0
|
||||
else
|
||||
print("Decode failed frame " .. frameIndex)
|
||||
end
|
||||
else
|
||||
if frameIndex > 1 and timer_id then
|
||||
repeat
|
||||
local event, id = os.pullEvent("timer")
|
||||
until id == timer_id
|
||||
print("[E] "..frameIndex.." is nil")
|
||||
nil_e = nil_e + 1
|
||||
if nil_e >= 5 then
|
||||
error("\n[E] No available video data for 10 seconds \nDownload speed is below the minimum threshold! \nPlease try checking your network bandwidth or adjusting the resolution.")
|
||||
end
|
||||
|
||||
sleep(2)
|
||||
timer_id = os.startTimer(frameDelay)
|
||||
-- === 修改点:数据为空时,不做任何操作,直接进入下方的同步逻辑 ===
|
||||
-- 此时 frameEnd 依然等于 frameStart,计算出的 costTime 为 0
|
||||
|
||||
Reference in New Issue
Block a user