新增「转码」分区与手动合并逐任务配置,修掉 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:
+96
-75
@@ -20,7 +20,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -147,6 +146,70 @@ type burnContext struct {
|
||||
// 透明头像层(无源片模式)默认的帧率
|
||||
const defaultLayerFps = 30
|
||||
|
||||
// 一次合成的参数。手动合并里每个任务可以自带一套,不带的项回落到全局设置。
|
||||
//
|
||||
// 码率、帧率、字号都用指针:0 本身是有明确含义的(自动码率 / 跟随源片 / 自动字号),
|
||||
// 用普通 int 分不清「没填」和「填了 0」,手动合并就会把用户在全局设置里填的值顶掉
|
||||
type mergeOptions struct {
|
||||
Bitrate *int `json:"bitrate,omitempty"` // 合成码率(Kbps),0 = 按「源片码率 + 弹幕余量」自动
|
||||
Fps *int `json:"fps,omitempty"` // 输出帧率,0 = 跟随源片
|
||||
FontSize *int `json:"fontSize,omitempty"` // 弹幕字号(像素),0 = 按画面分辨率自动
|
||||
Encoder string `json:"encoder,omitempty"` // 指定 ffmpeg 编码器,空或 auto = 自动
|
||||
}
|
||||
|
||||
// 取本次合成用的码率 / 帧率 / 字号,没指定就沿用全局设置
|
||||
func (o mergeOptions) bitrate() int {
|
||||
if o.Bitrate != nil {
|
||||
return *o.Bitrate
|
||||
}
|
||||
return config.MergeBitrate
|
||||
}
|
||||
|
||||
func (o mergeOptions) fps() int {
|
||||
if o.Fps != nil {
|
||||
return *o.Fps
|
||||
}
|
||||
return config.MergeFps
|
||||
}
|
||||
|
||||
func (o mergeOptions) fontSize() int {
|
||||
if o.FontSize != nil {
|
||||
return *o.FontSize
|
||||
}
|
||||
return config.DanmuFontSize
|
||||
}
|
||||
|
||||
// 指定的编码器(空表示让 ffmpeg 那边按可用性自动挑)
|
||||
func (o mergeOptions) encoder() string {
|
||||
if o.Encoder == "" || o.Encoder == "auto" {
|
||||
return ""
|
||||
}
|
||||
return o.Encoder
|
||||
}
|
||||
|
||||
// 检查一次合成的参数,返回给用户的错误说明(空字符串表示通过)
|
||||
func (o mergeOptions) validate(ffmpegFile string) string {
|
||||
if o.Bitrate != nil && (*o.Bitrate < 0 || *o.Bitrate > maxBitrateKbps) {
|
||||
return fmt.Sprintf("合成码率要在 0(自动)到 %d(Kbps)之间", maxBitrateKbps)
|
||||
}
|
||||
if o.Fps != nil && (*o.Fps < 0 || *o.Fps > maxMergeFps) {
|
||||
return fmt.Sprintf("输出帧率只能在 0(跟随源片)到 %d 之间", maxMergeFps)
|
||||
}
|
||||
if o.FontSize != nil && *o.FontSize < 0 {
|
||||
return "弹幕字号不能小于 0(0 表示按画面分辨率自动)"
|
||||
}
|
||||
if enc := o.encoder(); enc != "" {
|
||||
for _, n := range availableEncoders(ffmpegFile) {
|
||||
if n == enc {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("本机没有可用的编码器 %s,可用的是:%s",
|
||||
enc, strings.Join(availableEncoders(ffmpegFile), "、"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 设置里的输出帧率 vs 源片帧率:只在目标**更高**时才加 fps 滤镜 —— 降帧只会更卡。
|
||||
// 源片帧率探测不到(0)时也不动,免得把高帧率源压到某个值
|
||||
func outputFps(target int, source float64) int {
|
||||
@@ -275,7 +338,7 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
||||
}
|
||||
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
|
||||
// 这里到处都是 os.Exit,defer 不会被执行,所以显式调用
|
||||
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset); err != nil {
|
||||
if err := burnDanmu(src.jsonlPath, videoFile, outFile, offset, mergeOptions{}); err != nil {
|
||||
dropTempDanmu(src)
|
||||
if err == errNoDanmu {
|
||||
lPrintWarn(err.Error())
|
||||
@@ -346,8 +409,9 @@ func fitDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]da
|
||||
}
|
||||
|
||||
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
|
||||
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频
|
||||
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频。
|
||||
// opt 是这一次的合成参数,不填的项用全局设置(见 mergeOptions)
|
||||
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64, opt mergeOptions) error {
|
||||
jsonlFile, err := filepath.Abs(jsonlFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -420,11 +484,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径")
|
||||
}
|
||||
|
||||
// 时长、分辨率、码率、帧率一次探测拿全(原来为了拿时长和视频流各起了一次 ffmpeg)
|
||||
var vinfo mediaInfo
|
||||
if burnMode {
|
||||
vinfo = probeMedia(ffmpegFile, videoFile)
|
||||
}
|
||||
|
||||
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉;
|
||||
// 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来
|
||||
videoDuration := 0.0
|
||||
if burnMode {
|
||||
videoDuration = probeDuration(ffmpegFile, videoFile)
|
||||
videoDuration = vinfo.duration
|
||||
if videoDuration > 0 {
|
||||
records, offset = fitDanmuToVideo(records, offset, videoDuration)
|
||||
if len(records) == 0 {
|
||||
@@ -434,17 +504,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
|
||||
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
|
||||
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)
|
||||
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)。
|
||||
// 码率沿用「视频流自己的,没有就用容器总码率」的老口径(见 streamOrContainerKbps)
|
||||
sourceKbps := 0
|
||||
sourceFps := 0.0
|
||||
if burnMode {
|
||||
info := probeVideoStream(ffmpegFile, videoFile)
|
||||
sourceKbps, sourceFps = info.kbps, info.fps
|
||||
unifyDanmuSpace(&meta, info.width, info.height)
|
||||
sourceKbps, sourceFps = streamOrContainerKbps(vinfo), vinfo.fps
|
||||
unifyDanmuSpace(&meta, vinfo.width, vinfo.height)
|
||||
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
|
||||
}
|
||||
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
|
||||
applyFontSize(&meta, config.DanmuFontSize)
|
||||
applyFontSize(&meta, opt.fontSize())
|
||||
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
||||
relayoutLanes(records, meta)
|
||||
|
||||
@@ -476,7 +546,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
|
||||
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
|
||||
bitrate := config.MergeBitrate + burnBitrate
|
||||
bitrate := opt.bitrate() + burnBitrate
|
||||
if bitrate == 0 && burnMode {
|
||||
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
|
||||
lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto)
|
||||
@@ -486,11 +556,12 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 输出帧率:设置填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的,
|
||||
// 输出帧率:填 0 = 跟随源片(不动)。低帧率录播(15/24fps)里弹幕是一格一格跳的,
|
||||
// 提到更高帧率之后弹幕层按新帧率重绘,字和头像都顺了;源片本来就够高就不动
|
||||
outFps := outputFps(config.MergeFps, sourceFps)
|
||||
if burnMode && config.MergeFps > 0 && outFps == 0 {
|
||||
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, config.MergeFps)
|
||||
wantFps := opt.fps()
|
||||
outFps := outputFps(wantFps, sourceFps)
|
||||
if burnMode && wantFps > 0 && outFps == 0 {
|
||||
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, wantFps)
|
||||
}
|
||||
if outFps > 0 {
|
||||
lPrintf("输出帧率:源片 %.2f fps,按设置提到 %d fps(弹幕层会按新帧率重绘)", sourceFps, outFps)
|
||||
@@ -515,7 +586,8 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
||||
bitrate: bitrate,
|
||||
dir: workDir,
|
||||
outFps: outFps,
|
||||
layerFps: layerFps(config.MergeFps),
|
||||
layerFps: layerFps(wantFps),
|
||||
encoder: opt.encoder(),
|
||||
}
|
||||
|
||||
chunks := splitDanmuChunks(records, offset, videoDuration)
|
||||
@@ -724,51 +796,14 @@ type videoStreamInfo struct {
|
||||
fps float64 // 帧率
|
||||
}
|
||||
|
||||
// 探测视频流的分辨率、码率和帧率
|
||||
// 探测视频流的分辨率、码率和帧率。
|
||||
// 实际解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里只挑视频那几个字段
|
||||
func probeVideoStream(ffmpegFile, path string) videoStreamInfo {
|
||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
// 没指定输出文件时 ffmpeg 会返回非 0,但流信息已经打印出来了
|
||||
_ = cmd.Run()
|
||||
|
||||
var info videoStreamInfo
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
// 容器的总码率(含音频)先记下来,视频流自己没写码率时用它
|
||||
if strings.Contains(line, "Duration:") {
|
||||
if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
info.kbps, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 只看视频流,跳过封面图(mjpeg 之类的 attached pic)那几行
|
||||
if !strings.Contains(line, "Video:") || strings.Contains(line, "attached pic") {
|
||||
continue
|
||||
}
|
||||
if m := videoSizeRe.FindStringSubmatch(line); len(m) == 3 {
|
||||
info.width, _ = strconv.Atoi(m[1])
|
||||
info.height, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
if m := videoBitrateRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
info.kbps, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
if m := videoFpsRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
info.fps, _ = strconv.ParseFloat(m[1], 64)
|
||||
}
|
||||
}
|
||||
return info
|
||||
m := probeMedia(ffmpegFile, path)
|
||||
// 口径跟以前一致:视频流自己没写码率就退回容器总码率,别改成扣掉音频的版本
|
||||
return videoStreamInfo{width: m.width, height: m.height, kbps: streamOrContainerKbps(m), fps: m.fps}
|
||||
}
|
||||
|
||||
var (
|
||||
// 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分
|
||||
videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`)
|
||||
videoBitrateRe = regexp.MustCompile(`, (\d+) kb/s`)
|
||||
// 容器总码率,形如「Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s」
|
||||
containerBitrateRe = regexp.MustCompile(`bitrate: (\d+) kb/s`)
|
||||
// 帧率,形如「1920x1080, 30 fps, 30 tbr, 1k tbn」
|
||||
videoFpsRe = regexp.MustCompile(`, (\d+(?:\.\d+)?) fps`)
|
||||
)
|
||||
|
||||
// 合成码率填 0(默认)时的算法:源片码率 + 弹幕余量。
|
||||
// 烧进去的文字和头像都是高频细节,跟源片同样码率会糊,所以留一点余量;
|
||||
// 探测不到源片码率时返回 0,让调用方退回质量模式。
|
||||
@@ -790,26 +825,12 @@ const (
|
||||
minMergeBitrate = 800
|
||||
)
|
||||
|
||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0。
|
||||
// 解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里保留薄封装给老调用点
|
||||
func probeDuration(ffmpegFile, path string) float64 {
|
||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
// 没指定输出文件时 ffmpeg 会返回非 0,但时长信息已经打印出来了
|
||||
_ = cmd.Run()
|
||||
|
||||
m := durationRe.FindStringSubmatch(buf.String())
|
||||
if len(m) != 4 {
|
||||
return 0
|
||||
}
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
mi, _ := strconv.Atoi(m[2])
|
||||
s, _ := strconv.ParseFloat(m[3], 64)
|
||||
return float64(h)*3600 + float64(mi)*60 + s
|
||||
return probeMedia(ffmpegFile, path).duration
|
||||
}
|
||||
|
||||
var durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`)
|
||||
|
||||
// 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass
|
||||
func (c *burnContext) burnSingle(records []danmuRecord) error {
|
||||
chunk := danmuChunk{start: 0, end: math.Inf(1), records: records}
|
||||
|
||||
Reference in New Issue
Block a user