合并任务的可视化进度、预计剩余时间与暂停/停止
用户反馈:合并的进度只体现在日志里(ffmpeg 的 frame=/speed= 行),看不出 百分比、当前环节和还要多久,也没有办法中途暂停或放弃。 1. 新增全局任务状态(progress.go) 一份状态同时服务自动合并和手动合并,记录阶段、总进度、速度、帧率、 已处理时长、输入/预计输出/已写出体积、ETA 和速度折线采样点。 总进度按阶段加权:准备 0.02 / 分段拼接 0.08 / 获取头像 0.10 / 画面渲染 0.75 / 拼接与落盘 0.05,渲染阶段按「已渲染 ÷ 总时长」折算。 ETA 在渲染阶段用「剩余时长 ÷ 倍速」,其它阶段按已用时间线性外推。 2. 用 ffmpeg 的 -progress pipe:1 取结构化进度 runFFmpeg 拆出带回调的版本:解析 out_time_us / speed / fps / total_size, 分块渲染时把块内时间折算到整片时间轴,并把已完成块的字节数累计进来。 ffmpeg 自己的统计行仍在 stderr,单独用命令行时不会少东西。 预计成片体积先按「(视频码率 + 音频 192k)× 时长 ÷ 8」估,渲染过 5% 之后换成「已写出字节 ÷ 已完成比例」外推(estimateAudioKbps)。 3. 暂停 / 停止 Windows 用 ntdll 的 NtSuspendProcess / NtResumeProcess(照任务管理器 的「挂起」语义),Linux 用 SIGSTOP / SIGCONT,放在带 build tag 的 jobctl_windows.go / jobctl_unix.go 里。 非 ffmpeg 环节(下载头像、逐块渲染之间)用 jobGate 闸门生效。 停止 = 杀掉当前 ffmpeg 并置位,各层返回 errJobStopped:不再回退其它 编码器、不落成片、清掉分块临时文件,任务以「已停止」结束(而不是失败)。 4. 接口 GET /api/job 给页面轮询任务快照;POST /api/job/control 接受 pause / resume / stop,没有任务时 409、未知动作 400。 5. 界面 监控分区顶部新增「转换任务」卡片:阶段步骤条、总进度条、实时指标、 预计剩余时间、源片与预计成片的体积对比条、渲染速度折线(每秒一个点、 最多两分钟),以及暂停/继续/停止按钮;页面最顶上再加一条全局细进度条, 切到哪个分区都看得到。有任务时每秒轮询,空闲时 4 秒。任务结束后卡片 再停留 20 秒显示结果。 已验证: - 新增 progress_test.go 12 个用例:阶段权重和为 1、进度按阶段折算、阶段 不回退、ETA、体积预估与实测外推的切换、折线采样、暂停/停止的闸门语义、 控制接口在无任务时报错、speed 字段与 -progress 文本的解析。 测试里把 errJobStopped 和真失败区分开(jobErrText)。 - 端到端(独立实例 51897 + 独立 config,全程没碰仓库里的实例和 acfunlive.exe):跑通完整合并,进度到 100%、预计体积与成片实际大小 相差 0.4%;暂停后进度冻结且 ffmpeg 的 CPU 时间 5 秒内 0 增长; 继续后进度恢复;停止后任务以「已停止」结束、没有半成品成片、 没有残留临时文件、没有残留 ffmpeg 进程。 - 界面(playwright 驱动系统 Edge):步骤条 5 个阶段且只有一个处于进行中、 进度条与顶部细条同步、5 项指标(速度/帧率/已处理时长/已用时间/已写出)、 ETA 文案、体积对比条两段宽度按比例、折线有采样点、界面按钮能暂停/继续/ 停止、结束后按钮隐藏且文案是「已停止」、深色主题与 390 宽移动端无横向溢出。 - 过程中修掉三个真缺陷:停止时残留分块临时文件(part 登记晚于渲染)、 界面「继续」按钮点不动(onclick 写死了 pause)、停止被当成失败报红 (现在按 message 区分 已完成/已停止/失败)。 - gofmt / go vet / go build / GOOS=linux go build / go test 全过。
This commit is contained in:
+165
-15
@@ -64,6 +64,10 @@ const maxDanmuPerChunk = 80
|
||||
// 分块渲染产生的临时文件前缀
|
||||
const chunkTempPrefix = "acfunlive_chunk_"
|
||||
|
||||
// 预估成片体积时给音频留的码率(Kbps):分块时音频固定重编码成 192k,
|
||||
// 单块时是直接拷流,这里统一按 192 估,反正渲染过 5% 后会被实测外推值顶掉
|
||||
const estimateAudioKbps = 192
|
||||
|
||||
// 一个待渲染的弹幕块
|
||||
type danmuChunk struct {
|
||||
start float64 // 在视频时间轴上的起点(秒)
|
||||
@@ -300,7 +304,13 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
}
|
||||
looked := 0
|
||||
jobSetPhase(jobPhaseAvatar)
|
||||
jobAvatars(0, len(uids))
|
||||
for i, uid := range uids {
|
||||
// 这一阶段既要下网络又要解图片,允许在这里暂停 / 停止
|
||||
if err := jobGate(); err != nil {
|
||||
return err
|
||||
}
|
||||
url := urlByUID[uid]
|
||||
if url == "" {
|
||||
// 从 .ass 还原出来的弹幕没有头像链接,按 uid 反查一次
|
||||
@@ -311,6 +321,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
lPrintf("正在获取头像:已处理 %d/%d 个用户", i+1, len(uids))
|
||||
}
|
||||
}
|
||||
jobAvatars(i+1, len(uids))
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
@@ -348,6 +359,21 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
|
||||
chunks := splitDanmuChunks(records, offset, videoDuration)
|
||||
// 任务总时长:有视频就用视频时长,透明头像层模式没有视频,用各块时长之和
|
||||
jobTotalSec := videoDuration
|
||||
if jobTotalSec <= 0 {
|
||||
for _, ch := range chunks {
|
||||
jobTotalSec += ctx.chunkDuration(ch)
|
||||
}
|
||||
}
|
||||
jobSetTotal(jobTotalSec)
|
||||
// 预计成片体积 =(视频码率 + 音频码率)× 时长 ÷ 8;渲染过 5% 之后会被实测外推值顶掉
|
||||
if bitrate > 0 {
|
||||
jobPlanSize(int64(float64(bitrate+estimateAudioKbps) * jobTotalSec / 8 * 1000))
|
||||
}
|
||||
jobChunks(0, len(chunks))
|
||||
jobSetPhase(jobPhaseRender)
|
||||
|
||||
if len(chunks) <= 1 {
|
||||
lPrintf("共 %d 条弹幕,%d 个用户的头像可用,开始合成", len(records), len(avatarPaths))
|
||||
return ctx.burnSingle(records)
|
||||
@@ -518,7 +544,10 @@ func (c *burnContext) burnSingle(records []danmuRecord) error {
|
||||
}
|
||||
defer os.Remove(assPath)
|
||||
|
||||
return c.renderChunk(chunk, assName, c.outFile, false)
|
||||
// 只有一块,ffmpeg 报的时长就是整片进度
|
||||
return c.renderChunk(chunk, assName, c.outFile, false, func(outSec, speed, fps float64, size int64) {
|
||||
jobRender(outSec, speed, fps, size)
|
||||
})
|
||||
}
|
||||
|
||||
// 分块渲染:每块单独渲染成一个小片,最后用 concat 拼成完整成片
|
||||
@@ -536,7 +565,18 @@ func (c *burnContext) burnChunked(chunks []danmuChunk) error {
|
||||
}()
|
||||
|
||||
ad, gap := avatarSize(c.meta.FontSize), avatarGap(c.meta.FontSize)
|
||||
// 已完成块写出的字节数:加在当前块上,界面上的「已写出」和体积外推才是累计值
|
||||
var bytesBase int64
|
||||
var joinedSec float64
|
||||
for _, ch := range chunks {
|
||||
joinedSec += c.chunkDuration(ch)
|
||||
}
|
||||
for i, ch := range chunks {
|
||||
// 每块开始时过一下闸门:暂停在这里等,停止在这里直接退出
|
||||
if err := jobGate(); err != nil {
|
||||
return err
|
||||
}
|
||||
jobChunks(i, len(chunks))
|
||||
// 每块的字幕单独生成,只包含这一块时间范围内的弹幕
|
||||
assName := fmt.Sprintf("%schunk_%04d.ass", chunkTempPrefix, i)
|
||||
assPath := filepath.Join(c.dir, assName)
|
||||
@@ -546,16 +586,32 @@ func (c *burnContext) burnChunked(chunks []danmuChunk) error {
|
||||
defer os.Remove(assPath)
|
||||
|
||||
part := filepath.Join(c.dir, fmt.Sprintf("%spart_%04d%s", chunkTempPrefix, i, ext))
|
||||
// 先登记再渲染:中途失败或被打断时,defer 里的清理也能把这个半成品删掉
|
||||
parts = append(parts, part)
|
||||
lPrintf("正在渲染第 %d/%d 块(%s ~ %s,%d 条弹幕)",
|
||||
i+1, len(chunks), formatSeconds(ch.start), formatSeconds(ch.end), len(ch.records))
|
||||
if err := c.renderChunk(ch, assName, part, true); err != nil {
|
||||
base := bytesBase
|
||||
if err := c.renderChunk(ch, assName, part, true, func(outSec, speed, fps float64, size int64) {
|
||||
// 块内时间加上块起点,折算成整片时间轴上的进度
|
||||
jobRender(ch.start+outSec, speed, fps, base+size)
|
||||
}); err != nil {
|
||||
if jobStopRequested() {
|
||||
return errJobStopped
|
||||
}
|
||||
return fmt.Errorf("渲染第 %d/%d 块失败:%v", i+1, len(chunks), err)
|
||||
}
|
||||
parts = append(parts, part)
|
||||
if n := fileSizeOrZero(part); n > 0 {
|
||||
bytesBase += n
|
||||
}
|
||||
}
|
||||
|
||||
jobSetPhase(jobPhasePlace)
|
||||
lPrintf("正在把 %d 个分块拼接成完整成片", len(parts))
|
||||
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir); err != nil {
|
||||
if err := concatChunks(c.ffmpegFile, parts, c.outFile, c.dir, func(outSec, speed, fps float64, size int64) {
|
||||
if joinedSec > 0 {
|
||||
jobSetPhasePercent(outSec / joinedSec)
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -633,7 +689,8 @@ func writeChunkAss(path string, meta danmuMeta, chunk danmuChunk, offset float64
|
||||
|
||||
// 渲染一个块:把这一块的弹幕(文字层 + 头像层)压进对应的视频片段
|
||||
// cut 为 true 时表示这是分块渲染,需要按时长切出视频片段并重编码音频
|
||||
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool) error {
|
||||
// cb 用来回传 ffmpeg 的实时进度(已输出时长 / 倍速 / 帧率 / 已写出字节),可为 nil
|
||||
func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut bool, cb ffmpegProgress) error {
|
||||
burnMode := c.videoFile != ""
|
||||
|
||||
// 统计这一块用到的头像,按首次出现顺序分配输入序号(输入 0 是视频或背景)
|
||||
@@ -743,7 +800,7 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
|
||||
// 透明头像层模式固定用 ProRes 4444,不能再追加编码器覆盖
|
||||
full := append(append([]string{}, args...), out)
|
||||
lPrintln("开始运行 ffmpeg 合成透明头像层,输出到 " + out)
|
||||
if err := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
|
||||
if err := runFFmpegWith(c.ffmpegFile, full, c.dir, cb); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -770,7 +827,11 @@ func (c *burnContext) renderChunk(chunk danmuChunk, assBase, out string, cut boo
|
||||
if i == begin {
|
||||
lPrintf("开始运行 ffmpeg 合成(编码器 %s),输出到 %s", enc.name, out)
|
||||
}
|
||||
if err := runFFmpeg(c.ffmpegFile, full, c.dir); err != nil {
|
||||
if err := runFFmpegWith(c.ffmpegFile, full, c.dir, cb); err != nil {
|
||||
// 停止任务时杀掉 ffmpeg 也会让它非 0 退出,这时候不该再换编码器重试
|
||||
if jobStopRequested() {
|
||||
return errJobStopped
|
||||
}
|
||||
lastErr = err
|
||||
if i+1 < len(encoders) {
|
||||
lPrintWarnf("编码器 %s 运行失败,回退到 %s:%v", enc.name, encoders[i+1].name, err)
|
||||
@@ -859,16 +920,97 @@ func buildEncoders(bitrate int, ffmpegFile string) []videoEncoder {
|
||||
}
|
||||
|
||||
// 运行 ffmpeg,输出直接透传到控制台
|
||||
// ffmpeg 的进度回调:已输出时长(秒)、实时倍速、帧率、已写出字节
|
||||
type ffmpegProgress func(outSec, speed, fps float64, totalSize int64)
|
||||
|
||||
func runFFmpeg(ffmpegFile string, args []string, dir string) error {
|
||||
cmd := exec.Command(ffmpegFile, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
return runFFmpegWith(ffmpegFile, args, dir, nil)
|
||||
}
|
||||
|
||||
// 用 concat demuxer 把各分块的成片拼接起来
|
||||
func concatChunks(ffmpegFile string, files []string, out, dir string) error {
|
||||
// 跑一次 ffmpeg。cb 不为空时加 -progress pipe:1 取结构化进度;
|
||||
// ffmpeg 自己的统计行仍在 stderr 里,照旧进日志,单独用命令行时不会少东西。
|
||||
func runFFmpegWith(ffmpegFile string, args []string, dir string, cb ffmpegProgress) error {
|
||||
// 暂停时在这里等,任务被停止时直接返回,不再启动新进程
|
||||
if err := jobGate(); err != nil {
|
||||
return err
|
||||
}
|
||||
full := args
|
||||
if cb != nil {
|
||||
full = append([]string{"-progress", "pipe:1"}, args...)
|
||||
}
|
||||
cmd := exec.Command(ffmpegFile, full...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stderr = os.Stderr
|
||||
if cb != nil {
|
||||
// StdoutPipe 要求 Stdout 还没被设置,所以这里不能先指到 os.Stdout
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
jobAttach(cmd)
|
||||
defer jobDetach(cmd)
|
||||
// 读到 EOF(进程退出会关闭管道)再 Wait,避免和 Wait 抢着关管道
|
||||
scanFFmpegProgress(stdout, cb)
|
||||
return cmd.Wait()
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
jobAttach(cmd)
|
||||
defer jobDetach(cmd)
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
// 解析 -progress pipe:1 的输出:每行一个 key=value,
|
||||
// 每个块以 progress=continue / progress=end 收尾,就在这时候回调一次。
|
||||
func scanFFmpegProgress(r io.Reader, cb ffmpegProgress) {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 4096), 1<<20)
|
||||
var doneSec, speed, fps float64
|
||||
var size int64
|
||||
for sc.Scan() {
|
||||
key, val, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "out_time_us", "out_time_ms":
|
||||
// 这两个键都是微秒(ffmpeg 的历史遗留),都认一下,谁在后面谁说了算
|
||||
if us, err := strconv.ParseInt(val, 10, 64); err == nil && us >= 0 {
|
||||
doneSec = float64(us) / 1e6
|
||||
}
|
||||
case "speed":
|
||||
speed = parseFFmpegSpeed(val)
|
||||
case "fps":
|
||||
if v, err := strconv.ParseFloat(val, 64); err == nil && v >= 0 {
|
||||
fps = v
|
||||
}
|
||||
case "total_size":
|
||||
if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 {
|
||||
size = v
|
||||
}
|
||||
case "progress":
|
||||
cb(doneSec, speed, fps, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 ffmpeg 的 speed 字段,形如 "1.37x";还没开始或暂停时是 "N/A"
|
||||
func parseFFmpegSpeed(v string) float64 {
|
||||
v = strings.TrimSuffix(strings.TrimSpace(v), "x")
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err != nil || f < 0 {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// 用 concat demuxer 把各分块的成片拼接起来。cb 用来回传拼接进度,可为 nil
|
||||
func concatChunks(ffmpegFile string, files []string, out, dir string, cb ffmpegProgress) error {
|
||||
listFile := filepath.Join(dir, chunkTempPrefix+"list.txt")
|
||||
var b strings.Builder
|
||||
for _, p := range files {
|
||||
@@ -881,12 +1023,20 @@ func concatChunks(ffmpegFile string, files []string, out, dir string) error {
|
||||
}
|
||||
defer os.Remove(listFile)
|
||||
|
||||
if err := runFFmpeg(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir); err != nil {
|
||||
if err := runFFmpegWith(ffmpegFile, []string{"-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", out}, dir, cb); err != nil {
|
||||
return fmt.Errorf("拼接各分块失败:%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 取文件大小,出错时返回 0
|
||||
func fileSizeOrZero(path string) int64 {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
return info.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 把秒格式化成 ffmpeg 参数,保留毫秒
|
||||
func formatSeconds(sec float64) string {
|
||||
return strconv.FormatFloat(sec, 'f', 3, 64)
|
||||
|
||||
Reference in New Issue
Block a user