新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁
一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。
二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
- POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
- 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
- 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
失败或中断删临时文件,不留半成品
- 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止
三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。
四、审阅中一并修掉 / 加固的点
- 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
- 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
- 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
- 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
- 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
- probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
- 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则
验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
This commit is contained in:
+76
-4
@@ -14,6 +14,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 任务类型。阶段划分按它来取(见 jobPhasePlanFor)
|
||||
const (
|
||||
jobKindManualMerge = "手动合并"
|
||||
jobKindAutoMerge = "自动合并"
|
||||
jobKindTranscode = "转码"
|
||||
)
|
||||
|
||||
// 任务阶段,前端按这个顺序画步骤条
|
||||
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
|
||||
|
||||
@@ -30,6 +37,36 @@ const (
|
||||
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
|
||||
var jobPhaseWeights = []float64{0.02, 0.08, 0.10, 0.75, 0.05}
|
||||
|
||||
// 一类任务的阶段划分。转码没有弹幕和头像,步骤比合并少,硬套合并那套步骤条
|
||||
// 会冒出两个永远走不到的空阶段
|
||||
type jobPhasePlan struct {
|
||||
names []string
|
||||
weights []float64
|
||||
render int // 哪个阶段是真正耗时的主阶段,ETA 按它算
|
||||
}
|
||||
|
||||
// 合并类任务的阶段(默认),转码类任务的阶段
|
||||
var (
|
||||
mergePhasePlan = jobPhasePlan{names: jobPhaseNames, weights: jobPhaseWeights, render: jobPhaseRender}
|
||||
// 转码:探测一下参数就开始编,编完落到成片目录
|
||||
transcodePhasePlan = jobPhasePlan{
|
||||
names: []string{"准备", "转码", "落盘"},
|
||||
weights: []float64{0.03, 0.92, 0.05},
|
||||
render: 1,
|
||||
}
|
||||
)
|
||||
|
||||
// 转码任务阶段里「转码」这一步的序号
|
||||
const jobPhaseTranscode = 1
|
||||
|
||||
// 按任务类型取阶段划分,没见过的类型按合并处理
|
||||
func jobPhasePlanFor(kind string) jobPhasePlan {
|
||||
if kind == jobKindTranscode {
|
||||
return transcodePhasePlan
|
||||
}
|
||||
return mergePhasePlan
|
||||
}
|
||||
|
||||
// 渲染进度的采样间隔与折线最多保留的点数(1 秒 1 个,够画两分钟)
|
||||
const (
|
||||
jobSampleInterval = time.Second
|
||||
@@ -39,6 +76,10 @@ const (
|
||||
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
|
||||
var errJobStopped = errors.New("任务已被手动停止")
|
||||
|
||||
// 起任务时任务位被别人占着:这次没有真的开始,调用方据此跳过 jobFinish,
|
||||
// 不然会把正在跑的那个任务的进度卡片清掉
|
||||
var errJobNotStarted = errors.New("已经有别的任务在跑,请先等它结束")
|
||||
|
||||
// 速度折线上的一个点
|
||||
type jobSample struct {
|
||||
T float64 `json:"t"` // 任务开始后的秒数
|
||||
@@ -64,6 +105,8 @@ type jobInfo struct {
|
||||
ChunkTotal int `json:"chunkTotal"`
|
||||
AvatarDone int `json:"avatarDone"`
|
||||
AvatarTotal int `json:"avatarTotal"`
|
||||
QueueDone int `json:"queueDone"` // 批量任务:已经做完第几个(从 1 开始),单任务为 0
|
||||
QueueTotal int `json:"queueTotal"` // 批量任务:一共几个
|
||||
InputBytes int64 `json:"inputBytes"` // 源视频总大小
|
||||
EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小
|
||||
EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured
|
||||
@@ -90,6 +133,14 @@ type jobState struct {
|
||||
stopped bool
|
||||
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
|
||||
|
||||
// 当前任务的阶段划分,随任务类型在 jobStart 时定下来
|
||||
phases []string
|
||||
weights []float64
|
||||
renderIdx int
|
||||
// 批量任务(手动合并队列、转码队列)的进度,单任务时都是 0
|
||||
queueDone int
|
||||
queueTotal int
|
||||
|
||||
totalSec float64
|
||||
doneSec float64
|
||||
speed float64
|
||||
@@ -163,6 +214,10 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
||||
job.active = true
|
||||
job.kind = kind
|
||||
job.outName = outName
|
||||
plan := jobPhasePlanFor(kind)
|
||||
job.phases = plan.names
|
||||
job.weights = plan.weights
|
||||
job.renderIdx = plan.render
|
||||
job.phaseIdx = 0
|
||||
job.phasePct = 0
|
||||
job.started = time.Now()
|
||||
@@ -175,6 +230,7 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
||||
job.fps = 0
|
||||
job.chunkDone, job.chunkTotal = 0, 0
|
||||
job.avatarDone, job.avatarTotal = 0, 0
|
||||
job.queueDone, job.queueTotal = 0, 0
|
||||
job.inputBytes = inputBytes
|
||||
job.planOutBytes = 0
|
||||
job.liveOutBytes = 0
|
||||
@@ -217,7 +273,7 @@ func jobFinish(errText string) {
|
||||
job.doneAt = time.Now().Unix()
|
||||
switch errText {
|
||||
case "":
|
||||
job.phaseIdx = len(jobPhaseNames)
|
||||
job.phaseIdx = len(job.phases)
|
||||
job.phasePct = 1
|
||||
job.message = "已完成"
|
||||
case errJobStopped.Error():
|
||||
@@ -346,6 +402,16 @@ func jobAvatars(done, total int) {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量任务(手动合并队列、转码队列)排到第几个。done 从 1 开始,total 是总数
|
||||
func jobQueue(done, total int) {
|
||||
job.Lock()
|
||||
defer job.Unlock()
|
||||
if !job.active {
|
||||
return
|
||||
}
|
||||
job.queueDone, job.queueTotal = done, total
|
||||
}
|
||||
|
||||
// 设置任务总时长(视频秒数),ETA 和渲染百分比都靠它
|
||||
func jobSetTotal(sec float64) {
|
||||
job.Lock()
|
||||
@@ -481,8 +547,12 @@ func clamp01(v float64) float64 {
|
||||
|
||||
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
|
||||
func (j *jobState) percentLocked() float64 {
|
||||
weights := j.weights
|
||||
if len(weights) == 0 {
|
||||
weights = jobPhaseWeights
|
||||
}
|
||||
p := 0.0
|
||||
for i, w := range jobPhaseWeights {
|
||||
for i, w := range weights {
|
||||
switch {
|
||||
case i < j.phaseIdx:
|
||||
p += w
|
||||
@@ -498,7 +568,7 @@ func (j *jobState) etaLocked(now time.Time) float64 {
|
||||
if !j.active {
|
||||
return 0
|
||||
}
|
||||
if j.phaseIdx == jobPhaseRender && j.speed > 0 && j.totalSec > j.doneSec {
|
||||
if j.phaseIdx == j.renderIdx && j.speed > 0 && j.totalSec > j.doneSec {
|
||||
return (j.totalSec - j.doneSec) / j.speed
|
||||
}
|
||||
pct := j.percentLocked()
|
||||
@@ -518,7 +588,7 @@ func jobSnapshot() jobInfo {
|
||||
Kind: job.kind,
|
||||
OutName: job.outName,
|
||||
PhaseIdx: job.phaseIdx,
|
||||
Phases: jobPhaseNames,
|
||||
Phases: job.phases,
|
||||
Paused: job.paused,
|
||||
Stopping: job.stopped,
|
||||
Speed: job.speed,
|
||||
@@ -529,6 +599,8 @@ func jobSnapshot() jobInfo {
|
||||
ChunkTotal: job.chunkTotal,
|
||||
AvatarDone: job.avatarDone,
|
||||
AvatarTotal: job.avatarTotal,
|
||||
QueueDone: job.queueDone,
|
||||
QueueTotal: job.queueTotal,
|
||||
InputBytes: job.inputBytes,
|
||||
OutBytes: job.outBytes,
|
||||
Message: job.message,
|
||||
|
||||
Reference in New Issue
Block a user