Files
computer_craft_video_play/play.lua
2026-02-22 07:11:56 +08:00

654 lines
21 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")
local gpu_Size_Auto = true --自动分辨率设置
local gpu_Size = 64 --默认屏幕分辨率
if not gpu then gpu = peripheral.find("tm_gpu") end
gpu.refreshSize()
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
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
local 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
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
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
-- 检查是否完成
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 = {}
for i = 1, videoInfo.total_frames - 10 do
videoInfo.frame_urls[i] = "/frames/"..task_id.."/frame_" .. string.format("%06d", i) .. ".png"
end
-- 下载和播放函数
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
-- 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_error = http.post({
url = server_url .. "/api/framepack?" .. batch.urls[1],
headers = { ["Content-Type"] = "application/json" },
body = textutils.serializeJSON({ urls = batch.urls }),
timeout = 2,
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("[V] Cached init batch: " .. batch.start .. " - " .. (batch.start + #batchFrames - 1))
break
else
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
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()
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
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))
-- 标记这些帧可重试
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) .. ") Error: "..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.." "..handleOrErr)
-- 永久失败,不再重试
for j = batch.start, batch.start + #batch.urls - 1 do
downloadedFrames[j] = true
end
end
end
elseif event == "timer" then
os.queueEvent("time",url, handleOrErr)
end
end
end
-- === 全程性能记录表 (新增) ===
-- 结构: { {id=帧号, time=耗时秒}, ... }
local perf_log = {}
-- 视频渲染协程
local function renderVideo()
-- 基础参数初始化
local fps = videoInfo.fps
local frameDelay = 1 / fps
local startTime = os.clock()
local nil_e = 0
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) %dx%d gpuSize:%d",
frameIndex,
#videoInfo.frame_urls,
math.floor(ui_avgFps),
math.floor(ui_avgMs),
math.floor(ui_lowFps),
math.floor(ui_lowMs),
w,h,Size
)
gpu.drawText(1, 1, statusText, 0xFD452A)
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()
nil_e = 0
else
print("Decode failed frame " .. frameIndex)
end
else
print("[E] "..frameIndex.." is nil")
nil_e = nil_e + 1
if nil_e >= 200 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
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