diff --git a/README.md b/README.md index 2c88e0d..6c67601 100644 --- a/README.md +++ b/README.md @@ -162,22 +162,23 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。 -页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区: +页面按用途分成七个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区: | 分区 | 内容 | | --- | --- | | 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 | | 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 | -| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 | +| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) | +| 转码 | 把录播或成片重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 | | 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 | | 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 设置 | 是否允许外网访问、管理口令的查看与更换 | ### 转换任务进度 -自动合并和手动合并跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是: +自动合并、手动合并和转码跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是: -- **阶段步骤条**:准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,走过的打勾、当前的高亮(暂停时变红); +- **阶段步骤条**:合并是准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,转码是准备 → 转码 → 落盘,走过的打勾、当前的高亮(暂停时变红);一次排了多个任务时标题上还会标`第 n/N 个`; - **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权; - **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少; - **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推; @@ -253,15 +254,40 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe ### 手动合并 -web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。 +web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。**可以一次排多个任务,每个任务一套自己的文件和参数。** -- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。 -- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上并提示一句;找不到、或者弹幕框里已经有内容时就保持原样,自己手选即可。 +选好视频后会按文件名自动归组:文件名是`2026-09-25 01-00-00 主播名 标题.mp4`这种格式,去掉开头的时间和扩展名后剩下的(主播名 + 标题)相同、而且相邻两段录制时间差在 2 小时以内的算同一场——同一场断流分成的几段归到一个任务里、先拼起来再合,不同场次各排一个任务、依次合出多个成片。归错了(比如同一天连着两场、标题又一样但间隔在 2 小时内)可以点任务行上的`拆成单独任务`把它们拆开。 + +每个任务行里能单独改: + +- **视频**:行里列出这一组的文件,可以整组移除或拆成一人一个任务。既可以从录播目录的下拉列表里多选,也可以点`浏览`从别的文件夹挑。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用。 +- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl)。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上;找不到就保持空着,用行里的`…`按钮手选。 - **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。 -- **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。 -- **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。 +- **输出文件名**:留空时按弹幕文件名加`_弹幕合并`自动命名,重名会自动加`(2)`之类的后缀;自己填了名字就按填的来(**与已有文件同名时会被覆盖**,注意别填错)。 +- **本次的合成参数**:码率(Kbps)、输出帧率、弹幕字号(像素)、编码器。**留空就沿用`成片输出`里的全局设置**,填了就只对这一个任务生效——比如某一场要压小一点、另一场保持原码率。 +- **先预览再合并**:`检查`按钮会把每个任务的分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件逐段列出来,确认没问题再点`开始合并`。 -一次只允许跑一个手动合并任务,重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。 +一次只跑一个合并任务,排着的按列表顺序来;重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。同一组里的多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。 + +### 转码 + +`转码`分区用来把录播或成片重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。 + +从文件列表里多选加入之后,每个文件一行,参数各自独立: + +| 参数 | 说明 | +| --- | --- | +| 输出文件名 | 默认是`原名_转码.mp4`;这个默认名重名时会自动加`(2)`之类的后缀,自己改成别的名字就按填的来(**与已有文件同名时会被覆盖**) | +| 编码器 | `自动`按本机可用性挑(优先 Intel QSV 的 HEVC),也可以指定 HEVC 硬件 / H.264 硬件 / H.264 软件;本机没有的编码器会被挡住 | +| 码率控制 | `源片码率的比例`(默认 60%)/ `指定目标码率` / `质量模式`(CRF 或 ICQ,数值越小画质越好)/ `跟随源片码率` | +| 编码速度 | veryfast ~ slower,慢一点压得小一点 | +| 输出帧率 | 0 表示跟随源片;转码场景下可以按填的值升也可以降 | +| 分辨率 | 按高度缩放,只列出比源片低的档,不会把小图放大 | +| 音频 | 直接拷贝 / 重编码 128k 或 192k / 去掉音轨 | + +每行下面实时显示**预估产物大小**和相对源片的百分比(体积是服务端按「码率 × 时长」算的,目标码率模式是准数,质量模式只给粗估并标注依据),列表底部还有一整批的合计。调好第一个之后点`把第一个的参数套到全部`可以一次性铺给其它行。 + +转码和合并共用同一张进度卡片、同一个任务位,所以一次只跑一个文件,排着的按列表顺序来,也能在`监控`页暂停和停止;停止只丢当前这个,已经在输出目录里落地的成品不受影响。 ### 通知推送 diff --git a/automerge.go b/automerge.go index 496a340..bb4e46b 100644 --- a/automerge.go +++ b/automerge.go @@ -321,7 +321,7 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs inputBytes += fileSizeOrZero(p) } } - if !jobStart("自动合并", filepath.Base(outFile), inputBytes, 0) { + if !jobStart(jobKindAutoMerge, filepath.Base(outFile), inputBytes, 0) { // 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场 return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过") } @@ -335,7 +335,8 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs // 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉 markFileActive(tmpOut, true) - burnErr := burnDanmu(jsonl, video, tmpOut, offset) + // 自动合并没有单独的参数,全部沿用全局设置 + burnErr := burnDanmu(jsonl, video, tmpOut, offset, mergeOptions{}) markFileActive(tmpOut, false) if burnErr != nil { _ = os.Remove(tmpOut) diff --git a/danmuburn.go b/danmuburn.go index 6023b22..c4e4ed4 100644 --- a/danmuburn.go +++ b/danmuburn.go @@ -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} diff --git a/manualmerge.go b/manualmerge.go index 54cd85e..d76ecb7 100644 --- a/manualmerge.go +++ b/manualmerge.go @@ -27,13 +27,39 @@ var mergeVideoExts = map[string]struct{}{ var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}} // 手动合并的请求体 +// +// 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务, +// 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并 type mergeRequest struct { Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径 Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算 OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名 + + Options mergeOptions `json:"options"` // 这一次合成的参数,不填的项用全局设置 + Jobs []mergeJobReq `json:"jobs"` // 批量:每个任务一套自己的文件和参数 } +// 批量里的一个合并任务 +type mergeJobReq struct { + Videos []string `json:"videos"` // 这一组的视频,多个就是同一场的断流分段,会先拼起来 + Danmu string `json:"danmu"` // 这一组的弹幕文件 + Offset *float64 `json:"offset"` // 这一组的偏移 + OutName string `json:"outName"` // 这一组的输出文件名 + Options mergeOptions `json:"options"` // 这一组的合成参数 +} + +// 一次最多排多少个合并任务。再多就得等太久,队列状态也会一直占着 +const maxMergeJobs = 50 + +// 批量任务里每个任务的结果,页面按它显示「第几个、成功还是失败」 +type mergeJobResult struct { + OutName string `json:"outName"` + Err string `json:"err"` // 空表示成功 +} + +func (r mergeJobResult) ok() bool { return r.Err == "" } + // 检查结果里每个视频的信息 type mergeVideoInfo struct { Name string `json:"name"` @@ -65,6 +91,7 @@ type mergePlan struct { out string // 成片的绝对路径 meta danmuMeta offset *float64 + opts mergeOptions // 这一组自己的合成参数 info mergeInfo } @@ -75,6 +102,10 @@ var mergeRun struct { outName string lastErr string doneAt int64 + // 批量合并的进度:一共几个、已经做完几个、每个的结果 + total int + done int + results []mergeJobResult } // 手动合并是否正在进行 @@ -84,6 +115,15 @@ func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) { return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt } +// 批量合并的队列进度,页面用它显示「第 2/3 个」和各任务的结果 +func mergeQueueSnapshot() (busy bool, done, total int, results []mergeJobResult) { + mergeRun.Lock() + defer mergeRun.Unlock() + out := make([]mergeJobResult, len(mergeRun.results)) + copy(out, mergeRun.results) + return mergeRun.busy, mergeRun.done, mergeRun.total, out +} + // 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径 func resolveMergeInput(value string) (string, error) { value = strings.TrimSpace(value) @@ -359,9 +399,44 @@ func freeOutPath(dir, base string) string { return cand } +// 把一次请求拆成若干个合并任务并逐个检查。全部检查通过才返回: +// 排到第 3 个才发现文件不对、前两个已经合完了,这种情况很难跟用户解释 +func buildMergePlans(req mergeRequest) ([]mergePlan, error) { + if len(req.Jobs) == 0 { + plan, err := buildMergePlan(req) + if err != nil { + return nil, err + } + return []mergePlan{plan}, nil + } + if len(req.Jobs) > maxMergeJobs { + return nil, fmt.Errorf("一次最多排 %d 个合并任务,这次有 %d 个", maxMergeJobs, len(req.Jobs)) + } + plans := make([]mergePlan, 0, len(req.Jobs)) + for i, job := range req.Jobs { + plan, err := buildMergePlan(mergeRequest{ + Videos: job.Videos, + Danmu: job.Danmu, + Offset: job.Offset, + OutName: job.OutName, + Options: job.Options, + }) + if err != nil { + return nil, fmt.Errorf("第 %d 个任务:%v", i+1, err) + } + plans = append(plans, plan) + } + return plans, nil +} + // 解析请求、检查文件并算出合并参数 func buildMergePlan(req mergeRequest) (mergePlan, error) { var plan mergePlan + ffmpegFile := getFFmpeg() + plan.opts = req.Options + if msg := req.Options.validate(ffmpegFile); msg != "" { + return plan, errors.New(msg) + } // 视频:一行一个,允许夹带空行 var values []string @@ -431,7 +506,6 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) { DanmuHasAvatar: src.hasAvatar, Source: src.source, } - ffmpegFile := getFFmpeg() for _, f := range plan.videos { fi, err := os.Stat(f) if err != nil { @@ -489,8 +563,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) { return plan, nil } -// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片 -func runManualMerge(plan mergePlan) error { +// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片。 +// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」 +func runManualMerge(plan mergePlan, idx, total int) error { ffmpegFile := getFFmpeg() if ffmpegFile == "" { return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径") @@ -501,8 +576,11 @@ func runManualMerge(plan mergePlan) error { for _, v := range plan.info.Videos { inputBytes += v.Size } - if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) { - return errors.New("已经有别的合并任务在跑,请先等它结束") + if !jobStart(jobKindManualMerge, plan.info.OutName, inputBytes, plan.info.Duration) { + return errJobNotStarted + } + if idx > 0 && total > 0 { + jobQueue(idx, total) } src, err := prepareDanmuSource(plan.danmu, true) @@ -561,13 +639,18 @@ func runManualMerge(plan mergePlan) error { // 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品 tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out)) - lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)", - filepath.Base(video), filepath.Base(jsonl), offset, note) + // 批量合并时在日志里标出这是第几个,翻日志的时候对得上号 + tag := "" + if total > 1 { + tag = fmt.Sprintf("(第 %d/%d 个)", idx, total) + } + lPrintf("手动合并开始%s:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)", + tag, filepath.Base(video), filepath.Base(jsonl), offset, note) lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out) // 标记成正在写入,避免在文件管理里把还没写完的成片删掉 markFileActive(tmpOut, true) - burnErr := burnDanmu(jsonl, video, tmpOut, offset) + burnErr := burnDanmu(jsonl, video, tmpOut, offset, plan.opts) markFileActive(tmpOut, false) if burnErr != nil { _ = os.Remove(tmpOut) @@ -579,7 +662,7 @@ func runManualMerge(plan mergePlan) error { return err } - lPrintln("手动合并完成:" + plan.out) + lPrintln("手动合并完成" + tag + ":" + plan.out) // 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案 uid := guessUIDByFileName(plan.out) if uid == 0 { @@ -589,24 +672,38 @@ func runManualMerge(plan mergePlan) error { if uid > 0 { text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out } + if total > 1 { + text += fmt.Sprintf("\n(第 %d/%d 个)", idx, total) + } sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text) return nil } -// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成 +// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。 +// 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info func mergeInfoHandler(w http.ResponseWriter, r *http.Request) { var req mergeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) return } - plan, err := buildMergePlan(req) + plans, err := buildMergePlans(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(plan.info); err != nil { + if len(req.Jobs) == 0 { + if err := json.NewEncoder(w).Encode(plans[0].info); err != nil { + lPrintErrf("输出合并检查结果失败:%v", err) + } + return + } + infos := make([]mergeInfo, 0, len(plans)) + for _, p := range plans { + infos = append(infos, p.info) + } + if err := json.NewEncoder(w).Encode(map[string]any{"jobs": infos}); err != nil { lPrintErrf("输出合并检查结果失败:%v", err) } } @@ -618,7 +715,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) return } - plan, err := buildMergePlan(req) + plans, err := buildMergePlans(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -642,10 +739,14 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) { return } mergeRun.busy = true - mergeRun.outName = plan.info.OutName + mergeRun.outName = plans[0].info.OutName mergeRun.lastErr = "" + mergeRun.total = len(plans) + mergeRun.done = 0 + mergeRun.results = nil mergeRun.Unlock() + total := len(plans) safeGo("手动合并", func() { defer releaseMergeSlot() defer func() { @@ -654,33 +755,65 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) { mergeRun.doneAt = time.Now().Unix() mergeRun.Unlock() }() - err := runManualMerge(plan) - // 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上 - jobFinish(jobErrText(err)) - if err == nil { - return - } - msg := err.Error() - switch err { - case errNoDanmu: - msg = "弹幕文件里没有弹幕" - lPrintWarn("弹幕文件里没有弹幕,取消手动合并") - case errJobStopped: - // 手动停止不是失败,别让界面弹「合并失败」 - lPrintWarn("手动合并已被手动停止") + stopped := false + for i, plan := range plans { mergeRun.Lock() - mergeRun.lastErr = "" + mergeRun.outName = plan.info.OutName + mergeRun.done = i mergeRun.Unlock() - return - default: - lPrintErrf("手动合并失败:%v", err) - notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err) + + err := runManualMerge(plan, i+1, total) + // 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上。 + // 只有真的起了任务才收尾:没起成说明占着卡片的是别的任务,别把人家的进度清掉 + if err != errJobNotStarted { + jobFinish(jobErrText(err)) + } + if err == nil { + mergeRun.Lock() + mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName}) + mergeRun.Unlock() + continue + } + msg := err.Error() + switch err { + case errNoDanmu: + msg = "弹幕文件里没有弹幕" + lPrintWarn("弹幕文件里没有弹幕,跳过这个任务") + case errJobStopped: + // 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做 + lPrintWarn("手动合并已被手动停止,剩下的任务不再继续") + stopped = true + default: + lPrintErrf("手动合并失败:%v", err) + notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err) + } + mergeRun.Lock() + mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg}) + mergeRun.lastErr = msg + mergeRun.Unlock() + if stopped { + break + } } mergeRun.Lock() - mergeRun.lastErr = msg + mergeRun.done = len(mergeRun.results) mergeRun.Unlock() + if total > 1 { + ok, failed := 0, 0 + mergeRun.Lock() + for _, r := range mergeRun.results { + if r.ok() { + ok++ + } else { + failed++ + } + } + mergeRun.Unlock() + lPrintf("手动合并队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed) + } }) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`)) + _, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) + + `,"outName":` + strconv.Quote(plans[0].info.OutName) + `}`)) } diff --git a/manualmerge_test.go b/manualmerge_test.go index 378092c..5eb4c0f 100644 --- a/manualmerge_test.go +++ b/manualmerge_test.go @@ -68,7 +68,7 @@ Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)} // (正是它的职责),测试就会莫名其妙失败 func execManualMerge(plan mergePlan) error { jobFinish("") - err := runMergeExclusive(func() error { return runManualMerge(plan) }) + err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) }) jobFinish(jobErrText(err)) return err } @@ -844,3 +844,127 @@ func TestFindDanmuForVideo(t *testing.T) { } }) } + +// ==================== 一次排多个合并任务 ==================== + +// 造一组能过检查的「视频 + 弹幕」:视频随便写点字节,弹幕用真能解析的 jsonl +// 文件要落在录播目录里,因为请求里只带文件名,resolveMergeInput 按录播目录找 +func writeMergePair(t *testing.T, name string) (string, string) { + t.Helper() + video := filepath.Join(recordSaveDir(), name+".mp4") + writeTestFile(t, video, "video") + jsonl := filepath.Join(recordSaveDir(), name+".jsonl") + writeTestFile(t, jsonl, + "{\"playResX\":1920,\"playResY\":1080,\"fontSize\":48,\"startTime\":0}\n"+ + "{\"uid\":101,\"content\":\"第一条\",\"time\":1,\"lane\":0}\n") + return filepath.Base(video), filepath.Base(jsonl) +} + +// 批量请求要能拆成多个方案,且每个方案各自带上自己的参数 +func TestBuildMergePlansBatch(t *testing.T) { + setupMergeTest(t) + v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场") + v2, d2 := writeMergePair(t, "2026-09-25 02-00-00 乙 第二场") + + br1, fps2 := 1500, 60 + plans, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{ + {Videos: []string{v1}, Danmu: d1, Options: mergeOptions{Bitrate: &br1}}, + {Videos: []string{v2}, Danmu: d2, Options: mergeOptions{Fps: &fps2}}, + }}) + if err != nil { + t.Fatalf("批量检查失败:%v", err) + } + if len(plans) != 2 { + t.Fatalf("应拆成 2 个方案,实际 %d", len(plans)) + } + if plans[0].opts.bitrate() != 1500 { + t.Errorf("第一个任务的码率没带上:%d", plans[0].opts.bitrate()) + } + if plans[1].opts.bitrate() != config.MergeBitrate { + t.Errorf("第二个任务没填码率就该用全局设置,实际 %d", plans[1].opts.bitrate()) + } + if plans[1].opts.fps() != 60 { + t.Errorf("第二个任务的帧率没带上:%d", plans[1].opts.fps()) + } + // 输出名各自独立 + if plans[0].info.OutName == plans[1].info.OutName { + t.Errorf("两个任务的输出名撞了:%s", plans[0].info.OutName) + } + + // 单个任务(老写法)仍然只出一个方案 + one, err := buildMergePlans(mergeRequest{Videos: []string{v1}, Danmu: d1}) + if err != nil || len(one) != 1 { + t.Fatalf("单个任务应只出 1 个方案,实际 %d(err=%v)", len(one), err) + } +} + +// 排在后面的任务有问题时要连序号一起报出来,并且一个都不该开始 +func TestBuildMergePlansReportsBadIndex(t *testing.T) { + setupMergeTest(t) + v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场") + + _, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{ + {Videos: []string{v1}, Danmu: d1}, + {Videos: []string{v1}, Danmu: "不存在的弹幕.jsonl"}, + }}) + if err == nil { + t.Fatal("第二个任务的文件不存在,应当报错") + } + if !strings.Contains(err.Error(), "第 2 个任务") { + t.Errorf("报错里该带上序号,实际:%v", err) + } +} + +// 一次排太多要挡住,别让人不小心把几十场全排上 +func TestBuildMergePlansCapsCount(t *testing.T) { + setupMergeTest(t) + v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场") + jobs := make([]mergeJobReq, 0, maxMergeJobs+1) + for i := 0; i < maxMergeJobs+1; i++ { + jobs = append(jobs, mergeJobReq{Videos: []string{v1}, Danmu: d1}) + } + if _, err := buildMergePlans(mergeRequest{Jobs: jobs}); err == nil { + t.Errorf("超过 %d 个任务应当被挡住", maxMergeJobs) + } +} + +// 逐任务参数的兜底和校验 +func TestMergeOptionsFallbackAndValidate(t *testing.T) { + ff := testFFmpeg(t) + config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 2000, 30, 42 + defer func() { config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 0, 0, 0 }() + + var empty mergeOptions + if empty.bitrate() != 2000 || empty.fps() != 30 || empty.fontSize() != 42 { + t.Errorf("没填的项应回落到全局设置,实际 %d / %d / %d", + empty.bitrate(), empty.fps(), empty.fontSize()) + } + // 填 0 是有明确含义的,不能被当成「没填」 + zero := 0 + if (mergeOptions{Bitrate: &zero}).bitrate() != 0 { + t.Error("填了 0 就应当是 0(自动码率),不是全局设置的值") + } + if msg := (mergeOptions{Fps: &zero}).validate(ff); msg != "" { + t.Errorf("帧率 0(跟随源片)不该被挡:%s", msg) + } + if msg := (mergeOptions{Encoder: "auto"}).validate(ff); msg != "" { + t.Errorf("auto 表示自动挑,不该被挡:%s", msg) + } + bad := -1 + if msg := (mergeOptions{Bitrate: &bad}).validate(ff); msg == "" { + t.Error("负的码率应当被挡住") + } + over := maxMergeFps + 1 + if msg := (mergeOptions{Fps: &over}).validate(ff); msg == "" { + t.Error("超出上限的帧率应当被挡住") + } + if msg := (mergeOptions{Encoder: "av1_nvenc"}).validate(ff); msg == "" { + t.Error("本机没有的编码器应当被挡住") + } + // 本机可用的编码器要放行 + for _, e := range availableEncoders(ff) { + if msg := (mergeOptions{Encoder: e}).validate(ff); msg != "" { + t.Errorf("本机可用的 %s 不该被挡:%s", e, msg) + } + } +} diff --git a/notify_test.go b/notify_test.go index 4a3e5d7..666a180 100644 --- a/notify_test.go +++ b/notify_test.go @@ -727,7 +727,7 @@ func TestManualMergeSendsMergeDoneNotify(t *testing.T) { if err != nil { t.Fatalf("检查失败:%v", err) } - if err := runManualMerge(plan); err != nil { + if err := runManualMerge(plan, 1, 1); err != nil { t.Fatalf("手动合并失败:%v", err) } diff --git a/progress.go b/progress.go index b643cc4..fa39771 100644 --- a/progress.go +++ b/progress.go @@ -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, diff --git a/transcode.go b/transcode.go new file mode 100644 index 0000000..ba63466 --- /dev/null +++ b/transcode.go @@ -0,0 +1,1025 @@ +// web UI 的转码:把录好的视频按自己的需要重新压一遍 +// 合并是「录播 + 弹幕 → 成片」,转码是「已有视频 → 重新编码的视频」, +// 两者都要占同一个任务位(界面上的进度卡片只有一份),所以串行排队 +package main + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" +) + +// 码率控制模式 +const ( + rateModeSource = "source" // 跟随源片码率(体积基本不变) + rateModeBitrate = "bitrate" // 指定目标码率 + rateModeQuality = "quality" // 质量模式:libx264 用 CRF,QSV 用 ICQ + rateModePercent = "percent" // 源片码率的百分比 +) + +// 音频处理方式 +const ( + audioCopy = "copy" // 直接拷源音轨 + audioAAC128 = "aac128" // 重编码 AAC 128k + audioAAC192 = "aac192" // 重编码 AAC 192k + audioNone = "none" // 去掉音轨 +) + +// 编码速度预设。libx264 和 QSV 都认这一组,慢一点压得小一点 +var transcodePresets = []string{"veryfast", "faster", "fast", "medium", "slow", "slower"} + +const defaultTranscodePreset = "medium" + +// 分辨率缩放的下限,再小就看不清了 +const ( + minScaleHeight = 144 + maxScaleHeight = 4320 +) + +// 一次最多排多少个转码任务 +const maxTranscodeJobs = 50 + +// 码率上限(Kbps),转码和合并的逐任务码率都用它兜底。填个单位写错的数 +// (比如把 2000 写成 2000000000)不该原样丢给 ffmpeg,那只会换来一句看不懂的报错。 +// 200 Mbps 已经远超任何录播源 +const maxBitrateKbps = 200 * 1000 + +// 单个文件的转码参数 +type transcodeOptions struct { + Encoder string `json:"encoder"` // 空或 auto = 自动挑,否则是指定的 ffmpeg 编码器名 + Mode string `json:"mode"` // 码率控制模式,空 = 跟随源片 + Bitrate int `json:"bitrate"` // 目标码率(Kbps),mode=bitrate 时用 + Quality int `json:"quality"` // 质量值(CRF / ICQ),mode=quality 时用 + Percent int `json:"percent"` // 源片码率的百分比,mode=percent 时用 + Preset string `json:"preset"` // 编码速度预设,空 = medium + Fps int `json:"fps"` // 输出帧率,0 = 跟随源片 + Scale int `json:"scale"` // 目标高度(像素),0 = 原样 + Audio string `json:"audio"` // 音频处理,空 = 直接拷贝 +} + +// 一个待转码的文件 +type transcodeJobReq struct { + Dir string `json:"dir"` // record / merge + Name string `json:"name"` // 文件名 + OutName string `json:"outName"` // 输出文件名,留空自动 + Options transcodeOptions `json:"options"` +} + +// 探测到的源文件信息,页面用它显示原始参数和算预估体积 +type transcodeProbe struct { + Name string `json:"name"` + Dir string `json:"dir"` + Size int64 `json:"size"` + Duration float64 `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + Fps float64 `json:"fps"` + VideoKbps int `json:"videoKbps"` + AudioKbps int `json:"audioKbps"` + HasAudio bool `json:"hasAudio"` + HasVideo bool `json:"hasVideo"` // 有没有真正的视频轨(只有音轨/封面图的文件排了也转不了) + VideoCodec string `json:"videoCodec"` +} + +// 体积估算结果 +type transcodeEstimate struct { + Bytes int64 `json:"bytes"` // 预估产物大小(字节) + VideoKbps int `json:"videoKbps"` // 预估视频码率 + AudioKbps int `json:"audioKbps"` // 预估音频码率 + Exact bool `json:"exact"` // 是否为按目标码率算的准数 + Note string `json:"note"` // 不准时说明依据 +} + +// 转码任务的结果 +type transcodeResult struct { + Name string `json:"name"` + OutName string `json:"outName"` + Bytes int64 `json:"bytes"` + Err string `json:"err"` // 空表示成功 +} + +func (r transcodeResult) ok() bool { return r.Err == "" } + +// 转码队列的运行状态,页面轮询它决定按钮是否可用 +var transcodeRun struct { + sync.Mutex + busy bool + total int + done int + current string + results []transcodeResult +} + +// 转码队列的进度快照 +func transcodeQueueSnapshot() (busy bool, done, total int, current string, results []transcodeResult) { + transcodeRun.Lock() + defer transcodeRun.Unlock() + out := make([]transcodeResult, len(transcodeRun.results)) + copy(out, transcodeRun.results) + return transcodeRun.busy, transcodeRun.done, transcodeRun.total, transcodeRun.current, out +} + +// ==================== 参数校验 ==================== + +// 把没填的项补成默认值,并检查填了的项。返回给用户的错误说明(空字符串表示通过) +func (o transcodeOptions) normalize() transcodeOptions { + out := o + out.Encoder = strings.TrimSpace(out.Encoder) + out.Mode = strings.TrimSpace(out.Mode) + if out.Mode == "" { + out.Mode = rateModeSource + } + out.Preset = strings.ToLower(strings.TrimSpace(out.Preset)) + if out.Preset == "" { + out.Preset = defaultTranscodePreset + } + out.Audio = strings.ToLower(strings.TrimSpace(out.Audio)) + if out.Audio == "" { + out.Audio = audioCopy + } + return out +} + +func (o transcodeOptions) validate(ffmpegFile string) string { + n := o.normalize() + if n.Encoder != "" && n.Encoder != "auto" { + found := false + for _, e := range availableEncoders(ffmpegFile) { + if e == n.Encoder { + found = true + break + } + } + if !found { + return fmt.Sprintf("本机没有可用的编码器 %s,可用的是:%s", + n.Encoder, strings.Join(availableEncoders(ffmpegFile), "、")) + } + } + switch n.Mode { + case rateModeSource, rateModeBitrate, rateModeQuality, rateModePercent: + default: + return "码率控制模式只能是 source、bitrate、quality 或 percent" + } + if n.Mode == rateModeBitrate && (n.Bitrate <= 0 || n.Bitrate > maxBitrateKbps) { + return fmt.Sprintf("目标码率要在 1 到 %d(Kbps)之间", maxBitrateKbps) + } + if n.Mode == rateModeQuality && (n.Quality < 0 || n.Quality > 51) { + return "质量值只能在 0 到 51 之间(数字越小画质越好、体积越大)" + } + if n.Mode == rateModePercent && (n.Percent < 5 || n.Percent > 300) { + return "码率比例只能在 5% 到 300% 之间" + } + ok := false + for _, p := range transcodePresets { + if n.Preset == p { + ok = true + break + } + } + if !ok { + return "预设只能是 " + strings.Join(transcodePresets, "、") + } + if n.Fps < 0 || n.Fps > maxMergeFps { + return fmt.Sprintf("输出帧率只能在 0(跟随源片)到 %d 之间", maxMergeFps) + } + if n.Scale != 0 && (n.Scale < minScaleHeight || n.Scale > maxScaleHeight) { + return fmt.Sprintf("缩放高度只能在 %d 到 %d 之间,0 表示原样", minScaleHeight, maxScaleHeight) + } + switch n.Audio { + case audioCopy, audioAAC128, audioAAC192, audioNone: + default: + return "音频处理只能是 copy、aac128、aac192 或 none" + } + return "" +} + +// 音频码率(Kbps) +func audioKbpsOf(mode string, srcAudio int) int { + switch mode { + case audioAAC128: + return 128 + case audioAAC192: + return 192 + case audioNone: + return 0 + } + // 直接拷贝:探测不到源音轨码率时按 128 估 + if srcAudio > 0 { + return srcAudio + } + return defaultAudioKbps +} + +// 探测不到音轨码率时的兜底值 +const defaultAudioKbps = 128 + +// ==================== 体积估算 ==================== + +// 质量模式的码率系数:以 CRF 18 为基准(≈ 源片同画质),数值越大压得越狠。 +// 实测参考:x264 CRF 23 大约是 CRF 18 的六成体积 +var qualityFactors = []struct { + quality int + factor float64 +}{ + {0, 3.20}, {12, 1.90}, {16, 1.35}, {18, 1.00}, {20, 0.80}, + {22, 0.65}, {23, 0.58}, {25, 0.47}, {28, 0.36}, {32, 0.25}, {51, 0.10}, +} + +// 质量模式下的码率系数,表里的点之间线性插值 +func qualityFactor(q int) float64 { + if q <= qualityFactors[0].quality { + return qualityFactors[0].factor + } + last := qualityFactors[len(qualityFactors)-1] + if q >= last.quality { + return last.factor + } + for i := 1; i < len(qualityFactors); i++ { + b := qualityFactors[i] + if q > b.quality { + continue + } + a := qualityFactors[i-1] + return a.factor + (b.factor-a.factor)*float64(q-a.quality)/float64(b.quality-a.quality) + } + return 1 +} + +// HEVC 相对 H.264 的码率系数:同画质大约能省三到四成 +const hevcRateFactor = 0.65 + +// 源片视频码率(不含音频)。三级兜底: +// 1. 视频流自己写了码率,直接用; +// 2. 只有容器总码率(ffmpeg 对 mp4 一般只打这个,里面含音频),扣掉音轨; +// 3. 都没有就按「文件体积 ÷ 时长」倒推,再扣掉音轨。 +// +// 三级都用不上返回 0,调用方据此退回质量模式。 +// +// 为什么要分得这么细:容器总码率是含音频的,直接当成视频码率用会有两处错 —— +// 预估体积时又加了一遍音轨(算成双份),「跟随源片」时也把音轨的码率也算进视频里。 +func videoKbpsOf(m mediaInfo, size int64) int { + if m.videoKbps > 0 { + return m.videoKbps + } + if m.containerKbps > 0 { + if v := m.containerKbps - audioPartOf(m); v > 0 { + return v + } + } + if m.duration <= 0 || size <= 0 { + return 0 + } + total := int(float64(size) * 8 / 1000 / m.duration) + v := total - audioPartOf(m) + if v < 100 { + return 0 + } + return v +} + +// 容器总码率里音频占的那部分。没有音轨就是 0,有音轨但探测不到码率时给个经验值 +func audioPartOf(m mediaInfo) int { + if !m.hasAudio { + return 0 + } + if m.audioKbps > 0 { + return m.audioKbps + } + return defaultAudioKbps +} + +// 分辨率缩放带来的码率变化:像素数按比例缩放 +func scaleRateFactor(src transcodeProbe, targetH int) float64 { + if targetH <= 0 || src.Height <= 0 { + return 1 + } + if targetH >= src.Height { + return 1 + } + r := float64(targetH) / float64(src.Height) + return r * r +} + +// 估算转码产物的大小。目标码率模式算出来的是准数,质量模式只能给个粗估 +func estimateTranscode(o transcodeOptions, p transcodeProbe, enc string) transcodeEstimate { + n := o.normalize() + srcV := p.VideoKbps // 调用方已用 videoKbpsOf 填好,这里不再自己兜底 + + var vKbps float64 + exact := true + note := "" + switch n.Mode { + case rateModeBitrate: + vKbps = float64(n.Bitrate) + case rateModePercent: + vKbps = float64(srcV) * float64(n.Percent) / 100 + case rateModeQuality: + vKbps = float64(srcV) * qualityFactor(n.Quality) + if isHEVCCodec(enc) { + vKbps *= hevcRateFactor + } + exact = false + note = "质量模式的体积由画面内容决定,这里是粗估" + default: // 跟随源片 + vKbps = float64(srcV) + if isHEVCCodec(enc) { + vKbps *= hevcRateFactor + exact = false + note = "同码率下 HEVC 一般比 H.264 省三到四成,实际看画面" + } + } + // 缩放后按像素比例再调一次(目标码率 / 比例模式已经体现过意图的除外) + if n.Mode != rateModeBitrate && n.Mode != rateModePercent { + vKbps *= scaleRateFactor(p, n.Scale) + } + // 「跟随源片 / 按比例」都靠源片码率,探不到时实际会退回质量模式, + // 别让界面显示一个看着很准其实落不了地的数 + if srcV <= 0 && (n.Mode == rateModeSource || n.Mode == rateModePercent) { + exact = false + note = "源片码率探测不到,实际会退回质量模式" + } + vKbps = math.Round(vKbps) + aKbps := audioKbpsOf(n.Audio, p.AudioKbps) + // 源片本来就没有音轨时,拷贝也拷不出音轨来,别凭空加一份音频码率 + if !p.HasAudio && n.Audio == audioCopy { + aKbps = 0 + } + + bytes := int64(0) + if p.Duration > 0 { + bytes = int64((vKbps + float64(aKbps)) * 1000 / 8 * p.Duration) + } + return transcodeEstimate{ + Bytes: bytes, + VideoKbps: int(vKbps), + AudioKbps: aKbps, + Exact: exact, + Note: note, + } +} + +// ==================== 探测 ==================== +// +// 这里是一次探测拿全(时长 / 分辨率 / 帧率 / 视频码率 / 音轨)的底层实现, +// 合并那边的 probeDuration 与 probeVideoStream 也走它 —— 同一段 ffmpeg 输出解析一遍就够了, +// 之前为了拿时长和视频流各起一次 ffmpeg,等于白跑一个进程。 + +// 媒体文件的关键参数 +type mediaInfo struct { + duration float64 + width int + height int + fps float64 + videoKbps int // 视频流自己写的码率(Kbps),0 表示流里没写 + // 容器总码率(Kbps,含音频)。mp4 这类容器 ffmpeg 经常只在 Duration 行打总码率, + // 这时它是视频流的唯一线索,但不能直接当视频码率用(见 videoKbpsOf) + containerKbps int + codec string + audioKbps int + hasAudio bool + hasVideo bool // 有真正的视频轨(封面图 attached pic 不算) +} + +// 探测一个媒体文件。探测不到时长但能拿到画面信息时也算成功(有些流没有 Duration) +func probeMedia(ffmpegFile, path string) mediaInfo { + cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path) + var buf strings.Builder + cmd.Stderr = &buf + // 没指定输出文件时 ffmpeg 返回非 0,但流信息已经打印出来了 + _ = cmd.Run() + return parseMediaInfo(buf.String()) +} + +// 同一个文件在短时间内被反复探测是常态:体积估算跟着输入框重发、合并里先后问时长和视频流。 +// 每次都起一个 ffmpeg 进程的话,光排队就能把 CPU 占住,所以按「ffmpeg + 路径 + 体积 + 修改时间」 +// 记住上一次的结果。文件一变(录制中追加、换了个文件)体积或修改时间就变了,自然会重新探测。 +var probeCache struct { + sync.Mutex + m map[string]mediaInfo +} + +// 缓存上限,超过就整张丢掉重来(探测量本身很小,够用就行) +const probeCacheMax = 128 + +func probeMediaCached(ffmpegFile, path string) mediaInfo { + st, err := os.Stat(path) + if err != nil { + // 拿不到体积和修改时间就没法判断文件有没有变,老老实实探一次 + return probeMedia(ffmpegFile, path) + } + key := ffmpegFile + "\x00" + path + "\x00" + + strconv.FormatInt(st.Size(), 10) + "\x00" + strconv.FormatInt(st.ModTime().UnixNano(), 10) + + probeCache.Lock() + if m, ok := probeCache.m[key]; ok { + probeCache.Unlock() + return m + } + probeCache.Unlock() + + m := probeMedia(ffmpegFile, path) + // 什么都没探到的不缓存:那多半是这次读文件失败或 ffmpeg 忙, + // 记下来会让后面一直用这份空结论 + if m.duration > 0 || m.width > 0 { + probeCache.Lock() + if probeCache.m == nil || len(probeCache.m) >= probeCacheMax { + probeCache.m = make(map[string]mediaInfo) + } + probeCache.m[key] = m + probeCache.Unlock() + } + return m +} + +// 解析 ffmpeg -i 的输出。视频、音频、容器的信息在同一段文本里,一次解析完 +func parseMediaInfo(out string) mediaInfo { + var info mediaInfo + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "Duration:") { + if m := durationRe.FindStringSubmatch(line); len(m) == 4 { + h, _ := strconv.Atoi(m[1]) + mi, _ := strconv.Atoi(m[2]) + s, _ := strconv.ParseFloat(m[3], 64) + info.duration = float64(h)*3600 + float64(mi)*60 + s + } + // 容器总码率(含音频),视频流自己没写码率时用它 + if m := containerBitrateRe.FindStringSubmatch(line); len(m) == 2 { + info.containerKbps, _ = strconv.Atoi(m[1]) + } + continue + } + // 封面图(mjpeg 之类的 attached pic)也是一条 Video 流,但它不是画面, + // 拿它的分辨率当画布会把弹幕层算错,所以整条跳过 + if strings.Contains(line, "Video:") && !strings.Contains(line, "attached pic") { + info.hasVideo = true + 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.videoKbps, _ = strconv.Atoi(m[1]) + } + if m := videoFpsRe.FindStringSubmatch(line); len(m) == 2 { + info.fps, _ = strconv.ParseFloat(m[1], 64) + } + if m := videoCodecRe.FindStringSubmatch(line); len(m) == 2 { + info.codec = m[1] + } + continue + } + if strings.Contains(line, "Audio:") { + info.hasAudio = true + if m := audioBitrateRe.FindStringSubmatch(line); len(m) == 2 { + info.audioKbps, _ = strconv.Atoi(m[1]) + } + } + } + return info +} + +// 视频流自己没写码率时退回容器总码率(含音频)。 +// 合并那边的自动码率一直按这个口径算(「源片码率 + 弹幕余量」),别改成扣掉音频的版本, +// 否则所有自动合并的成片都会莫名变小一截 +func streamOrContainerKbps(m mediaInfo) int { + if m.videoKbps > 0 { + return m.videoKbps + } + return m.containerKbps +} + +var ( + // 「Stream #0:1(und): Video: h264 (High) ...」里的编码器名 + videoCodecRe = regexp.MustCompile(`Video: ([a-z0-9_]+)`) + // 「Stream #0:1: Audio: aac (LC) ... 192 kb/s」 + audioBitrateRe = regexp.MustCompile(`Audio:.*? (\d+) kb/s`) + // 逗号后紧跟的「数字x数字」就是分辨率,前面的编码器名里也有 0x… 这种十六进制,靠逗号区分 + videoSizeRe = regexp.MustCompile(`, (\d{2,5})x(\d{2,5})[, \[]`) + // 视频流自己的码率,形如「1920x1080 [SAR 1:1 DAR 16:9], 331 kb/s, 30 fps」 + 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`) + // 时长,形如「Duration: 00:02:55.07」 + durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`) +) + +// 日志里打印的体积,例如 1.2 GB +func formatByteSize(n int64) string { + if n <= 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB", "TB"} + v := float64(n) + i := 0 + for v >= 1024 && i < len(units)-1 { + v /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf("%d B", n) + } + return fmt.Sprintf("%.1f %s", v, units[i]) +} + +// 转码任务的输入必须来自文件管理里的两个文件夹,和手动合并一致 +func resolveTranscodeInput(dir, name string) (string, error) { + full, err := resolveFile(dir, name) + if err != nil { + return "", err + } + if _, ok := mergeVideoExts[strings.ToLower(filepath.Ext(full))]; !ok { + return "", fmt.Errorf("不支持的视频格式 %s,支持 %s", filepath.Ext(full), extListText(mergeVideoExts)) + } + return full, nil +} + +// ==================== ffmpeg 命令 ==================== + +// 这一帧用哪个编码器:指定了就用指定的,否则按本机可用性自动挑 +func pickTranscodeEncoder(o transcodeOptions, ffmpegFile string) string { + if enc := strings.TrimSpace(o.Encoder); enc != "" && enc != "auto" { + return enc + } + avail := availableEncoders(ffmpegFile) + if len(avail) == 0 { + return "libx264" + } + return avail[0] +} + +// 本次转码的视频码率(Kbps),0 表示交给编码器自己按质量模式决定。 +// src.videoKbps 由 runTranscode 用 videoKbpsOf 填好;填不上(探不到源片码率)时 +// 「按比例」和「跟随源片」都只能返回 0,于是落到质量模式 —— 这件事要在日志里说出来 +func transcodeVideoKbps(o transcodeOptions, src mediaInfo) int { + n := o.normalize() + switch n.Mode { + case rateModeBitrate: + return n.Bitrate + case rateModePercent: + return int(math.Round(float64(src.videoKbps) * float64(n.Percent) / 100)) + case rateModeQuality: + return 0 + } + return src.videoKbps +} + +// 质量模式没填数值时的默认值:QSV 用 ICQ,软编用 CRF +func defaultQuality(enc string) int { + if strings.HasSuffix(enc, "_qsv") { + return 22 + } + return 23 +} + +// 拼 ffmpeg 的参数。返回参数、实际用的编码器和输出帧率 +func transcodeArgs(ffmpegFile, in, out string, o transcodeOptions, src mediaInfo) ([]string, string, int) { + n := o.normalize() + enc := pickTranscodeEncoder(n, ffmpegFile) + + args := []string{"-hide_banner", "-y", "-i", in} + + // 缩放和帧率都走 -vf:缩放按高度算宽度(-2 保证是偶数), + // 帧率这里允许往下降(转码就是要压体积,降帧是用户的明确选择) + var filters []string + scaleH := n.Scale + if scaleH > 0 && src.height > 0 && scaleH >= src.height { + scaleH = 0 // 不放大小图,放大只会白白增加体积 + } + if scaleH > 0 { + filters = append(filters, fmt.Sprintf("scale=-2:%d", scaleH)) + } + outFps := 0 + if n.Fps > 0 && (src.fps <= 0 || math.Abs(float64(n.Fps)-src.fps) > 0.01) { + outFps = n.Fps + filters = append(filters, fmt.Sprintf("fps=%d", outFps)) + } + if len(filters) > 0 { + args = append(args, "-vf", strings.Join(filters, ",")) + } + + args = append(args, "-map", "0:v:0", "-c:v", enc) + if vbr := transcodeVideoKbps(n, src); vbr > 0 { + args = append(args, "-b:v", strconv.Itoa(vbr)+"k") + } else { + // 质量模式。没填数值时按编码器给个常用默认:QSV 用 ICQ,软编用 CRF + q := n.Quality + if q <= 0 { + q = defaultQuality(enc) + } + if strings.HasSuffix(enc, "_qsv") { + args = append(args, "-global_quality", strconv.Itoa(q)) + } else { + args = append(args, "-crf", strconv.Itoa(q)) + } + } + args = append(args, "-preset", n.Preset) + // HEVC 在 mp4 里默认写成 hev1,Apple 系不认,统一改 hvc1 + if isHEVCCodec(enc) { + args = append(args, "-tag:v", "hvc1") + } + + switch n.Audio { + case audioNone: + args = append(args, "-an") + case audioAAC128: + args = append(args, "-map", "0:a?", "-c:a", "aac", "-b:a", "128k") + case audioAAC192: + args = append(args, "-map", "0:a?", "-c:a", "aac", "-b:a", "192k") + default: + args = append(args, "-map", "0:a?", "-c:a", "copy") + } + args = append(args, "-movflags", "+faststart", out) + return args, enc, outFps +} + +// ==================== 执行 ==================== + +// 转码一个文件,返回实际落地的成片文件名。idx / total 是队列里的位置(单任务传 1,1) +func runTranscode(job transcodeJobReq, outDir string, idx, total int) (string, error) { + ffmpegFile := getFFmpeg() + if ffmpegFile == "" { + return "", errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径") + } + in, err := resolveTranscodeInput(job.Dir, job.Name) + if err != nil { + return "", err + } + if isFileActive(in) { + return "", fmt.Errorf("%s 正在录制或写入中,等它结束再转码", filepath.Base(in)) + } + info, err := os.Stat(in) + if err != nil { + return "", fmt.Errorf("读不到文件信息:%s", filepath.Base(in)) + } + + src := probeMediaCached(ffmpegFile, in) + if src.duration <= 0 { + return "", fmt.Errorf("读不出 %s 的时长,没法预估进度", filepath.Base(in)) + } + if !src.hasVideo { + return "", fmt.Errorf("%s 里没有视频轨(只有音轨或封面图),没法转码", filepath.Base(in)) + } + // 源片视频码率探到一次就固定下来:预估体积和实际拼出来的 -b:v 必须用同一个数 + // (这里填不上时「按比例 / 跟随源片」会退回质量模式,日志里会说一声) + src.videoKbps = videoKbpsOf(src, info.Size()) + + enc := pickTranscodeEncoder(job.Options, ffmpegFile) + // 默认名:`原名_转码.mp4`。用户没改名字时自动避开重名,改了就按他填的来 + base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in)) + autoName := base + "_转码.mp4" + outName := strings.TrimSpace(job.OutName) + if outName == "" || outName == autoName { + outName = filepath.Base(freeOutPath(outDir, base+"_转码")) + } else { + clean, err := sanitizeOutName(outName) + if err != nil { + return "", err + } + outName = clean + } + out := filepath.Join(outDir, outName) + + if !jobStart(jobKindTranscode, outName, info.Size(), src.duration) { + return "", errJobNotStarted + } + if total > 0 { + jobQueue(idx, total) + } + // 转码的目标码率是明确的,先把预估体积登记上,界面在 ffmpeg 出数之前就有东西看 + probe := transcodeProbe{ + Name: filepath.Base(in), Size: info.Size(), Duration: src.duration, + Width: src.width, Height: src.height, Fps: src.fps, + VideoKbps: src.videoKbps, AudioKbps: src.audioKbps, HasAudio: src.hasAudio, + VideoCodec: src.codec, + } + jobPlanSize(estimateTranscode(job.Options, probe, enc).Bytes) + + // 先在本地(源文件所在目录)转,完成后再落到输出目录: + // 输出目录可能是网络盘或外接盘,直接往里写、写到一半失败会留个半成品 + tmpOut := tempMergeOutput(in, filepath.Base(out)) + args, enc, outFps := transcodeArgs(ffmpegFile, in, tmpOut, job.Options, src) + + tag := "" + if total > 1 { + tag = fmt.Sprintf("(第 %d/%d 个)", idx, total) + } + // 「跟随源片」和「按比例」都要用源片码率,探不到就只能落到质量模式,日志里说清楚, + // 不然用户会以为按 60% 压的、结果成片体积对不上 + if m := job.Options.normalize().Mode; (m == rateModeSource || m == rateModePercent) && transcodeVideoKbps(job.Options, src) <= 0 { + lPrintWarn("源片码率探测不到,「跟随源片 / 按比例」这次改用质量模式(CRF / ICQ)") + } + lPrintf("转码开始%s:%s → %s,编码器 %s,%dx%d → %s,%s", + tag, filepath.Base(in), outName, enc, src.width, src.height, + transcodeBrief(job.Options, src, outFps), formatByteSize(info.Size())) + lPrintf("先在本地生成 %s,完成后落到 %s", filepath.Base(tmpOut), out) + + jobSetPhase(jobPhaseTranscode) + markFileActive(tmpOut, true) + err = runFFmpegWith(ffmpegFile, args, filepath.Dir(in), func(outSec, speed, fps float64, size int64) { + jobRender(outSec, speed, fps, size) + }) + markFileActive(tmpOut, false) + if err != nil { + _ = os.Remove(tmpOut) + if jobStopRequested() { + return "", errJobStopped + } + return "", fmt.Errorf("ffmpeg 运行失败:%v", err) + } + + jobSetPhase(len(transcodePhasePlan.names) - 1) + if err := placeOutput(tmpOut, out); err != nil { + _ = os.Remove(tmpOut) + return "", err + } + if _, err := os.Stat(out); err == nil { + jobRender(src.duration, 0, 0, fileSizeOrZero(out)) + } + lPrintln("转码完成" + tag + ":" + out) + return outName, nil +} + +// 日志里的一句参数摘要,方便事后对照「这一个是按什么压的」 +func transcodeBrief(o transcodeOptions, src mediaInfo, outFps int) string { + n := o.normalize() + parts := []string{} + switch n.Mode { + case rateModeBitrate: + parts = append(parts, fmt.Sprintf("目标码率 %d kbps", n.Bitrate)) + case rateModePercent: + parts = append(parts, fmt.Sprintf("源片码率的 %d%%", n.Percent)) + case rateModeQuality: + parts = append(parts, fmt.Sprintf("质量值 %d", n.Quality)) + default: + parts = append(parts, "跟随源片码率") + } + if outFps > 0 { + parts = append(parts, fmt.Sprintf("%d fps(源片 %.2f)", outFps, src.fps)) + } + if n.Scale > 0 && src.height > 0 && n.Scale < src.height { + parts = append(parts, fmt.Sprintf("缩放到 %dp", n.Scale)) + } + if n.Audio != audioCopy { + parts = append(parts, "音频 "+n.Audio) + } + parts = append(parts, "预设 "+n.Preset) + return strings.Join(parts, ",") +} + +// ==================== 接口 ==================== + +// POST /api/transcode/probe:批量探测源文件的参数,顺便把本机可用的编码器带回去 +func transcodeProbeHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Files []transcodeJobReq `json:"files"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if len(req.Files) == 0 { + http.Error(w, "请先选择要转码的文件", http.StatusBadRequest) + return + } + if len(req.Files) > maxTranscodeJobs { + http.Error(w, fmt.Sprintf("一次最多选 %d 个文件", maxTranscodeJobs), http.StatusBadRequest) + return + } + ffmpegFile := getFFmpeg() + if ffmpegFile == "" { + http.Error(w, "找不到 ffmpeg,请先在设置里填写 ffmpeg 路径", http.StatusBadRequest) + return + } + + out := make([]transcodeProbe, 0, len(req.Files)) + for _, f := range req.Files { + full, err := resolveTranscodeInput(f.Dir, f.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + st, err := os.Stat(full) + if err != nil { + http.Error(w, "读不到文件信息:"+filepath.Base(full), http.StatusBadRequest) + return + } + m := probeMediaCached(ffmpegFile, full) + out = append(out, transcodeProbe{ + Name: filepath.Base(full), Dir: f.Dir, Size: st.Size(), + Duration: m.duration, Width: m.width, Height: m.height, Fps: m.fps, + // 视频码率在这里就扣掉音轨:后面预估体积还要单独加一次音频, + // 直接用容器总码率会把音轨算成两份 + VideoKbps: videoKbpsOf(m, st.Size()), AudioKbps: m.audioKbps, HasAudio: m.hasAudio, + HasVideo: m.hasVideo, VideoCodec: m.codec, + }) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "files": out, + "encoders": availableEncoders(ffmpegFile), + "outDir": absPath(mergeSaveDir()), + }); err != nil { + lPrintErrf("输出转码探测结果失败:%v", err) + } +} + +// POST /api/transcode/estimate:按当前参数估算每个文件的产物大小 +func transcodeEstimateHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Jobs []transcodeJobReq `json:"jobs"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if len(req.Jobs) > maxTranscodeJobs { + http.Error(w, fmt.Sprintf("一次最多估 %d 个文件", maxTranscodeJobs), http.StatusBadRequest) + return + } + ffmpegFile := getFFmpeg() + + out := make([]transcodeEstimate, 0, len(req.Jobs)) + for _, j := range req.Jobs { + if msg := j.Options.validate(ffmpegFile); msg != "" { + http.Error(w, j.Name+":"+msg, http.StatusBadRequest) + return + } + full, err := resolveTranscodeInput(j.Dir, j.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + st, err := os.Stat(full) + if err != nil { + http.Error(w, "读不到文件信息:"+filepath.Base(full), http.StatusBadRequest) + return + } + m := probeMediaCached(ffmpegFile, full) + p := transcodeProbe{ + Name: filepath.Base(full), Size: st.Size(), Duration: m.duration, + Width: m.width, Height: m.height, Fps: m.fps, + VideoKbps: videoKbpsOf(m, st.Size()), AudioKbps: m.audioKbps, HasAudio: m.hasAudio, + } + out = append(out, estimateTranscode(j.Options, p, pickTranscodeEncoder(j.Options, ffmpegFile))) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{"jobs": out}); err != nil { + lPrintErrf("输出转码体积估算失败:%v", err) + } +} + +// POST /api/transcode:开始转码,立刻返回,编码在后台串行进行 +func transcodeHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + OutDir string `json:"outDir"` // 留空用成片目录 + Jobs []transcodeJobReq `json:"jobs"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) + return + } + if len(req.Jobs) == 0 { + http.Error(w, "请先选择要转码的文件", http.StatusBadRequest) + return + } + if len(req.Jobs) > maxTranscodeJobs { + http.Error(w, fmt.Sprintf("一次最多排 %d 个转码任务", maxTranscodeJobs), http.StatusBadRequest) + return + } + ffmpegFile := getFFmpeg() + if ffmpegFile == "" { + http.Error(w, "找不到 ffmpeg,请先在设置里填写 ffmpeg 路径", http.StatusBadRequest) + return + } + // 逐个检查参数和源文件,有问题的在开跑之前就报出来,别跑到第三个才失败。 + // 探测走缓存,刚才「加入列表」和「估算体积」时已经探过,这里不会再多起 ffmpeg + for _, j := range req.Jobs { + if msg := j.Options.validate(ffmpegFile); msg != "" { + http.Error(w, j.Name+":"+msg, http.StatusBadRequest) + return + } + full, err := resolveTranscodeInput(j.Dir, j.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m := probeMediaCached(ffmpegFile, full) + if isFileActive(full) { + http.Error(w, filepath.Base(full)+" 正在录制或写入中,等它结束再转码", http.StatusBadRequest) + return + } + if !m.hasVideo { + http.Error(w, filepath.Base(full)+" 里没有视频轨(只有音轨或封面图),没法转码", http.StatusBadRequest) + return + } + if m.duration <= 0 { + http.Error(w, "读不出 "+filepath.Base(full)+" 的时长,没法预估进度", http.StatusBadRequest) + return + } + } + + outDir := strings.TrimSpace(req.OutDir) + if outDir == "" { + outDir = mergeSaveDir() + } else { + if msg := checkWebDir(outDir, "输出目录"); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + // 归一成绝对路径:相对路径会按进程的工作目录解析,写进日志和结果里都容易看错 + outDir = absPath(outDir) + } + + transcodeRun.Lock() + if transcodeRun.busy { + transcodeRun.Unlock() + http.Error(w, "已经有一个转码队列在跑,等它结束再说", http.StatusConflict) + return + } + // 进度卡片只有一份,合并也可能占着它 + if !tryTakeMergeSlot() { + transcodeRun.Unlock() + busy := jobBusyName() + if busy == "" { + busy = "另一个任务" + } + http.Error(w, "已经有一个任务在进行("+busy+"),等它结束再说", http.StatusConflict) + return + } + transcodeRun.busy = true + transcodeRun.total = len(req.Jobs) + transcodeRun.done = 0 + transcodeRun.current = req.Jobs[0].Name + transcodeRun.results = nil + transcodeRun.Unlock() + + total := len(req.Jobs) + safeGo("转码", func() { + defer releaseMergeSlot() + defer func() { + transcodeRun.Lock() + transcodeRun.busy = false + transcodeRun.current = "" + transcodeRun.Unlock() + }() + for i, j := range req.Jobs { + transcodeRun.Lock() + transcodeRun.current = j.Name + transcodeRun.done = i + transcodeRun.Unlock() + + outName, err := runTranscode(j, outDir, i+1, total) + // 这次没起成任务(任务位被别人占着)就别收尾,否则会把别人的进度清掉 + if err != errJobNotStarted { + jobFinish(jobErrText(err)) + } + res := transcodeResult{Name: j.Name} + if err == nil { + res.OutName = filepath.Base(outName) + res.Bytes = fileSizeOrZero(filepath.Join(outDir, outName)) + } else { + res.Err = err.Error() + if err == errJobStopped { + lPrintWarn("转码已被手动停止,剩下的文件不再处理") + } else if err != errJobNotStarted { + lPrintErrf("转码失败:%v", err) + } + } + transcodeRun.Lock() + transcodeRun.results = append(transcodeRun.results, res) + transcodeRun.Unlock() + if err == errJobStopped { + break + } + } + transcodeRun.Lock() + transcodeRun.done = len(transcodeRun.results) + ok, failed := 0, 0 + for _, r := range transcodeRun.results { + if r.ok() { + ok++ + } else { + failed++ + } + } + transcodeRun.Unlock() + if total > 1 { + lPrintf("转码队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed) + } + }) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) + `}`)) +} diff --git a/transcode_test.go b/transcode_test.go new file mode 100644 index 0000000..31fe5e7 --- /dev/null +++ b/transcode_test.go @@ -0,0 +1,513 @@ +package main + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ==================== 参数校验 ==================== + +// 没填的项要补成默认值,不然拼出来的 ffmpeg 命令会带着一堆空参数 +func TestTranscodeOptionsNormalize(t *testing.T) { + o := transcodeOptions{} + n := o.normalize() + if n.Mode != rateModeSource { + t.Errorf("模式空时应按「跟随源片」,实际 %q", n.Mode) + } + if n.Preset != defaultTranscodePreset { + t.Errorf("预设空时应取 %q,实际 %q", defaultTranscodePreset, n.Preset) + } + if n.Audio != audioCopy { + t.Errorf("音频空时应按直接拷贝,实际 %q", n.Audio) + } +} + +// 各种填错的情况都要在开跑之前挡住,别等 ffmpeg 报一句看不懂的错 +func TestTranscodeOptionsValidate(t *testing.T) { + ff := testFFmpeg(t) + bad := map[string]transcodeOptions{ + "模式不认识": {Mode: "xyz"}, + "选了码率却没填": {Mode: rateModeBitrate}, + "质量值越界": {Mode: rateModeQuality, Quality: 99}, + "比例越界": {Mode: rateModePercent, Percent: 1000}, + "码率上界越界": {Mode: rateModeBitrate, Bitrate: maxBitrateKbps + 1}, + "预设不认识": {Preset: "ultrafast"}, + "帧率越界": {Fps: maxMergeFps + 1}, + "缩放高度太小": {Scale: 10}, + "音频处理不认识": {Audio: "mp3"}, + "编码器本机没有": {Encoder: "av1_nvenc"}, + } + for name, o := range bad { + if msg := o.validate(ff); msg == "" { + t.Errorf("%s:应当被挡住,实际通过了", name) + } + } + // 正常参数要能过 + ok := transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Preset: "fast", Fps: 30, Scale: 720, Audio: audioAAC128} + if msg := ok.validate(ff); msg != "" { + t.Errorf("正常参数不该被挡:%s", msg) + } + if msg := (transcodeOptions{Encoder: "auto"}).validate(ff); msg != "" { + t.Errorf("auto 表示自动挑,不该被挡:%s", msg) + } +} + +// ==================== 体积估算 ==================== + +// 目标码率模式的体积是能算准的:码率 × 时长 +func TestEstimateTranscodeBitrateMode(t *testing.T) { + p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128} + got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "h264_qsv") + // 视频 2000 + 音频 128 = 2128 kbps × 100 秒 = 26.6 MB + want := int64((2000 + 128) * 1000 / 8 * 100) + if got.Bytes != want { + t.Errorf("预估体积应为 %d,实际 %d", want, got.Bytes) + } + if !got.Exact { + t.Error("目标码率模式算出来的是准数,不该标成粗估") + } + if got.VideoKbps != 2000 || got.AudioKbps != 128 { + t.Errorf("预估码率不对:视频 %d、音频 %d", got.VideoKbps, got.AudioKbps) + } +} + +// 按比例模式:60% 就是源片的六成 +func TestEstimateTranscodePercentMode(t *testing.T) { + p := transcodeProbe{Duration: 60, VideoKbps: 5000} + got := estimateTranscode(transcodeOptions{Mode: rateModePercent, Percent: 60, Audio: audioNone}, p, "libx264") + want := int64(3000 * 1000 / 8 * 60) + if got.Bytes != want { + t.Errorf("60%% 的源片码率应为 %d,实际 %d", want, got.Bytes) + } + if got.AudioKbps != 0 { + t.Errorf("去掉音轨后音频码率应为 0,实际 %d", got.AudioKbps) + } +} + +// 质量模式只能粗估:数值越大体积越小,且要标成粗估 +func TestEstimateTranscodeQualityMode(t *testing.T) { + p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128} + low := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 18}, p, "libx264") + high := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 28}, p, "libx264") + if low.Bytes <= high.Bytes { + t.Errorf("质量值 18(%d 字节)应比 28(%d 字节)体积大", low.Bytes, high.Bytes) + } + if low.Exact || high.Exact { + t.Error("质量模式只能粗估,不该标成准数") + } + if high.Note == "" { + t.Error("粗估要给出依据说明,界面上要能看出来这不是准数") + } +} + +// 质量值的系数表要单调:数值越大系数越小 +func TestQualityFactorMonotonic(t *testing.T) { + prev := math.Inf(1) + for q := 0; q <= 51; q++ { + f := qualityFactor(q) + if f > prev+1e-9 { + t.Fatalf("质量值 %d 的系数 %v 比上一个还大,压得越狠体积越大了", q, f) + } + if f <= 0 { + t.Fatalf("质量值 %d 的系数不该是 %v", q, f) + } + prev = f + } + // 表上的点要精确命中 + if got := qualityFactor(18); math.Abs(got-1.0) > 1e-9 { + t.Errorf("CRF 18 是基准,系数应为 1,实际 %v", got) + } +} + +// 缩到更低分辨率后,同样的码率要求下产物应更小 +func TestEstimateTranscodeScalingShrinks(t *testing.T) { + p := transcodeProbe{Duration: 100, VideoKbps: 4000, Height: 1080} + full := estimateTranscode(transcodeOptions{Mode: rateModeSource}, p, "libx264") + half := estimateTranscode(transcodeOptions{Mode: rateModeSource, Scale: 540}, p, "libx264") + if half.Bytes >= full.Bytes { + t.Errorf("缩到 540p(%d)应比原样(%d)小", half.Bytes, full.Bytes) + } + // 按面积算:540/1080 = 0.5,码率约为四分之一 + if half.VideoKbps > full.VideoKbps/3 { + t.Errorf("高度减半后码率应降到约四分之一,实际 %d → %d", full.VideoKbps, half.VideoKbps) + } +} + +// 源片没有音轨时别凭空加一份音频码率(拷贝模式拷不出音轨来) +func TestEstimateTranscodeNoAudioTrack(t *testing.T) { + // HasAudio 没置位 = 探测不到音轨 + p := transcodeProbe{Duration: 100, VideoKbps: 4000} + got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioCopy}, p, "libx264") + want := int64(2000 * 1000 / 8 * 100) + if got.Bytes != want { + t.Errorf("没有音轨时不该算音频:应 %d,实际 %d(音频 %d kbps)", want, got.Bytes, got.AudioKbps) + } + if got.AudioKbps != 0 { + t.Errorf("没有音轨时音频码率应为 0,实际 %d", got.AudioKbps) + } + // 显式要求重编码音频的话仍然按目标算(源片没有也只是多加一条空轨,不至于算错体积量级) + got = estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "libx264") + if got.AudioKbps != 128 { + t.Errorf("显式重编码 128k 时应按 128 算,实际 %d", got.AudioKbps) + } +} + +// 探测不到视频码率时按「体积 ÷ 时长」倒推,不能算成 0(那会显示成「—」) +func TestVideoKbpsFallsBackToFileSize(t *testing.T) { + // 100 秒、50 MB 的文件 → 约 4000 kbps,扣掉 128k 音轨 + m := mediaInfo{duration: 100, hasAudio: true, audioKbps: 128} + got := videoKbpsOf(m, 50*1024*1024) + if got < 3000 || got > 4500 { + t.Errorf("倒推的源片码率应在 3000~4500 之间,实际 %d", got) + } +} + +// 容器总码率含音频,不能直接当视频码率:算体积时会和音频重复计一遍, +// 「跟随源片」时也会把音轨的码率算进视频里 +func TestVideoKbpsDropsAudioFromContainer(t *testing.T) { + m := mediaInfo{containerKbps: 3000, hasAudio: true, audioKbps: 192} + if got := videoKbpsOf(m, 0); got != 2808 { + t.Errorf("容器 3000 扣掉 192 音轨应为 2808,实际 %d", got) + } + // 视频流自己写了码率就以它为准,不再动 + m.videoKbps = 2600 + if got := videoKbpsOf(m, 0); got != 2600 { + t.Errorf("视频流自己写了码率就该用它,实际 %d", got) + } + // 没有音轨时不扣 + m = mediaInfo{containerKbps: 3000} + if got := videoKbpsOf(m, 0); got != 3000 { + t.Errorf("没有音轨时不扣,实际 %d", got) + } + // 音轨码率探测不到时按经验值扣,别一点不扣 + m = mediaInfo{containerKbps: 3000, hasAudio: true} + if got := videoKbpsOf(m, 0); got != 3000-defaultAudioKbps { + t.Errorf("音轨码率未知时应按 %d 扣,实际 %d", defaultAudioKbps, got) + } +} + +// 合并那边的自动码率一直按「视频流码率,没有就用容器总码率(含音频)」算, +// 改口径会让所有自动合并的成片都变小一截,这里锁住 +func TestStreamOrContainerKbpsKeepsOldBehavior(t *testing.T) { + if got := streamOrContainerKbps(mediaInfo{videoKbps: 2000, containerKbps: 2500}); got != 2000 { + t.Errorf("有视频流码率时应优先用它,实际 %d", got) + } + if got := streamOrContainerKbps(mediaInfo{containerKbps: 2500, hasAudio: true, audioKbps: 192}); got != 2500 { + t.Errorf("没有视频流码率时应退回容器总码率(含音频),实际 %d", got) + } +} + +// 探测结果的解析:容器总码率和视频流码率要分开记,封面图不能算成画面 +func TestParseMediaInfoSplitsBitrates(t *testing.T) { + out := "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'x.mp4':\n" + + " Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s\n" + + " Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 1234 kb/s, 30 fps, 30 tbr, 15360 tbn\n" + + " Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s\n" + m := parseMediaInfo(out) + if !m.hasVideo { + t.Error("有视频轨时 hasVideo 应为 true") + } + if m.videoKbps != 1234 || m.containerKbps != 1767 { + t.Errorf("视频流码率和容器总码率要分开记,实际 %d / %d", m.videoKbps, m.containerKbps) + } + if m.width != 1920 || m.height != 1080 || m.fps != 30 { + t.Errorf("画面参数没解析对:%dx%d %v fps", m.width, m.height, m.fps) + } + if !m.hasAudio || m.audioKbps != 192 { + t.Errorf("音轨没解析对:hasAudio=%v %d kbps", m.hasAudio, m.audioKbps) + } + if m.codec != "h264" { + t.Errorf("编码器名应为 h264,实际 %q", m.codec) + } + if d := m.duration; d < 175.0 || d > 175.2 { + t.Errorf("时长应为 175.07 秒,实际 %v", d) + } + + // 只有封面图(attached pic)的文件不算有视频轨,否则转码时会当成画面 + pic := " Duration: 00:00:10.00, start: 0.000000, bitrate: 128 kb/s\n" + + " Stream #0:0[0x1]: Video: mjpeg (attached pic), yuvj420p, 600x600, 90k tbr\n" + + " Stream #0:1: Audio: mp3, 44100 Hz, stereo, 128 kb/s\n" + pm := parseMediaInfo(pic) + if pm.hasVideo { + t.Error("封面图不该算视频轨") + } + if pm.width != 0 { + t.Errorf("封面图的分辨率不该当画面用,实际 %d", pm.width) + } +} + +// 同一个文件反复探测要走缓存(体积估算跟着输入框重发,每次起一个 ffmpeg 会把 CPU 占住), +// 但文件变了必须重新探 +func TestProbeMediaCacheInvalidatesOnChange(t *testing.T) { + ff := testFFmpeg(t) + setupMergeTest(t) + path := filepath.Join(recordSaveDir(), "缓存.mp4") + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "testsrc=size=160x120:rate=10:duration=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil { + t.Skipf("造不出测试视频,跳过:%v %s", err, out) + } + + probeCache.Lock() + probeCache.m = nil + probeCache.Unlock() + + first := probeMediaCached(ff, path) + if first.duration <= 0 { + t.Fatalf("探测不到时长:%+v", first) + } + // 第二次必须命中缓存:键的数量不该变 + probeCache.Lock() + n1 := len(probeCache.m) + probeCache.Unlock() + if again := probeMediaCached(ff, path); again.duration != first.duration { + t.Errorf("命中缓存时结果应一致,实际 %v ≠ %v", again.duration, first.duration) + } + probeCache.Lock() + n2 := len(probeCache.m) + probeCache.Unlock() + if n1 != 1 || n2 != 1 { + t.Errorf("同一个文件第二次探测不该新增缓存项,实际 %d → %d", n1, n2) + } + + // 换个更长的片子放同一个路径:体积和修改时间都变了,必须重新探 + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "testsrc=size=160x120:rate=10:duration=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil { + t.Fatalf("重造测试视频失败:%v %s", err, out) + } + changed := probeMediaCached(ff, path) + if changed.duration <= first.duration+0.5 { + t.Errorf("文件换成 3 秒的了,时长该跟着变(%v → %v)", first.duration, changed.duration) + } +} + +// ==================== ffmpeg 参数 ==================== + +// 指定的编码器要被用上;没指定时按本机可用的挑 +func TestTranscodeArgsEncoder(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + + args, enc, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Encoder: "libx264"}, src) + if enc != "libx264" { + t.Fatalf("指定 libx264 就该用它,实际 %s", enc) + } + if !containsSeq(args, "-c:v", "libx264") { + t.Errorf("命令里没有 -c:v libx264:%v", args) + } + // -c:v 必须在 -i 之后,否则 ffmpeg 会当成输入选项 + iPos, cPos := indexOf(args, "in.mp4"), indexOf(args, "-c:v") + if iPos < 0 || cPos < 0 || cPos < iPos { + t.Errorf("-c:v 要放在 -i 之后,实际 %v", args) + } + + _, auto, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{}, src) + if auto == "" { + t.Fatal("自动模式也该挑出一个编码器") + } + if !containsSeq(args, "-map", "0:v:0") { + t.Errorf("应显式只映射第一条视频流(跳过封面图),实际 %v", args) + } +} + +// 质量模式下给质量参数、给目标码率模式下给码率参数,两者不能同时出现 +func TestTranscodeArgsRateControl(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Mode: rateModeBitrate, Bitrate: 1500}, src) + if !containsSeq(args, "-b:v", "1500k") { + t.Errorf("目标码率模式应给 -b:v 1500k,实际 %v", args) + } + if has(args, "-crf") || has(args, "-global_quality") { + t.Errorf("给了目标码率就不该再给质量参数:%v", args) + } + + // 质量模式没填数值时用默认值,不能把 0 塞给 ffmpeg + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Mode: rateModeQuality, Encoder: "libx264"}, src) + if !containsSeq(args, "-crf", "23") { + t.Errorf("软编的质量模式应默认 CRF 23,实际 %v", args) + } +} + +// 缩放和帧率:只缩不放,帧率变了才加 fps 滤镜 +func TestTranscodeArgsFilters(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + + args, _, fps := transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Scale: 720, Fps: 60}, src) + vf := valueAfter(args, "-vf") + if !strings.Contains(vf, "scale=-2:720") { + t.Errorf("缩放应写进 -vf,实际 %q", vf) + } + if !strings.Contains(vf, "fps=60") { + t.Errorf("帧率变化应写进 -vf,实际 %q", vf) + } + if fps != 60 { + t.Errorf("返回的帧率应为 60,实际 %d", fps) + } + + // 帧率跟源片一样时不加滤镜,白跑一遍滤镜只会更慢 + _, _, fps = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Fps: 30}, src) + if fps != 0 { + t.Errorf("帧率没变时不该加 fps 滤镜,实际返回 %d", fps) + } + // 目标比源片高时也不放大 + _, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src) + if vf := valueAfter(transcodeArgsArgs(ff, src), "-vf"); strings.Contains(vf, "scale") { + t.Errorf("不该把 1080p 放大到 2160p,实际 %q", vf) + } +} + +// HEVC 要带 hvc1 标签,否则 Apple 系打不开 +func TestTranscodeArgsHEVCTag(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000} + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Encoder: "hevc_qsv", Mode: rateModeBitrate, Bitrate: 2000}, src) + if !containsSeq(args, "-tag:v", "hvc1") { + t.Errorf("HEVC 要写 hvc1,实际 %v", args) + } + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", + transcodeOptions{Encoder: "libx264", Mode: rateModeBitrate, Bitrate: 2000}, src) + if has(args, "-tag:v") { + t.Errorf("H.264 不该加 hvc1,实际 %v", args) + } +} + +// 音频的三种处理要各自拼对 +func TestTranscodeArgsAudio(t *testing.T) { + ff := testFFmpeg(t) + src := mediaInfo{duration: 10, width: 1280, height: 720, fps: 30, videoKbps: 2000, hasAudio: true, audioKbps: 128} + + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioNone}, src) + if !has(args, "-an") { + t.Errorf("去掉音轨应给 -an,实际 %v", args) + } + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioAAC192}, src) + if !containsSeq(args, "-c:a", "aac") || !containsSeq(args, "-b:a", "192k") { + t.Errorf("AAC 192k 没拼对:%v", args) + } + args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioCopy}, src) + if !containsSeq(args, "-c:a", "copy") { + t.Errorf("直接拷贝应给 -c:a copy,实际 %v", args) + } +} + +// ==================== 端到端:真的转一个 ==================== + +// 拿 ffmpeg 造一段两秒的测试视频,再按各种参数真转一遍。 +// 只验证参数和落盘,体积大小不断言(不同机器编码结果不一样) +func TestRunTranscodeProducesFile(t *testing.T) { + ff := testFFmpeg(t) + setupMergeTest(t) + src := filepath.Join(recordSaveDir(), "源片.mp4") + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "testsrc=size=640x360:rate=30:duration=2", "-c:v", "libx264", "-pix_fmt", "yuv420p", src).CombinedOutput(); err != nil { + t.Skipf("造不出测试视频,跳过:%v %s", err, out) + } + + jobFinish("") + var got string + err := runMergeExclusive(func() error { + var e error + got, e = runTranscode(transcodeJobReq{ + Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src), + Options: transcodeOptions{Mode: rateModeBitrate, Bitrate: 800, Scale: 360, Preset: "veryfast"}, + }, mergeSaveDir(), 1, 1) + return e + }) + jobFinish(jobErrText(err)) + if err != nil { + t.Fatalf("转码失败:%v", err) + } + if got != "源片_转码.mp4" { + t.Errorf("返回的成片名不对:%q", got) + } + want := filepath.Join(mergeSaveDir(), "源片_转码.mp4") + if _, err := os.Stat(want); err != nil { + t.Fatalf("成片没落到 %s:%v", want, err) + } + // 转出来的文件不能是空壳 + if n := fileSizeOrZero(want); n < 1024 { + t.Errorf("成片只有 %d 字节,不正常", n) + } +} + +// 只有音轨的文件要当场挡住并说清原因,别等 ffmpeg 报一句「matches no streams」 +func TestRunTranscodeRejectsAudioOnly(t *testing.T) { + ff := testFFmpeg(t) + setupMergeTest(t) + src := filepath.Join(recordSaveDir(), "只有声音.mp4") + if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i", + "sine=frequency=440:duration=1", "-c:a", "aac", "-vn", src).CombinedOutput(); err != nil { + t.Skipf("造不出只有音轨的文件,跳过:%v %s", err, out) + } + + jobFinish("") + err := runMergeExclusive(func() error { + _, e := runTranscode(transcodeJobReq{ + Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src), + }, mergeSaveDir(), 1, 1) + return e + }) + jobFinish(jobErrText(err)) + if err == nil { + t.Fatal("只有音轨的文件不该转得下去") + } + if !strings.Contains(err.Error(), "没有视频轨") { + t.Errorf("报错要说明是没有视频轨,实际:%v", err) + } +} + +// ==================== 小工具 ==================== + +func has(args []string, v string) bool { + for _, a := range args { + if a == v { + return true + } + } + return false +} + +func indexOf(args []string, v string) int { + for i, a := range args { + if a == v { + return i + } + } + return -1 +} + +// 参数里是否连续出现了这两个值 +func containsSeq(args []string, a, b string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == a && args[i+1] == b { + return true + } + } + return false +} + +// 取 -vf 后面的值 +func valueAfter(args []string, key string) string { + for i, a := range args { + if a == key && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// 单独构造一次参数给上面的用例用(避免重复拼 src) +func transcodeArgsArgs(ff string, src mediaInfo) []string { + args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src) + return args +} diff --git a/webui.go b/webui.go index 2414dc1..196ae1f 100644 --- a/webui.go +++ b/webui.go @@ -234,6 +234,27 @@ const webuiHTML = ` .vbar .vout{background:var(--accent)} .vlg{display:flex;gap:14px;font-size:11.5px;color:var(--sub);margin-top:4px} .vlg i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px} + /* 一个文件/一个任务一行:标题 + 参数网格 + 结果行 */ + .trow{border:1px solid var(--line);border-radius:var(--radius-s);padding:10px 12px;margin-bottom:10px;background:var(--soft2)} + .trow .th{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap} + .trow .tn{font-weight:600;font-size:13px;flex:1 1 200px;min-width:0;word-break:break-all} + .trow .tm{font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums} + .trow .tvid{flex:1 1 100%;font-size:11.5px;color:var(--sub);word-break:break-all;line-height:1.6} + .trow .tg{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px 10px;margin-top:9px} + .trow label{display:block;font-size:11px;color:var(--sub);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .trow select,.trow input[type=text]{width:100%;padding:5px 7px;font-size:12.5px} + .trow input.tn-num{text-align:center} + .trow .tr{margin-top:9px;font-size:12px;color:var(--sub);display:flex;gap:12px;flex-wrap:wrap;align-items:baseline} + .trow .tr b{color:var(--text);font-size:13px;font-variant-numeric:tabular-nums} + .trow .tbadge{font-size:11.5px;padding:2px 8px;border-radius:999px;background:var(--soft);color:var(--sub)} + .trow .tbadge.ok{background:var(--ok-soft);color:var(--ok)} + /* 失败用红字(--bad),别用主色:主色在深色主题下和「进行中」的灰看着差不多 */ + .trow .tbadge.bad{background:var(--accent-soft);color:var(--bad)} + /* 已经转完的那一行淡化一点,一眼看出还剩哪些没做 */ + .trow.done{opacity:.72} + @media (max-width:700px){ + .trow .tg{grid-template-columns:repeat(auto-fit,minmax(140px,1fr))} + } /* 速度折线:颜色走 CSS 变量,属性里写 var() 在部分浏览器不生效 */ .spark{display:block;width:100%;height:48px;margin-top:10px;border-radius:var(--radius-s);background:var(--soft2)} .spark polyline{fill:none;stroke:var(--accent);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke} @@ -258,6 +279,7 @@ const webuiHTML = ` + @@ -387,36 +409,22 @@ const webuiHTML = `
文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。
-

