578 lines
18 KiB
Lua
578 lines
18 KiB
Lua
-- 简化版视频播放器
|
||
local gpu = peripheral.wrap("tm_gpu_0")
|
||
if not gpu then gpu = peripheral.find("tm_gpu") end
|
||
gpu.refreshSize()
|
||
gpu.setSize(64)
|
||
local w, h = 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
|
||
|
||
local server_url = "https://newgmapi.liulikeji.cn"
|
||
|
||
-- 检查命令行参数
|
||
local age = {...}
|
||
local videoUrl = age[1]
|
||
local debug
|
||
|
||
if age[2] == "debug" then
|
||
debug = true
|
||
end
|
||
|
||
if not videoUrl then
|
||
print("Usage: video_player <video URL>")
|
||
print("Example: video_player https://example.com/video.mp4")
|
||
return
|
||
end
|
||
|
||
_G.audio_ready = false
|
||
local task_id
|
||
local status_url
|
||
|
||
if videoUrl:sub(1,4) ~= "http" then
|
||
-- 如果不是 URL,则直接当作 task_id 使用
|
||
task_id = videoUrl
|
||
status_url = server_url .. "/api/task/" .. task_id
|
||
else
|
||
-- 创建新任务
|
||
print("Submitting video frame extraction task...")
|
||
local requestData = {
|
||
video_url = videoUrl,
|
||
w = w,
|
||
h = h,
|
||
force_resolution = false,
|
||
pad_to_target = true,
|
||
fps = 20
|
||
}
|
||
|
||
local response = http.post(
|
||
server_url .. "/api/video_frame/async",
|
||
textutils.serializeJSON(requestData),
|
||
{["Content-Type"] = "application/json"}
|
||
)
|
||
|
||
if not response then
|
||
error("Failed to connect to API server")
|
||
end
|
||
|
||
local respBody = response.readAll()
|
||
response.close()
|
||
|
||
local createResult = textutils.unserialiseJSON(respBody)
|
||
if not createResult or createResult.status ~= "success" then
|
||
error("Task creation failed: " .. (createResult and createResult.message or "Unknown error"))
|
||
end
|
||
|
||
task_id = createResult.task_id
|
||
status_url = createResult.status_url
|
||
|
||
|
||
end
|
||
|
||
term.clear()
|
||
term.setCursorPos(1,1)
|
||
print("task_id: " .. task_id)
|
||
|
||
-- 用于记录已打印的日志数量,避免重复打印
|
||
local total_logs_printed = 0
|
||
speakerrun = true
|
||
while true do
|
||
|
||
|
||
local response = http.get(status_url, {["Content-Type"] = "application/json"})
|
||
if not response then
|
||
error("Failed to fetch task status")
|
||
end
|
||
|
||
local respBody = response.readAll()
|
||
response.close()
|
||
|
||
local task_info = textutils.unserialiseJSON(respBody)
|
||
|
||
term.clear()
|
||
term.setCursorPos(1,1)
|
||
-- 打印基本信息
|
||
print("task_id: " .. task_id)
|
||
print("Status: " .. (task_info.status or "unknown"))
|
||
print("Progress: " .. (task_info.progress or 0) .. "%")
|
||
if task_info.message then
|
||
print("Message: " .. task_info.message)
|
||
end
|
||
|
||
-- 打印新增日志(new_logs 是数组)
|
||
if task_info.new_logs and #task_info.new_logs > 0 then
|
||
for i = 1, #task_info.new_logs do
|
||
print(task_info.new_logs[i])
|
||
end
|
||
total_logs_printed = total_logs_printed + #task_info.new_logs
|
||
end
|
||
|
||
if task_info.result.audio_urls and speakerrun then
|
||
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)
|
||
end
|
||
|
||
-- 检查是否完成
|
||
if task_info.result.current_frames then
|
||
if task_info.result.current_frames >= 400 then
|
||
break
|
||
end
|
||
end
|
||
|
||
sleep(1)
|
||
end
|
||
|
||
|
||
-- 获取最终结果
|
||
local response = http.get(status_url, {["Content-Type"] = "application/json"})
|
||
local finalResp = textutils.unserialiseJSON(response.readAll())
|
||
response.close()
|
||
|
||
videoInfo = finalResp.result
|
||
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
|
||
frameData = {}
|
||
|
||
local get_task = {}
|
||
|
||
local function readU32(s, pos)
|
||
if pos + 3 > #s then return nil end
|
||
local b1, b2, b3, b4 = s:byte(pos, pos + 3)
|
||
-- 使用 bit32.lshift 和 bit32.bor
|
||
return bit32.bor(
|
||
bit32.lshift(b1, 24),
|
||
bit32.lshift(b2, 16),
|
||
bit32.lshift(b3, 8),
|
||
b4
|
||
)
|
||
end
|
||
|
||
-- 解析 FramePack 二进制数据
|
||
local function unpackFramePack(data)
|
||
local frames = {}
|
||
local pos = 1
|
||
|
||
local frameCount = readU32(data, pos)
|
||
if not frameCount then error("Invalid framepack header") end
|
||
pos = pos + 4
|
||
|
||
for i = 1, frameCount do
|
||
local size = readU32(data, pos)
|
||
if not size then error("Truncated framepack at frame " .. i) end
|
||
pos = pos + 4
|
||
if pos + size - 1 > #data then error("Frame " .. i .. " out of bounds") end
|
||
local frameData = data:sub(pos, pos + size - 1)
|
||
frames[i] = frameData
|
||
pos = pos + size
|
||
end
|
||
|
||
return frames
|
||
end
|
||
|
||
-- 分批下载(每批 50 帧)
|
||
local BATCH_SIZE = 20
|
||
-- local totalFrames = #videoInfo.frame_urls
|
||
local totalFrames = videoInfo.fps * 20 -- 仅下载前20秒以节省时间
|
||
local allFrameData = {}
|
||
|
||
-- 第一步:构建所有需要下载的批次(仅前20秒)
|
||
local initBatches = {}
|
||
for startIdx = 1, totalFrames, BATCH_SIZE do
|
||
local endIdx = math.min(startIdx + BATCH_SIZE - 1, totalFrames)
|
||
local urls = {}
|
||
for i = startIdx, endIdx do
|
||
table.insert(urls, videoInfo.frame_urls[i])
|
||
end
|
||
table.insert(initBatches, {
|
||
start = startIdx,
|
||
urls = urls
|
||
})
|
||
end
|
||
|
||
print("Pre-caching first " .. totalFrames .. " frames (" .. #initBatches .. " batches)...")
|
||
|
||
-- 第二步:并发下载所有初始化批次
|
||
local initTasks = {}
|
||
for _, batch in ipairs(initBatches) do
|
||
table.insert(initTasks, function()
|
||
while true do
|
||
local resp = http.post({
|
||
url = server_url .. "/api/framepack?" .. batch.urls[1],
|
||
headers = { ["Content-Type"] = "application/json" },
|
||
body = textutils.serializeJSON({ urls = batch.urls }),
|
||
timeout = 3,
|
||
binary = true
|
||
})
|
||
|
||
if resp then
|
||
local binData = resp.readAll()
|
||
resp.close()
|
||
|
||
local batchFrames = unpackFramePack(binData)
|
||
for idx = 1, #batchFrames do
|
||
local globalIdx = batch.start + idx - 1
|
||
allFrameData[globalIdx] = batchFrames[idx]
|
||
end
|
||
|
||
print("Cached init batch: " .. batch.start .. " - " .. (batch.start + #batchFrames - 1))
|
||
break
|
||
else
|
||
print("Retry init batch starting at " .. batch.start)
|
||
sleep(0.5)
|
||
end
|
||
end
|
||
end)
|
||
end
|
||
|
||
-- 执行并发下载
|
||
parallel.waitForAll(table.unpack(initTasks))
|
||
|
||
print("Initial caching completed.")
|
||
|
||
-- 播放循环
|
||
local frameDelay = tonumber(string.format("%.3f",1 / videoInfo.fps))
|
||
print("Starting playback (FPS: " .. videoInfo.fps .. ")")
|
||
print(frameDelay * #videoInfo.frame_urls)
|
||
|
||
repeat
|
||
sleep(0.05)
|
||
until _G.audio_ready
|
||
|
||
local starttime1 = os.clock()
|
||
|
||
|
||
-- 播放前已缓存 20 秒
|
||
local totalFramesToPlay = #videoInfo.frame_urls
|
||
|
||
-- 标志:是否还在播放
|
||
local running = true
|
||
|
||
-- 全局帧索引(由播放线程更新)
|
||
local frameIndex = 1
|
||
|
||
|
||
-- 共享状态(用于 cacheAhead 和 httpResponseHandler 通信)
|
||
local pendingRequests = {} -- url -> { batch = {...}, retry = N }
|
||
local downloadedFrames = {} -- frameIndex -> true (防止重复调度)
|
||
|
||
-- 缓存协程:边播边下(使用异步 http.request)
|
||
local function cacheAhead()
|
||
while running do
|
||
local currentStart = frameIndex
|
||
local currentEnd = math.min(frameIndex + videoInfo.fps * 20 - 1, totalFramesToPlay)
|
||
|
||
local batches = {}
|
||
local i = currentStart
|
||
while i <= currentEnd do
|
||
if not allFrameData[i] and not downloadedFrames[i] then
|
||
local batchStart = i
|
||
local batchEnd = math.min(batchStart + BATCH_SIZE - 1, currentEnd)
|
||
local urls = {}
|
||
for j = batchStart, batchEnd do
|
||
if not allFrameData[j] and not downloadedFrames[j] then
|
||
table.insert(urls, videoInfo.frame_urls[j])
|
||
downloadedFrames[j] = true
|
||
end
|
||
end
|
||
if #urls > 0 then
|
||
table.insert(batches, {
|
||
start = batchStart,
|
||
urls = urls,
|
||
retry = 0
|
||
})
|
||
end
|
||
i = batchEnd + 1
|
||
else
|
||
i = i + 1
|
||
end
|
||
end
|
||
|
||
-- 发起所有新请求(不等待!)
|
||
for _, batch in ipairs(batches) do
|
||
local url = server_url .. "/api/framepack?" .. batch.urls[1]
|
||
local body = textutils.serializeJSON({ urls = batch.urls })
|
||
http.request({
|
||
url = url,
|
||
headers = { ["Content-Type"] = "application/json" },
|
||
body = body,
|
||
timeout = 2,
|
||
binary = true
|
||
})
|
||
pendingRequests[url] = batch
|
||
end
|
||
|
||
-- 如果没有要下载的,小睡
|
||
if next(batches) == nil then
|
||
sleep(0.5)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- HTTP 处理协程
|
||
local function httpResponseHandler()
|
||
while running do
|
||
local event, url, handleOrErr = os.pullEvent()
|
||
|
||
if event == "http_success" then
|
||
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))
|
||
else
|
||
print("[R] Unpack failed for batch " .. batch.start .. ": " .. tostring(batchFrames))
|
||
-- 标记这些帧可重试
|
||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||
downloadedFrames[j] = nil
|
||
end
|
||
end
|
||
end
|
||
|
||
elseif event == "http_failure" then
|
||
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)
|
||
-- 永久失败,不再重试
|
||
for j = batch.start, batch.start + #batch.urls - 1 do
|
||
downloadedFrames[j] = true
|
||
end
|
||
end
|
||
end
|
||
|
||
end
|
||
end
|
||
end
|
||
|
||
|
||
-- === 全程性能记录表 (新增) ===
|
||
-- 结构: { {id=帧号, time=耗时秒}, ... }
|
||
local perf_log = {}
|
||
|
||
-- 视频渲染协程
|
||
local function renderVideo()
|
||
-- 基础参数初始化
|
||
local fps = videoInfo.fps
|
||
local frameDelay = 1 / fps
|
||
local startTime = os.clock()
|
||
|
||
os.queueEvent("audio_start") -- 通知音频开始播放
|
||
|
||
-- === 性能统计变量 ===
|
||
local lastStatTime = os.clock() -- 上次结算时间
|
||
local frameCount = 0 -- 当前秒内已渲染帧数
|
||
local timeAccum = 0 -- 当前秒内累计渲染耗时
|
||
local maxFrameTime = 0 -- 当前秒内最大单帧耗时 (用于计算最低FPS)
|
||
|
||
-- === UI显示缓存 ===
|
||
local ui_avgFps, ui_avgMs = 0, 0
|
||
local ui_lowFps, ui_lowMs = 0, 0
|
||
|
||
local timer_id -- 声明计时器ID变量
|
||
|
||
while running and frameIndex <= #videoInfo.frame_urls do
|
||
local frameStart = frameEnd
|
||
|
||
-- 1. 内存清理
|
||
for i = 1, math.max(0, frameIndex - 10) do
|
||
allFrameData[i] = nil
|
||
end
|
||
|
||
local data = allFrameData[frameIndex]
|
||
|
||
-- === 如果 data 存在才进行解码和渲染 否则跳帧 ===
|
||
if data then
|
||
-- 将字符串转换为字节表
|
||
-- 感谢来自 https://center.mcmod.cn/1288558/ 提供高性能解码方案
|
||
local imgBin = { data:byte(1, #data) }
|
||
data = nil
|
||
|
||
-- 2. 图像解码
|
||
local success, image = pcall(function()
|
||
return gpu.decodeImage(table.unpack(imgBin))
|
||
end)
|
||
|
||
if success and image then
|
||
-- 3. 画面渲染
|
||
gpu.drawImage(0, 0, image.ref())
|
||
|
||
if debug then
|
||
-- 格式:帧进度 | 平均FPS(平均耗时) | 最低FPS(波峰耗时)
|
||
local statusText = string.format("%d/%d Avg:%d(%dms) Low:%d(%dms)",
|
||
frameIndex,
|
||
#videoInfo.frame_urls,
|
||
math.floor(ui_avgFps),
|
||
math.floor(ui_avgMs),
|
||
math.floor(ui_lowFps),
|
||
math.floor(ui_lowMs)
|
||
)
|
||
gpu.drawText(1, 1, statusText, 0xFFFFFF)
|
||
end
|
||
|
||
-- 这里的 Timer 逻辑移出去了,确保跳帧时也能同步
|
||
|
||
if frameIndex > 1 and timer_id then
|
||
repeat
|
||
local event, id = os.pullEvent("timer")
|
||
until id == timer_id
|
||
end
|
||
|
||
timer_id = os.startTimer(frameDelay)
|
||
|
||
gpu.sync()
|
||
image:free()
|
||
-- === 修改点:只有成功渲染并同步后,才更新 frameEnd ===
|
||
frameEnd = os.clock()
|
||
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
|
||
end
|
||
|
||
timer_id = os.startTimer(frameDelay)
|
||
-- === 修改点:数据为空时,不做任何操作,直接进入下方的同步逻辑 ===
|
||
-- 此时 frameEnd 依然等于 frameStart,计算出的 costTime 为 0
|
||
end
|
||
|
||
if frameEnd and frameStart then
|
||
-- 5. 性能数据计算
|
||
local costTime = frameEnd - frameStart -- 如果跳帧,这里是 0 秒
|
||
|
||
-- 记录当前帧的统计信息到总表中
|
||
table.insert(perf_log, { f = frameIndex, t = costTime })
|
||
|
||
if debug then
|
||
-- 实时统计累加
|
||
frameCount = frameCount + 1
|
||
timeAccum = timeAccum + costTime
|
||
if costTime > maxFrameTime then maxFrameTime = costTime end
|
||
|
||
-- 每隔 1秒 更新一次UI显示的数值
|
||
-- 注意:如果大量跳帧,timeDiff 可能会包含很多等待时间,这里的逻辑保持原样即可
|
||
local statEndCheck = os.clock()
|
||
local timeDiff = statEndCheck - lastStatTime
|
||
if timeDiff >= 1.0 then
|
||
ui_avgFps = frameCount / timeDiff
|
||
ui_avgMs = (timeAccum / frameCount) * 1000
|
||
|
||
ui_lowFps = (maxFrameTime > 0) and (1 / maxFrameTime) or ui_avgFps
|
||
ui_lowMs = maxFrameTime * 1000
|
||
|
||
-- 重置计数器
|
||
lastStatTime = statEndCheck
|
||
frameCount = 0
|
||
timeAccum = 0
|
||
maxFrameTime = 0
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 6. 自动追帧逻辑
|
||
local playTime = os.clock() - startTime
|
||
local targetFrame = math.ceil(playTime / frameDelay)
|
||
|
||
-- 如果当前帧和理论帧 相差3 则跳帧
|
||
if targetFrame > frameIndex + 3 or targetFrame < frameIndex - 2 then
|
||
frameIndex = targetFrame
|
||
else
|
||
frameIndex = frameIndex+1
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 启动协程
|
||
-- 启动三个协程:渲染 + 缓存调度 + HTTP 响应处理
|
||
parallel.waitForAny(renderVideo, cacheAhead, httpResponseHandler)
|
||
|
||
|
||
local endtime = os.clock()
|
||
local total_time_sec = endtime - starttime1
|
||
|
||
-- 格式化总播放时间为 HH:MM:SS
|
||
local function format_seconds_to_hms(seconds)
|
||
local h = math.floor(seconds / 3600)
|
||
local m = math.floor((seconds % 3600) / 60)
|
||
local s = math.floor(seconds % 60)
|
||
return string.format("%02d:%02d:%02d", h, m, s)
|
||
end
|
||
|
||
print("Playback finished")
|
||
print("Play_Time: " .. format_seconds_to_hms(total_time_sec))
|
||
|
||
if #perf_log == 0 then
|
||
print("No performance data recorded.")
|
||
else
|
||
local total_frame_time = 0
|
||
local max_frame_time = 0
|
||
|
||
for _, entry in ipairs(perf_log) do
|
||
local t = entry.t
|
||
|
||
total_frame_time = total_frame_time + t
|
||
if t > max_frame_time then
|
||
max_frame_time = t
|
||
end
|
||
end
|
||
|
||
local avg_frame_time = total_frame_time / #perf_log -- 单位:秒
|
||
local avg_fps = 1 / avg_frame_time
|
||
local min_fps = max_frame_time > 0 and (1 / max_frame_time) or 0
|
||
|
||
-- 转换为整数 FPS 和毫秒
|
||
local avg_fps_int = math.floor(avg_fps)
|
||
local min_fps_int = math.floor(min_fps)
|
||
local avg_ms = math.floor(avg_frame_time * 1000)
|
||
local max_ms = math.floor(max_frame_time * 1000)
|
||
|
||
print(string.format("Avg_FPS: %d (%dms)", avg_fps_int, avg_ms))
|
||
print(string.format("Low_FPS: %d (%dms)", min_fps_int, max_ms))
|
||
|
||
print("The ID for playing the video is "..task_id)
|
||
print("You can use the following command to replay this video within an hour.")
|
||
print('" play '..task_id..' "')
|
||
|
||
if debug then
|
||
local perf_log_file = fs.open(mypath.."/perf_log.json","w")
|
||
perf_log_file.write(textutils.serialiseJSON(perf_log))
|
||
print("perf_log has been saved to "..mypath.."/perf_log.json")
|
||
end
|
||
end |