Files
main/play.lua
colorgarden b2b32e593b Implement progress bar in video player
Added a progress bar to the video player display.
2026-02-22 14:53:30 +08:00

526 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_9")
gpu.refreshSize()
gpu.setSize(64)
local w, h = gpu.getSize()
server_url = "https://newgmapi.liulikeji.cn"
-- 手动加载 speakerlib.lua 并确保返回模块表
local speakerlib
local file = fs.open("speakerlib.lua", "r")
if not file then
error("speakerlib.lua not found")
end
local content = file.readAll()
file.close()
-- 创建一个新环境,以 _G 为原型,并显式注入 require
local env = setmetatable({ require = require }, { __index = _G })
local fn, err = load(content, "speakerlib.lua", nil, env)
if not fn then
error("Failed to compile speakerlib.lua: " .. tostring(err))
end
local ok, result = pcall(fn)
if not ok then
error("Error executing speakerlib.lua: " .. tostring(result))
end
if type(result) ~= "table" then
error("speakerlib.lua did not return a table (returned " .. type(result) .. ")")
end
speakerlib = result
print("[OK] speakerlib loaded successfully")
-- 检查命令行参数
local videoUrl = ...
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
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)
-- 轮询任务状态直到获取足够帧数200帧后开始播放
local total_logs_printed = 0
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
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.current_frames and task_info.result.current_frames >= 200 then
break
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 = {}
for i = 1, videoInfo.total_frames - 10 do
videoInfo.frame_urls[i] = "/frames/"..task_id.."/frame_" .. string.format("%06d", i) .. ".png"
end
----------------------------------------------------------------
-- 音频播放协程
----------------------------------------------------------------
local function audioPlayer()
print("[AUDIO] audioPlayer started")
if not videoInfo.audio_urls then
print("[AUDIO] No audio URLs provided, skipping playback.")
_G.audio_ready = true
return
end
print("[AUDIO] Audio URLs found")
local mono = videoInfo.audio_urls.audio_dfpwm_url and (server_url .. videoInfo.audio_urls.audio_dfpwm_url) or nil
local left = videoInfo.audio_urls.audio_dfpwm_left_url and (server_url .. videoInfo.audio_urls.audio_dfpwm_left_url) or nil
local right = videoInfo.audio_urls.audio_dfpwm_right_url and (server_url .. videoInfo.audio_urls.audio_dfpwm_right_url) or nil
print("[AUDIO] mono: " .. tostring(mono))
print("[AUDIO] left: " .. tostring(left))
print("[AUDIO] right: " .. tostring(right))
-- 强制开启 speakerlib 内部日志
_G.Playprint = true
-- 调用播放
speakerlib.play(mono, left, right)
print("[AUDIO] Playback finished normally")
end
-- 以下为视频播放相关函数
local function readU32(s, pos)
if pos + 3 > #s then return nil end
local b1, b2, b3, b4 = s:byte(pos, pos + 3)
return bit32.bor(
bit32.lshift(b1, 24),
bit32.lshift(b2, 16),
bit32.lshift(b3, 8),
b4
)
end
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
-- 配置参数
local BATCH_SIZE = 20
local PRELOAD_SECONDS = 20
local CACHE_WINDOW_SECONDS = 10
local totalFramesToPlay = #videoInfo.frame_urls
local preloadFrames = math.min(videoInfo.fps * PRELOAD_SECONDS, totalFramesToPlay)
local allFrameData = {}
-- 分批下载预加载帧(带重试限制)
print("Pre-caching first " .. preloadFrames .. " frames...")
local initBatches = {}
for startIdx = 1, preloadFrames, BATCH_SIZE do
local endIdx = math.min(startIdx + BATCH_SIZE - 1, preloadFrames)
local urls = {}
for i = startIdx, endIdx do
table.insert(urls, videoInfo.frame_urls[i])
end
table.insert(initBatches, { start = startIdx, urls = urls })
end
-- 并发下载所有预加载批次最多重试3次
local initTasks = {}
for _, batch in ipairs(initBatches) do
table.insert(initTasks, function()
local maxRetries = 3
for retry = 1, maxRetries 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))
return -- 成功则退出函数
else
print("Retry init batch starting at " .. batch.start .. " (attempt " .. retry .. "/" .. maxRetries .. ")")
if retry < maxRetries then
sleep(0.5)
end
end
end
-- 所有重试失败,打印警告,该批次帧缺失
print("[WARN] Failed to cache init batch starting at " .. batch.start .. " after " .. maxRetries .. " attempts, skipping")
end)
end
parallel.waitForAll(table.unpack(initTasks))
print("Initial caching completed.")
-- 播放循环参数
local frameDelay = 1 / videoInfo.fps
print("Starting playback (FPS: " .. videoInfo.fps .. ")")
local starttime1 = os.clock()
local running = true
local frameIndex = 1
-- 共享状态
local pendingRequests = {}
local downloadedFrames = {}
-- 缓存协程:动态预取未来 CACHE_WINDOW_SECONDS 秒的帧
local function cacheAhead()
while running do
local currentStart = frameIndex
local currentEnd = math.min(frameIndex + videoInfo.fps * CACHE_WINDOW_SECONDS - 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
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
-- 视频渲染协程(每帧更新文字,平滑帧率)
local function renderVideo()
-- 等待音频准备就绪
repeat
sleep(0.05)
until _G.audio_ready
os.queueEvent("audio_start")
local frameDelay = 1 / videoInfo.fps
local startTime = os.clock()
local totalFrames = #videoInfo.frame_urls
local lastFrameTime = os.clock()
local stallCount = 0
local lastFrameIndex = 0
local lastFrameStart = nil
local smooth_fps = videoInfo.fps -- 初始平滑帧率
while running and frameIndex <= totalFrames do
local frame_start = os.clock()
-- 检查是否长时间没有新帧(可能卡住)
if frame_start - lastFrameTime > 2.0 then
print("[RENDER] Warning: No new frame for " .. string.format("%.2f", frame_start - lastFrameTime) .. "s, frameIndex=" .. frameIndex)
end
if frameIndex == lastFrameIndex then
stallCount = stallCount + 1
if stallCount > 10 then
print("[RENDER] Frame index stuck at " .. frameIndex .. " for too long, forcing advance")
frameIndex = frameIndex + 1
stallCount = 0
goto continue
end
else
stallCount = 0
end
lastFrameIndex = frameIndex
lastFrameTime = frame_start
-- 获取当前帧数据
local data = allFrameData[frameIndex]
if not data then
-- 如果数据不存在,等待并重试,但不要无限等待
local retry = 0
while not data and retry < 20 and running do
sleep(0.1)
data = allFrameData[frameIndex]
retry = retry + 1
end
if not data then
print("[RENDER] Frame " .. frameIndex .. " data missing after retries, skipping")
frameIndex = frameIndex + 1
goto continue
end
end
-- 解码图像
local imgBin = { data:byte(1, #data) }
local success, image = pcall(gpu.decodeImage, table.unpack(imgBin))
if success and image then
gpu.drawImage(0, 0, image.ref())
-- 每帧更新文字
if lastFrameStart then
local interval = frame_start - lastFrameStart
if interval > 0 then
local instant_fps = 1 / interval
smooth_fps = smooth_fps * 0.8 + instant_fps * 0.2 -- 指数移动平均
end
end
gpu.drawText(1, 1,
frameIndex .. " / " .. totalFrames ..
" fps: " .. string.format("%.1f", smooth_fps),
0xffffff)
-- ====== 添加进度条 (已下移7格到第9行绿色带宽度判断) ======
if h >= 9 then
local progress = frameIndex / totalFrames
local percent = string.format("%.1f%%", progress * 100)
if w >= 256 then
-- 完整进度条(图形条 + 百分比)
local bar_length = 20
local filled = math.floor(progress * bar_length)
local bar = string.rep("=", filled) .. string.rep("-", bar_length - filled)
local progress_text = "Progress: [" .. bar .. "] " .. percent
gpu.drawText(1, 9, progress_text, 0x00FF00) -- 绿色
else
-- 仅显示百分比(无图形条)
local progress_text = "Progress: " .. percent
gpu.drawText(1, 9, progress_text, 0x00FF00) -- 绿色
end
end
-- ====== 进度条结束 ======
-- 更新上一帧开始时间
lastFrameStart = frame_start
-- 等待下一帧(使用定时器,但设置超时保护)
if frameIndex > 1 then
local event, id
local timerStarted = os.startTimer(frameDelay)
-- 等待定时器事件,但最多等待 2 倍帧间隔
local timeoutTimer = os.startTimer(frameDelay * 2)
local gotTimer = false
while not gotTimer and running do
event, id = os.pullEvent()
if event == "timer" and id == timerStarted then
gotTimer = true
elseif event == "timer" and id == timeoutTimer then
print("[RENDER] Timer timeout, continuing anyway")
gotTimer = true
end
end
end
gpu.sync()
image:free()
else
print("[RENDER] Failed to decode frame " .. frameIndex .. ": " .. tostring(image))
-- 跳过此帧
end
-- 释放已渲染帧内存
for i = 1, frameIndex - 3 do
allFrameData[i] = nil
end
-- 基于实际播放时间调整帧索引
local elapsed = os.clock() - startTime
local expectedFrame = math.floor(elapsed / frameDelay) + 1
if expectedFrame > frameIndex + 10 then
print("[RENDER] Skipping ahead from " .. frameIndex .. " to " .. expectedFrame)
frameIndex = expectedFrame
else
frameIndex = frameIndex + 1
end
::continue::
end
-- 视频结束,通知音频停止
_G.Playopen = false
running = false
print("[RENDER] Render loop ended")
end
-- 启动四个协程
parallel.waitForAll(renderVideo, cacheAhead, httpResponseHandler, audioPlayer)
-- 播放结束统计
local endtime = os.clock()
local time = endtime - starttime1
print("Playback finished")
print("Play_Time: " .. time)
local fps = totalFramesToPlay / time
print("Final_FPS: " .. fps)
print("Average Frame Interval: " .. 1/fps)