手动合并

自己挑视频和弹幕合成
-
自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见「监控」页的运行日志。
+

手动合并

自己挑视频和弹幕合成,可以一次排多个
+
自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。选多个视频会按文件名里的录制时间和标题自动归组:同一场断流分成的几段归到一个任务、先拼起来再合成,不同场次各排一个任务、依次合并出多个成片。每个任务的弹幕和参数都能单独改。
-
- - -
- - 可多选,能换文件夹继续勾;选完追加到框里,不清掉已填内容 -
-
-
- -
- - -
- -
-
-
- + +
+ + 也能从别的文件夹挑,选完追加成新任务 +
- - - - + +
+
还没有选视频
+
@@ -428,6 +436,48 @@ const webuiHTML = `
+ +
+
+

选择要转码的文件

从录播和成片文件夹里挑
+
+ +
+ + + 按住 Ctrl 点可以一次选多个 +
+
+
加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。
+
+
+

每个文件的参数

+
+
还没有选文件
+
+ + +
+
+
+

输出与执行

+
+
+
+ + +
+
+
+
+ + + +
+
转码和合并共用一个进度卡片,一次只跑一个文件,排着的按列表顺序来;进度见「监控」页,也可以在那里暂停和停止。
+
+
+
@@ -612,7 +662,7 @@ function api(url, opt){ return fetch(url, opt); } // ========== 分区导航 ========== -var tabNames = {mon:"监控", rec:"录制", mrg:"合并", ntf:"通知", file:"文件", sys:"设置"}; +var tabNames = {mon:"监控", rec:"录制", mrg:"合并", trc:"转码", ntf:"通知", file:"文件", sys:"设置"}; var curTab = ""; function showTab(name){ if(!tabNames[name]) name = "mon"; @@ -630,6 +680,10 @@ function showTab(name){ // 切过去的时候顺手刷新那个分区里的实时数据 if(name === "file" && authed) loadFiles(); if(name === "mrg" && authed) fillMergePickers(); + // 转码页的文件下拉靠文件列表填,切过去时列表还是空的就先拉一次 + if(name === "trc" && authed){ + if(fileList.length) fillTranscodePick(); else loadFiles(); + } } document.getElementById("tabs").addEventListener("click", function(e){ var t = e.target.closest(".tab"); @@ -750,6 +804,7 @@ async function refresh(){ setChk("mergeDelVideo", st.mergeDelVideo); setChk("mergeDelDanmu", st.mergeDelDanmu); syncMergeState(st); + syncTranscodeState(st); // 顶栏的状态摘要 var sl = st.streamers||[]; var rec = 0, live = 0; @@ -913,8 +968,6 @@ document.getElementById("defaultQualityPref").addEventListener("change", functio dropDirty("defaultQualityPref"); saveSettings(null, true); }); -// 手动合并的视频框:手填/粘贴完(失焦)也自动找一次同名弹幕文件 -document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); }); // HTML 高度只对 html 类型有意义,选 text 时收起来 function toggleMeowHeight(){ var isHTML = document.getElementById("meowMsgType").value === "html"; @@ -937,7 +990,13 @@ document.addEventListener("keydown", function(e){ if(el.classList.contains("br") || el.classList.contains("brnum")){ el.blur(); return; } // 档位相关的框失焦即提交 if(el.id === "newuid"){ e.preventDefault(); addUid(); return; } if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; } - if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); return; } + // 合并 / 转码那一行一行的参数框不是设置项:回车用来触发检查或重新估算,别去点保存 + if(el.closest && el.closest(".trow")){ + e.preventDefault(); + if(el.closest("#mrgJobs")) checkMerge(); + else if(el.closest("#tcRows")) estimateTranscode(); + return; + } if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); } }); // 还有没保存的改动时提醒一句,别直接关掉页面 @@ -972,6 +1031,7 @@ async function loadFiles(b){ fileList = d.files || []; recDir = d.recordDir || ""; fillMergePickers(); + fillTranscodePick(); document.getElementById("diskInfo").textContent = "磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) + " 录播:" + d.recordDir + " 成片:" + d.mergeDir; @@ -1015,16 +1075,19 @@ var pickCur = ""; // 当前所在目录 var pickParent = ""; // 上一级 var pickFile = ""; // 单选时选中的那个 var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的) +var pickCb = null; // 选完后交给它的回调,不为空时不再往输入框里填 var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒 var recDir = ""; // 录播目录,挑弹幕和视频时从它开始 var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次 var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层 -function openPick(id, kind, multi){ - var el = document.getElementById(id); +// cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用) +function openPick(id, kind, multi, cb){ + var el = id ? document.getElementById(id) : null; pickField = id; pickKind = kind === "file" ? "file" : "dir"; pickMulti = !!multi && pickKind === "file"; + pickCb = cb || null; pickFile = ""; pickSel = []; document.getElementById("pickTitle").textContent = pickMulti ? "选择文件(可多选)" : (pickKind === "file" ? "选择文件" : "选择文件夹"); @@ -1037,7 +1100,7 @@ function openPick(id, kind, multi){ // 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录 var start = el ? el.value.split("\n")[0].trim() : ""; // 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始 - if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || id === "mrgVideos") ? recDir : ""; + if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || !id) ? recDir : ""; pickGo(start); } function closePick(){ document.getElementById("pick").classList.remove("on"); } @@ -1122,22 +1185,6 @@ function paintPickSel(){ updatePickBar(); } function pickClearSel(){ pickSel = []; paintPickSel(); } -// 选好视频后自动找同目录同名的弹幕文件(.jsonl / .ass),找不到就让用户自己选。 -// 弹幕框里已经有内容时不动它,避免把用户填好的覆盖掉 -async function autoMatchDanmu(){ - var vEl = document.getElementById("mrgVideos"), dEl = document.getElementById("mrgDanmu"); - if(!vEl || !dEl || dEl.value.trim()) return; - var lines = vEl.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; }); - if(!lines.length) return; - var r = null; - try{ r = await api("/api/merge/danmu?video=" + encodeURIComponent(lines[0])); }catch(e){} - if(!r || !r.ok) return; - var d = null; - try{ d = await r.json(); }catch(e){ return; } - if(!d || !d.danmu) return; - dEl.value = d.danmu; - showMsg(true, "已按视频文件名自动匹配弹幕:" + d.danmu); -} // 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径 function pickFillValue(p){ if(!recDir) return p; @@ -1148,9 +1195,16 @@ function pickFillValue(p){ return p; } function pickConfirm(){ - var el = document.getElementById(pickField); + var el = pickField ? document.getElementById(pickField) : null; + // 多选模式下要先把路径换成「录播目录里的只写文件名」,填框和走回调都一样 if(pickMulti){ if(!pickSel.length) return; + var picked = pickSel.map(function(p){ return pickFillValue(p); }); + if(pickCb){ + closePick(); + pickCb(picked); + return; + } if(el){ // 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复) var lines = el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; }); @@ -1163,12 +1217,16 @@ function pickConfirm(){ if(settingFields.indexOf(pickField) >= 0) markDirty(pickField); } closePick(); - if(pickField === "mrgVideos") autoMatchDanmu(); return; } var v = pickKind === "file" ? pickFile : pickCur; if(!v) return; if(pickKind === "file") v = pickFillValue(v); + if(pickCb){ + closePick(); + pickCb([v]); + return; + } if(el){ el.value = v; if(settingFields.indexOf(pickField) >= 0) markDirty(pickField); @@ -1212,20 +1270,168 @@ document.addEventListener("keydown", function(e){ }); // ========== 手动合并 ========== var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新 -function mrgPayload(){ - var videos = document.getElementById("mrgVideos").value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; }); - var off = document.getElementById("mrgOffset").value.trim(); - var body = { - videos: videos, - danmu: document.getElementById("mrgDanmu").value.trim(), - outName: document.getElementById("mrgOut").value.trim() - }; - if(off !== ""){ - var v = parseFloat(off); - if(isNaN(v)){ showMsg(false, "偏移必须是数字"); return null; } - body.offset = v; +var mrgJobs = []; // 待合并的任务:每个任务一组视频 + 自己的弹幕和参数 +var mrgSeq = 0; // 任务编号,重画行时靠它认回原来的数据 + +// 文件名是「2006-01-02 15-04-05 主播名 标题.ext」,开头的录制时刻就在这里 +function nameStartTime(name){ + var base = name.replace(/\.[^.]+$/, ""); + if(base.length < 19) return 0; + var ms = Date.parse(base.slice(0,10).replace(/-/g,"/") + " " + base.slice(11,19).replace(/-/g,":")); + return isNaN(ms) ? 0 : ms; +} +// 去掉开头的时间和扩展名,剩下的就是这场的主播名 + 标题 +function nameSubject(name){ + var base = name.replace(/\.[^.]+$/, ""); + return base.length > 19 ? base.slice(19) : base; +} +// 把选中的视频按场次归组:主题相同、且相邻两段录制时间差在 2 小时内的算同一场 +// (同一天连着开两场、标题又一样的时候靠时间差分开,不然两场会被拼成一个成片) +function groupVideos(names){ + var items = names.slice().sort(); + var groups = [], prevKey = null, prevT = 0; + items.forEach(function(n){ + var key = nameSubject(n), t = nameStartTime(n); + var gap = (t && prevT) ? Math.abs(t - prevT) : 0; + if(!groups.length || key !== prevKey || gap > 2*3600*1000) groups.push([]); + groups[groups.length-1].push(n); + prevKey = key; prevT = t; + }); + return groups; +} +// 浏览弹窗选回来的文件:视频按场次归组,弹幕直接跳过(视频自己会找同名弹幕) +function addMergeVideos(names){ + if(!names || !names.length) return; + var vids = names.filter(function(n){ return /\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(n); }); + if(!vids.length){ showMsg(false, "选的是弹幕文件,视频请另外选"); return; } + var added = 0; + groupVideos(vids).forEach(function(g){ + if(mergeJobsHave(g[0])) return; + mrgJobs.push({id: ++mrgSeq, videos: g, danmu: "", out: "", offset: "", br: "", fps: "", fs: "", enc: ""}); + added++; + }); + renderMergeJobs(); + if(added) autoMatchAllDanmu(); +} +// 这个视频是不是已经在某个任务里了(重复点选不该加出第二个一样的任务) +function mergeJobsHave(name){ + for(var i = 0; i < mrgJobs.length; i++){ + if(mrgJobs[i].videos.indexOf(name) >= 0) return true; } - return body; + return false; +} +// 从文件列表里勾选 +function pickVideos(){ + var names = []; + Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); }); + names.sort(); // 按文件名里的录制时间排,点选顺序无所谓 + addMergeVideos(names); +} +function removeMergeJob(i){ + if(mrgBusy) return; + mrgJobs.splice(i, 1); + renderMergeJobs(); +} +// 一个任务里有好几段时拆成一人一个任务(本来就是分开的两场却归到一组时用) +function splitMergeJob(i){ + if(mrgBusy) return; + var job = mrgJobs[i]; + if(!job || job.videos.length < 2) return; + var parts = job.videos.map(function(v){ return {id: ++mrgSeq, videos: [v], danmu: "", out: job.out, + offset: job.offset, br: job.br, fps: job.fps, fs: job.fs, enc: job.enc}; }); + mrgJobs.splice.apply(mrgJobs, [i, 1].concat(parts)); + renderMergeJobs(); + autoMatchAllDanmu(); +} +function pickDanmuFor(i){ + openPick(null, "file", false, function(v){ + if(mrgJobs[i]) mrgJobs[i].danmu = v[0] || ""; + renderMergeJobs(); + }); +} +// 每个还没填弹幕的任务都按视频名找一次同名的弹幕文件 +async function autoMatchAllDanmu(){ + for(var i = 0; i < mrgJobs.length; i++){ + var job = mrgJobs[i]; + if(job.danmu || !job.videos.length) continue; + var r = null, d = null; + try{ r = await api("/api/merge/danmu?video=" + encodeURIComponent(job.videos[0])); }catch(e){ continue; } + if(!r || !r.ok) continue; + try{ d = await r.json(); }catch(e){ continue; } + if(d && d.danmu) job.danmu = d.danmu; + } + renderMergeJobs(); +} +function renderMergeJobs(){ + var box = document.getElementById("mrgJobs"); + document.getElementById("mrgEmpty").style.display = mrgJobs.length ? "none" : ""; + box.innerHTML = mrgJobs.map(function(job, i){ + return '
' + + '
第 ' + (i+1) + ' 个' + (job.videos.length > 1 ? '(' + job.videos.length + ' 段拼接)' : '') + '' + + (job.videos.length > 1 ? '' : '') + + '
' + + '
' + job.videos.map(function(v){ return esc(v); }).join("
") + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + encSelectHtml("mj-enc", job.enc) + '
' + + '
'; + }).join(""); + var rows = document.querySelectorAll("#mrgJobs .trow"); + for(var k = 0; k < rows.length; k++) bindMergeRow(rows[k]); +} +// 行里的输入直接写回对应的任务对象,重画时不会丢 +function bindMergeRow(row){ + var job = mrgJobs[parseInt(row.dataset.i, 10)]; + if(!job) return; + bindRowInput(row, ".mj-danmu", function(v){ job.danmu = v; }); + bindRowInput(row, ".mj-out", function(v){ job.out = v; }); + bindRowInput(row, ".mj-offset", function(v){ job.offset = v; }); + bindRowInput(row, ".mj-br", function(v){ job.br = v; }); + bindRowInput(row, ".mj-fps", function(v){ job.fps = v; }); + bindRowInput(row, ".mj-fs", function(v){ job.fs = v; }); + var sel = row.querySelector(".mj-enc"); + if(sel) sel.addEventListener("change", function(){ job.enc = sel.value; }); +} +function bindRowInput(row, css, set){ + var el = row.querySelector(css); + if(!el) return; + el.addEventListener("input", function(){ set(el.value); }); + el.addEventListener("change", function(){ set(el.value); }); +} +// 拼请求体。留空的项就不带,服务端按全局设置走 +function mrgPayload(){ + var jobs = []; + for(var i = 0; i < mrgJobs.length; i++){ + var job = mrgJobs[i]; + if(!job.videos.length){ showMsg(false, "第 " + (i+1) + " 个任务没有视频"); return null; } + var one = {videos: job.videos, danmu: job.danmu.trim(), outName: job.out.trim()}; + var off = job.offset.trim(); + if(off !== ""){ + var v = parseFloat(off); + if(isNaN(v)){ showMsg(false, "第 " + (i+1) + " 个任务的偏移必须是数字"); return null; } + one.offset = v; + } + var opts = {}, nums = {bitrate: job.br, fps: job.fps, fontSize: job.fs}; + for(var key in nums){ + var s = (nums[key] || "").trim(); + if(s === "") continue; + var n = parseInt(s, 10); + if(isNaN(n) || n < 0){ showMsg(false, "第 " + (i+1) + " 个任务的参数必须是 0 或正整数"); return null; } + opts[key] = n; + } + if(job.enc) opts.encoder = job.enc; + one.options = opts; + jobs.push(one); + } + if(!jobs.length){ showMsg(false, "请先选择要合并的视频"); return null; } + return {jobs: jobs}; } // 用文件列表里的文件填下拉框和输入建议 function fillMergePickers(){ @@ -1249,28 +1455,43 @@ function fillMergePickers(){ return ''; }).join(""); } -function pickVideos(){ - var names = []; - Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); }); - names.sort(); // 按文件名里的录制时间排,点选顺序无所谓 - if(names.length){ - document.getElementById("mrgVideos").value = names.join("\n"); - autoMatchDanmu(); - } -} -function setMergeState(){ +function setMergeState(st){ + st = st || {}; var go = document.getElementById("mrgGo"); go.disabled = mrgBusy; go.textContent = mrgBusy ? "合并中…" : "开始合并"; document.getElementById("mrgCheck").disabled = mrgBusy; - document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见「监控」页的运行日志" : ""; + var txt = ""; + if(mrgBusy){ + if(st.mergeQueueTotal > 1){ + var n = Math.min((st.mergeQueueDone || 0) + 1, st.mergeQueueTotal); + txt = "正在合并第 " + n + "/" + st.mergeQueueTotal + " 个:" + (st.mergeOutName || ""); + }else{ + txt = "正在合并,进度见「监控」页"; + } + } + document.getElementById("mrgState").textContent = txt; +} +// 把检查结果按任务分段显示 +function mergeInfoText(infos){ + var lines = []; + infos.forEach(function(d, i){ + if(infos.length > 1) lines.push("── 第 " + (i+1) + " 个 ──"); + (d.videos||[]).forEach(function(v){ + lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : "")); + }); + lines.push("视频总时长 " + d.duration.toFixed(1) + " 秒"); + lines.push("弹幕 " + d.danmuName + " " + d.danmuCount + " 条 时间 " + d.danmuFirst.toFixed(1) + " ~ " + d.danmuLast.toFixed(1) + " 秒(" + d.source + ")"); + lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")"); + lines.push("输出 " + d.outName); + (d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); }); + }); + return lines.join("\n"); } async function checkMerge(){ - // 弹幕框还空着就先按视频文件名自动找一次(选了视频直接点检查也能用) - if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu(); + await autoMatchAllDanmu(); var body = mrgPayload(); if(!body) return; - if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; } var b = document.getElementById("mrgCheck"); b.disabled = true; var r = null; @@ -1279,23 +1500,17 @@ async function checkMerge(){ if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; } var d = await r.json(); - var lines = []; - (d.videos||[]).forEach(function(v){ - lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : "")); - }); - lines.push("视频总时长 " + d.duration.toFixed(1) + " 秒"); - lines.push("弹幕 " + d.danmuName + " " + d.danmuCount + " 条 时间 " + d.danmuFirst.toFixed(1) + " ~ " + d.danmuLast.toFixed(1) + " 秒(" + d.source + ")"); - lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")"); - lines.push("输出 " + d.outName); - (d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); }); - document.getElementById("mrgInfo").textContent = lines.join("\n"); + var infos = d && d.jobs ? d.jobs : [d]; + document.getElementById("mrgInfo").textContent = mergeInfoText(infos); } async function startMerge(){ - if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu(); + await autoMatchAllDanmu(); var body = mrgPayload(); if(!body) return; - if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; } - if(!confirm("开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return; + var n = body.jobs.length; + if(!confirm(n > 1 + ? ("开始合并这 " + n + " 个任务?会按列表顺序一个一个来,进度见「监控」页。") + : "开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return; var b = document.getElementById("mrgGo"); b.disabled = true; var r = null; @@ -1308,19 +1523,312 @@ async function startMerge(){ } var d = await r.json(); mrgBusy = true; - setMergeState(); - showMsg(true, "已开始合并:" + d.outName); + setMergeState(lastSt); + showMsg(true, n > 1 ? ("已开始合并 " + n + " 个任务") : ("已开始合并:" + (d.outName || ""))); } // 合并状态跟着 /api/status 一起刷新,只在「进行中 → 结束」这一刻提示一次 function syncMergeState(st){ var busy = !!st.mergeBusy; - if(busy === mrgBusy) return; + var was = mrgBusy; mrgBusy = busy; - setMergeState(); - if(busy) return; + setMergeState(st); + if(busy || !was) return; loadFiles(); + var rs = st.mergeResults || []; + if(rs.length > 1){ + var ok = 0; + rs.forEach(function(r){ if(!r.err) ok++; }); + document.getElementById("mrgInfo").textContent = "合并结束:共 " + rs.length + " 个,成功 " + ok + " 个\n" + + rs.map(function(r, i){ return "第 " + (i+1) + " 个 " + r.outName + (r.err ? " 失败:" + r.err : " 完成"); }).join("\n"); + } if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr); - else showMsg(true, "手动合并结束,结果见「监控」页的运行日志"); + else showMsg(true, rs.length > 1 ? ("手动合并全部结束,成功 " + rs.length + " 个") : "手动合并结束,结果见「监控」页的运行日志"); +} + +// ========== 转码 ========== +var tcJobs = []; // {dir,name,size,dur,w,h,fps,vkbps,akbps,audio,codec,out,opts,est} +var tcVideos = []; // 文件列表里的视频,下拉框按它在数组里的下标选 +var tcEncoders = []; // 本机可用的编码器 +var tcBusy = false; +var tcEstTimer = null; +var tcEstSeq = 0; // 估算请求的序号,回包时对不上就说明有更新的一次在跑,这次作废 + +function fillTranscodePick(){ + tcVideos = fileList.filter(function(f){ return /\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(f.name); }); + tcVideos.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); }); + var sel = document.getElementById("tcFilePick"); + // 刷新列表时不要把已经勾好的弄丢(和合并页那个下拉一样) + var keep = {}; + Array.prototype.forEach.call(sel.options, function(o){ if(o.selected) keep[o.value] = 1; }); + sel.innerHTML = tcVideos.map(function(f, i){ + return ''; + }).join(""); + Array.prototype.forEach.call(sel.options, function(o){ if(keep[o.value]) o.selected = true; }); +} +// 新加一个文件时给的默认参数:优先硬件 HEVC,码率按源片的六成,音轨直接拷 +function defaultTranscodeOpts(){ + return {encoder: "", mode: "percent", bitrate: 0, quality: 23, percent: 60, + preset: "medium", fps: 0, scale: 0, audio: "copy"}; +} +function defaultTranscodeOut(p){ + return p.name.replace(/\.[^.]+$/, "") + "_转码.mp4"; +} +async function addTranscodeFiles(){ + var sel = document.getElementById("tcFilePick"); + var picks = []; + Array.prototype.forEach.call(sel.options, function(o){ + if(o.selected){ + var f = tcVideos[parseInt(o.value, 10)]; + if(f && !tcJobs.some(function(j){ return j.dir === f.dir && j.name === f.name; })) picks.push(f); + } + }); + if(!picks.length){ showMsg(false, tcJobs.length ? "选中的文件已经在列表里了" : "请先选择要转码的文件"); return; } + var r = null; + try{ + r = await api("/api/transcode/probe", {method:"POST", headers:{"Content-Type":"application/json"}, + body: JSON.stringify({files: picks.map(function(f){ return {dir: f.dir, name: f.name}; })})}); + }catch(e){} + if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } + if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; } + var d = null; + try{ d = await r.json(); }catch(e){ showMsg(false, "读文件信息失败"); return; } + if(d && d.encoders && d.encoders.length) tcEncoders = d.encoders; + var skipped = []; + (d.files || []).forEach(function(p){ + // 只有音轨(或只有封面图)的文件排进去也是白排,ffmpeg 会直接报没视频流 + if(!p.hasVideo){ skipped.push(p.name); return; } + tcJobs.push({dir: p.dir, name: p.name, size: p.size, dur: p.duration, w: p.width, h: p.height, + fps: p.fps, vkbps: p.videoKbps, akbps: p.audioKbps, audio: !!p.hasAudio, codec: p.videoCodec, + out: defaultTranscodeOut(p), opts: defaultTranscodeOpts(), est: null}); + }); + renderTranscodeRows(); + estimateTranscode(); + if(skipped.length) showMsg(false, "这些文件里没有视频轨,已跳过:" + skipped.join("、")); +} +function clearTranscodeFiles(){ + if(tcBusy) return; + tcJobs = []; + renderTranscodeRows(); + updateTranscodeTotal(); +} +function removeTranscodeFile(i){ + if(tcBusy) return; + tcJobs.splice(i, 1); + renderTranscodeRows(); + updateTranscodeTotal(); +} +// 第一个文件调好之后,其余的照抄一份(多数情况整批用同一套参数) +function applyTranscodeToAll(){ + if(!tcJobs.length) return; + var o = tcJobs[0].opts; + for(var i = 1; i < tcJobs.length; i++) tcJobs[i].opts = JSON.parse(JSON.stringify(o)); + renderTranscodeRows(); + estimateTranscode(); +} +function encSelectHtml(cls, cur){ + var list = tcEncoders.length ? tcEncoders : ["hevc_qsv", "h264_qsv", "libx264"]; + return ''; +} +// 码率控制模式决定那个数值框填的是什么 +function tcNumField(o){ + if(o.mode === "bitrate") return {label: "目标码率(Kbps)", val: o.bitrate || "", ph: "例如 2000"}; + if(o.mode === "quality") return {label: "质量值(0-51)", val: o.quality || "", ph: "越小越清晰,默认 23"}; + if(o.mode === "percent") return {label: "源片码率的比例(%)", val: o.percent || "", ph: "例如 60"}; + return {label: "数值(当前模式用不到)", val: "", ph: "跟随源片"}; +} +function renderTranscodeRows(){ + document.getElementById("tcEmpty").style.display = tcJobs.length ? "none" : ""; + document.getElementById("tcCount").textContent = tcJobs.length ? (tcJobs.length + " 个文件") : ""; + document.getElementById("tcRows").innerHTML = tcJobs.map(function(job, i){ + var o = job.opts; + var num = tcNumField(o); + var meta = [job.w && job.h ? (job.w + "x" + job.h) : "", job.fps ? (Math.round(job.fps*10)/10 + " fps") : "", + job.vkbps ? (job.vkbps + " kbps") : "", job.codec ? ("源 " + job.codec) : "", + job.dur ? fmtDur(job.dur) : "", fmtSize(job.size), job.audio ? "" : "无音轨"] + .filter(function(x){ return x; }).join(" "); + var scales = [0, 2160, 1440, 1080, 720, 480, 360].filter(function(h){ + return h === 0 || !job.h || h < job.h; + }); + return '
' + + '
' + esc(job.name) + '' + + '' + esc(meta) + '' + + '
' + + '
' + + '
' + + '
' + encSelectHtml("tc-enc", o.encoder) + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
预估产物 —' + + '
' + + '
'; + }).join(""); + var rows = document.querySelectorAll("#tcRows .trow"); + for(var k = 0; k < rows.length; k++) bindTcRow(rows[k]); + paintTranscodeEst(); +} +// 行里的改动写回任务对象;下拉框一改就要重画(数值框的含义会跟着模式变) +function bindTcRow(row){ + var job = tcJobs[parseInt(row.dataset.i, 10)]; + if(!job) return; + bindRowInput(row, ".tc-out", function(v){ job.out = v; }); + bindRowInput(row, ".tc-num", function(v){ readTcNum(job, v); }); + bindRowInput(row, ".tc-fps", function(v){ var n = parseInt(v, 10); job.opts.fps = isNaN(n) || n < 0 ? 0 : n; }); + var sel = row.querySelector(".tc-mode"); + if(sel) sel.addEventListener("change", function(){ job.opts.mode = sel.value; renderTranscodeRows(); scheduleEstimate(); }); + var enc = row.querySelector(".tc-enc"); + if(enc) enc.addEventListener("change", function(){ job.opts.encoder = enc.value; scheduleEstimate(); }); + var pre = row.querySelector(".tc-preset"); + if(pre) pre.addEventListener("change", function(){ job.opts.preset = pre.value; scheduleEstimate(); }); + var sc = row.querySelector(".tc-scale"); + if(sc) sc.addEventListener("change", function(){ job.opts.scale = parseInt(sc.value, 10) || 0; scheduleEstimate(); }); + var au = row.querySelector(".tc-audio"); + if(au) au.addEventListener("change", function(){ job.opts.audio = au.value; scheduleEstimate(); }); +} +function readTcNum(job, v){ + var n = parseInt(v, 10); + if(isNaN(n) || n < 0) n = 0; + if(job.opts.mode === "bitrate") job.opts.bitrate = n; + else if(job.opts.mode === "quality") job.opts.quality = n; + else if(job.opts.mode === "percent") job.opts.percent = n; + scheduleEstimate(); +} +function scheduleEstimate(){ + if(tcEstTimer) clearTimeout(tcEstTimer); + tcEstTimer = setTimeout(estimateTranscode, 350); +} +// 体积估算是服务端算的(公式只有一份,界面和落地的预估不会打架),输入停一下才发 +async function estimateTranscode(){ + if(!tcJobs.length){ updateTranscodeTotal(); return; } + var seq = ++tcEstSeq; + var body = {jobs: tcJobs.map(function(j){ return {dir: j.dir, name: j.name, options: j.opts}; })}; + var r = null; + try{ + r = await api("/api/transcode/estimate", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); + }catch(e){ return; } + if(seq !== tcEstSeq) return; // 已经有更新的一次在跑,这次的结果别覆盖它 + // 参数不合法时服务端会 400。这里必须把原因说出来,否则填错了框只会看到 + // 预估体积一动不动,还以为是自己没改成功 + if(!r || !r.ok){ + document.getElementById("tcTotal").textContent = "估算失败:" + (r ? await r.text() : "连接失败"); + return; + } + var d = null; + try{ d = await r.json(); }catch(e){ return; } + if(seq !== tcEstSeq) return; + var ests = (d && d.jobs) || []; + for(var i = 0; i < tcJobs.length && i < ests.length; i++) tcJobs[i].est = ests[i]; + paintTranscodeEst(); + updateTranscodeTotal(); +} +function paintTranscodeEst(){ + var rows = document.querySelectorAll("#tcRows .trow"); + for(var k = 0; k < rows.length; k++){ + var job = tcJobs[parseInt(rows[k].dataset.i, 10)]; + if(!job) continue; + var est = rows[k].querySelector(".tc-est"), rate = rows[k].querySelector(".tc-rate"), note = rows[k].querySelector(".tc-note"); + if(!est) continue; + if(!job.est || !job.est.bytes){ est.textContent = "—"; continue; } + var pct = job.size > 0 ? Math.round(job.est.bytes / job.size * 100) : 0; + est.textContent = fmtSize(job.est.bytes) + "(源片的 " + pct + "%)"; + rate.textContent = "视频 " + job.est.videoKbps + " kbps + 音频 " + job.est.audioKbps + " kbps"; + note.textContent = job.est.exact ? "" : ("· " + (job.est.note || "粗估")); + } +} +function updateTranscodeTotal(){ + var el = document.getElementById("tcTotal"); + var src = 0, out = 0, got = 0; + tcJobs.forEach(function(j){ src += j.size; if(j.est && j.est.bytes){ out += j.est.bytes; got++; } }); + if(!tcJobs.length){ el.textContent = ""; return; } + if(!got){ el.textContent = "共 " + fmtSize(src); return; } + var pct = src > 0 ? Math.round(out / src * 100) : 0; + el.textContent = "合计 " + fmtSize(src) + " → " + fmtSize(out) + "(" + pct + "%)"; +} +function setTranscodeState(st){ + st = st || {}; + var go = document.getElementById("tcGo"); + go.disabled = tcBusy; + go.textContent = tcBusy ? "转码中…" : "开始转码"; + var txt = ""; + if(tcBusy && st.transcodeTotal > 1){ + txt = "正在转第 " + Math.min((st.transcodeDone || 0) + 1, st.transcodeTotal) + "/" + st.transcodeTotal + " 个:" + (st.transcodeCurrent || ""); + }else if(tcBusy){ + txt = "正在转码,进度见「监控」页"; + } + document.getElementById("tcState").textContent = txt; +} +function syncTranscodeState(st){ + var busy = !!st.transcodeBusy; + var was = tcBusy; + tcBusy = busy; + setTranscodeState(st); + // 跑完给每行标一下结果 + var rs = st.transcodeResults || []; + var rows = document.querySelectorAll("#tcRows .trow"); + for(var k = 0; k < rows.length; k++){ + var badge = rows[k].querySelector(".tc-badge"); + if(!badge) continue; + var res = rs[k]; + if(!busy && res){ + badge.innerHTML = res.err + ? '失败 ' + esc(res.err) + : '完成 ' + fmtSize(res.bytes || 0); + rows[k].classList.toggle("done", !res.err); + }else{ + if(busy && k === (st.transcodeDone || 0)) badge.innerHTML = '进行中'; + else badge.innerHTML = ""; + rows[k].classList.remove("done"); + } + } + if(!busy && was && rs.length){ + var ok = 0; + rs.forEach(function(r){ if(!r.err) ok++; }); + showMsg(true, "转码结束:共 " + rs.length + " 个,成功 " + ok + " 个"); + loadFiles(); + } +} +async function startTranscode(){ + if(!tcJobs.length){ showMsg(false, "请先选择要转码的文件"); return; } + var n = tcJobs.length; + if(!confirm(n > 1 ? ("开始转码这 " + n + " 个文件?会按列表顺序一个一个来。") : "开始转码?")) return; + var body = {outDir: document.getElementById("tcOutDir").value.trim(), + jobs: tcJobs.map(function(j){ return {dir: j.dir, name: j.name, outName: j.out, options: j.opts}; })}; + var b = document.getElementById("tcGo"); + b.disabled = true; + var r = null; + try{ r = await api("/api/transcode", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){} + if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); b.disabled = false; return; } + if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); b.disabled = false; return; } + tcBusy = true; + setTranscodeState(lastSt); + showMsg(true, n > 1 ? ("已开始转码 " + n + " 个文件") : "已开始转码"); } async function testNotify(b, channel){ if(b) b.disabled = true; @@ -1498,6 +2006,8 @@ setInterval(refresh, 3000); var JOB_LINGER_MS = 20000; // 自动任务结束后要播报一下,同一次任务只报一次 var jobAnnounced = ""; +// 最近一次的任务快照,窗口尺寸变化时用它重画折线 +var lastJob = null; function fmtBytes(n){ if(!n || n <= 0) return "—"; @@ -1522,6 +2032,7 @@ function fmtEta(sec){ return "剩余约 " + h + " 小时 " + (m%60) + " 分"; } function renderJob(j){ + lastJob = j; // 窗口宽度变了要按新宽度重画折线,留一份最近的数据 var card = document.getElementById("jobCard"); var topEl = document.getElementById("jobTop"); var recent = j.doneAt > 0 && (Date.now() - j.doneAt*1000) < JOB_LINGER_MS; @@ -1537,8 +2048,10 @@ function renderJob(j){ // 「被手动停止」不是失败,别当成错误报出来 var failed = !!j.err && j.err !== "任务已被手动停止"; + // 批量任务(多个手动合并 / 多个转码)标出这是队列里的第几个 + var q = (j.queueTotal > 1) ? "(第 " + Math.min((j.queueDone || 0) + 1, j.queueTotal) + "/" + j.queueTotal + " 个)" : ""; document.getElementById("jobOut").textContent = - (j.kind || "") + (j.outName ? " · " + j.outName : ""); + (j.kind || "") + q + (j.outName ? " · " + j.outName : ""); // 控制按钮只在跑的时候出现;暂停中也允许直接停止(后台杀挂起的进程没问题) var pb = document.getElementById("jobPause"), sb = document.getElementById("jobStop"); @@ -1621,7 +2134,12 @@ function renderJob(j){ var wrap = document.getElementById("jobSparkWrap"); if(samples.length >= 2){ wrap.style.display = ""; - var W = 300, H = 48, PAD = 5, maxS = 0; + var svg = document.getElementById("jobSpark"); + // viewBox 的宽度必须跟着元素的实际宽度走:写死一个值再靠 preserveAspectRatio="none" + // 铺满整行的话,宽屏上横轴被放大好几倍而纵轴不变,里面的文字会被拉扁 + var W = Math.round(svg.getBoundingClientRect().width); + if(!W || W < 200) W = 300; + var H = 48, PAD = 5, maxS = 0; samples.forEach(function(s){ if(s.speed > maxS) maxS = s.speed; }); if(maxS <= 0) maxS = 1; var t0 = samples[0].t, span = Math.max(samples[samples.length-1].t - t0, 1); @@ -1631,7 +2149,8 @@ function renderJob(j){ return x.toFixed(1) + "," + y.toFixed(1); }).join(" "); var last = samples[samples.length-1]; - document.getElementById("jobSpark").innerHTML = + svg.setAttribute("viewBox", "0 0 " + W + " " + H); + svg.innerHTML = '' + '峰值 ' + maxS.toFixed(2) + 'x' + '当前 ' + (last.speed || 0).toFixed(2) + 'x'; @@ -1677,6 +2196,8 @@ async function pollJob(){ setTimeout(pollJob, (j && j.active) ? 1000 : 4000); } pollJob(); +// 折线图的 viewBox 是按元素宽度算的,窗口尺寸一变就得重画,否则文字又被拉扁 +window.addEventListener("resize", function(){ if(lastJob) renderJob(lastJob); }); document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); }); @@ -1702,42 +2223,52 @@ type webStreamer struct { // web UI 的状态数据 type webStatus struct { - UIDs []uidSetting `json:"uids"` - Directory string `json:"directory"` - AutoMerge bool `json:"autoMerge"` - MergeDelVideo bool `json:"mergeDelVideo"` - MergeDelDanmu bool `json:"mergeDelDanmu"` - MergeOutputDir string `json:"mergeOutputDir"` - MergeBitrate int `json:"mergeBitrate"` - MergeFps int `json:"mergeFps"` - DanmuFontSize int `json:"danmuFontSize"` - DefaultQualityPref string `json:"defaultQualityPref"` - DefaultBitrate int `json:"defaultBitrate"` - WebExternal bool `json:"webExternal"` - RecordSubdir string `json:"recordSubdir"` - MergeSubdir string `json:"mergeSubdir"` - FFmpegPath string `json:"ffmpegPath"` - RecordFormat string `json:"recordFormat"` - NotifyEnabled bool `json:"notifyEnabled"` - NotifyURL string `json:"notifyUrl"` - NotifyToken string `json:"notifyToken"` - NotifyGroup string `json:"notifyGroup"` - NotifyLiveOn bool `json:"notifyLiveOn"` - NotifyLiveOff bool `json:"notifyLiveOff"` - NotifyRecordStart bool `json:"notifyRecordStart"` - NotifyMergeDone bool `json:"notifyMergeDone"` - NotifyMergeFail bool `json:"notifyMergeFail"` - MeowEnabled bool `json:"meowEnabled"` - MeowNickname string `json:"meowNickname"` - MeowMsgType string `json:"meowMsgType"` - MeowHTMLHeight int `json:"meowHtmlHeight"` - MeowImgURL string `json:"meowImgUrl"` - MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常 - MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行 - MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名 - MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误 - MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻 - Streamers []webStreamer `json:"streamers"` + UIDs []uidSetting `json:"uids"` + Directory string `json:"directory"` + AutoMerge bool `json:"autoMerge"` + MergeDelVideo bool `json:"mergeDelVideo"` + MergeDelDanmu bool `json:"mergeDelDanmu"` + MergeOutputDir string `json:"mergeOutputDir"` + MergeBitrate int `json:"mergeBitrate"` + MergeFps int `json:"mergeFps"` + DanmuFontSize int `json:"danmuFontSize"` + DefaultQualityPref string `json:"defaultQualityPref"` + DefaultBitrate int `json:"defaultBitrate"` + WebExternal bool `json:"webExternal"` + RecordSubdir string `json:"recordSubdir"` + MergeSubdir string `json:"mergeSubdir"` + FFmpegPath string `json:"ffmpegPath"` + RecordFormat string `json:"recordFormat"` + NotifyEnabled bool `json:"notifyEnabled"` + NotifyURL string `json:"notifyUrl"` + NotifyToken string `json:"notifyToken"` + NotifyGroup string `json:"notifyGroup"` + NotifyLiveOn bool `json:"notifyLiveOn"` + NotifyLiveOff bool `json:"notifyLiveOff"` + NotifyRecordStart bool `json:"notifyRecordStart"` + NotifyMergeDone bool `json:"notifyMergeDone"` + NotifyMergeFail bool `json:"notifyMergeFail"` + MeowEnabled bool `json:"meowEnabled"` + MeowNickname string `json:"meowNickname"` + MeowMsgType string `json:"meowMsgType"` + MeowHTMLHeight int `json:"meowHtmlHeight"` + MeowImgURL string `json:"meowImgUrl"` + MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常 + MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行 + MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名 + MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误 + MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻 + MergeQueueDone int `json:"mergeQueueDone"` // 批量合并:已经做完第几个 + MergeQueueTotal int `json:"mergeQueueTotal"` // 批量合并:一共几个 + MergeResults []mergeJobResult `json:"mergeResults"` // 批量合并:每个任务的结果 + + TranscodeBusy bool `json:"transcodeBusy"` // 转码队列是否正在跑 + TranscodeDone int `json:"transcodeDone"` // 已经转完第几个 + TranscodeTotal int `json:"transcodeTotal"` // 一共几个 + TranscodeCurrent string `json:"transcodeCurrent"` // 正在转的文件名 + TranscodeResults []transcodeResult `json:"transcodeResults"` // 每个文件的结果 + + Streamers []webStreamer `json:"streamers"` } // 打印日志到 web UI @@ -1764,6 +2295,8 @@ func webuiHandler(w http.ResponseWriter, r *http.Request) { func statusHandler(w http.ResponseWriter, r *http.Request) { // 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿 mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot() + _, mergeDone, mergeTotal, mergeResults := mergeQueueSnapshot() + tcBusy, tcDone, tcTotal, tcCurrent, tcResults := transcodeQueueSnapshot() streamers.RLock() status := webStatus{ UIDs: append(uidList{}, config.UIDs...), @@ -1801,6 +2334,14 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { MergeOutName: mergeOut, MergeErr: mergeErr, MergeDoneAt: mergeDoneAt, + MergeQueueDone: mergeDone, + MergeQueueTotal: mergeTotal, + MergeResults: mergeResults, + TranscodeBusy: tcBusy, + TranscodeDone: tcDone, + TranscodeTotal: tcTotal, + TranscodeCurrent: tcCurrent, + TranscodeResults: tcResults, Streamers: make([]webStreamer, 0, len(streamers.crt)), } for _, s := range streamers.crt { @@ -2382,6 +2923,9 @@ func startWebUI(ctx context.Context) { mux.HandleFunc("POST /api/merge/info", mergeInfoHandler) mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler) mux.HandleFunc("POST /api/merge", mergeHandler) + mux.HandleFunc("POST /api/transcode/probe", transcodeProbeHandler) + mux.HandleFunc("POST /api/transcode/estimate", transcodeEstimateHandler) + mux.HandleFunc("POST /api/transcode", transcodeHandler) mux.HandleFunc("GET /api/job", jobHandler) mux.HandleFunc("POST /api/job/control", jobControlHandler) diff --git a/webui_test.go b/webui_test.go index 5875b53..85da166 100644 --- a/webui_test.go +++ b/webui_test.go @@ -349,10 +349,8 @@ func TestWebuiSettingFieldsMatchInputs(t *testing.T) { "pickHidden": "路径选择弹窗的内部状态", "pickPath": "路径选择弹窗的内部状态", "mrgVideoPick": "手动合并:视频列表,随合并请求提交", - "mrgVideos": "手动合并:视频路径多行框,随合并请求提交", - "mrgDanmu": "手动合并:弹幕文件,随合并请求提交", - "mrgOffset": "手动合并:偏移秒数,随合并请求提交", - "mrgOut": "手动合并:成片名字,随合并请求提交", + "tcFilePick": "转码:待选文件列表,随转码请求提交", + "tcOutDir": "转码:输出目录,随转码请求提交", } for id := range ids { if settings[id] {