Files
computer_craft_video_play/play.lua
nnwang 295bd18719 加入性能统计
加入帧不存在自动跳帧
修改解码方式
2026-02-22 04:39:20 +08:00

577 lines
18 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 简化版视频播放器
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 -- 仅下载前10秒以节省时间
local allFrameData = {}
-- 第一步构建所有需要下载的批次仅前10秒
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()
-- 播放前已缓存 10 秒
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 - 5) do
allFrameData[i] = nil
end
local data = allFrameData[frameIndex]
-- === 如果 data 存在才进行解码和渲染 ===
if data then
-- 将字符串转换为字节表
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