新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁
一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。
二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
- POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
- 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
- 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
失败或中断删临时文件,不留半成品
- 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止
三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。
四、审阅中一并修掉 / 加固的点
- 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
- 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
- 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
- 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
- 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
- probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
- 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则
验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
This commit is contained in:
@@ -162,22 +162,23 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。
|
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。
|
||||||
|
|
||||||
页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
页面按用途分成七个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
||||||
|
|
||||||
| 分区 | 内容 |
|
| 分区 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 |
|
| 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 |
|
||||||
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
||||||
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 |
|
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) |
|
||||||
|
| 转码 | 把录播或成片重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 |
|
||||||
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
||||||
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
||||||
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
||||||
|
|
||||||
### 转换任务进度
|
### 转换任务进度
|
||||||
|
|
||||||
自动合并和手动合并跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是:
|
自动合并、手动合并和转码跑起来之后,`监控`分区顶部会出现一张`转换任务`卡片,页面最顶上还有一条很细的全局进度条,切到哪个分区都能看到。卡片里给的是:
|
||||||
|
|
||||||
- **阶段步骤条**:准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,走过的打勾、当前的高亮(暂停时变红);
|
- **阶段步骤条**:合并是准备 → 分段拼接 → 获取头像 → 画面渲染 → 拼接与落盘,转码是准备 → 转码 → 落盘,走过的打勾、当前的高亮(暂停时变红);一次排了多个任务时标题上还会标`第 n/N 个`;
|
||||||
- **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权;
|
- **总进度条和百分比**:渲染阶段按「已渲染时长 ÷ 视频总时长」算,其余阶段按各自的完成比例折算后加权;
|
||||||
- **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少;
|
- **实时指标**:渲染倍速、帧率、已处理时长 / 总时长、已用时间、已写出多少;
|
||||||
- **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推;
|
- **预计剩余时间**:渲染阶段用「剩余时长 ÷ 当前倍速」,别的阶段按已用时间线性外推;
|
||||||
@@ -253,15 +254,40 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
### 手动合并
|
### 手动合并
|
||||||
|
|
||||||
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。**可以一次排多个任务,每个任务一套自己的文件和参数。**
|
||||||
|
|
||||||
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
选好视频后会按文件名自动归组:文件名是`2026-09-25 01-00-00 主播名 标题.mp4`这种格式,去掉开头的时间和扩展名后剩下的(主播名 + 标题)相同、而且相邻两段录制时间差在 2 小时以内的算同一场——同一场断流分成的几段归到一个任务里、先拼起来再合,不同场次各排一个任务、依次合出多个成片。归错了(比如同一天连着两场、标题又一样但间隔在 2 小时内)可以点任务行上的`拆成单独任务`把它们拆开。
|
||||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上并提示一句;找不到、或者弹幕框里已经有内容时就保持原样,自己手选即可。
|
|
||||||
|
每个任务行里能单独改:
|
||||||
|
|
||||||
|
- **视频**:行里列出这一组的文件,可以整组移除或拆成一人一个任务。既可以从录播目录的下拉列表里多选,也可以点`浏览`从别的文件夹挑。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用。
|
||||||
|
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl)。**选好视频会自动找同名弹幕文件**(同目录下依次找`.jsonl`、`.ass`、`.nick.ass`,同目录没有再去录播目录找一次),找到就自动填上;找不到就保持空着,用行里的`…`按钮手选。
|
||||||
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。
|
- **时间偏移**:留空时按文件名里的录制时间自动推算(视频时间 = 弹幕时间 + 偏移),也可以自己填,比如录播比弹幕晚开始 3 秒就填`3`。
|
||||||
- **输出文件名**:可以改,重名时自动加`(2)`之类的后缀,不会覆盖已有文件。
|
- **输出文件名**:留空时按弹幕文件名加`_弹幕合并`自动命名,重名会自动加`(2)`之类的后缀;自己填了名字就按填的来(**与已有文件同名时会被覆盖**,注意别填错)。
|
||||||
- **先预览再合并**:`预览`按钮会先把分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件列出来,确认没问题再点`开始合并`。
|
- **本次的合成参数**:码率(Kbps)、输出帧率、弹幕字号(像素)、编码器。**留空就沿用`成片输出`里的全局设置**,填了就只对这一个任务生效——比如某一场要压小一点、另一场保持原码率。
|
||||||
|
- **先预览再合并**:`检查`按钮会把每个任务的分段顺序、各段时长、总时长、弹幕条数和时间范围、推算出的偏移、以及要用的弹幕文件逐段列出来,确认没问题再点`开始合并`。
|
||||||
|
|
||||||
一次只允许跑一个手动合并任务,重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。
|
一次只跑一个合并任务,排着的按列表顺序来;重复提交会被拒绝并提示现在这个成片的名字。合并期间成片会标记`写入中`,和录制一样不能删除;合并失败的原因会显示在页面上,不用去翻日志。同一组里的多个视频会按文件名里的录制时间自动排序再拼接,所以下拉里选出来的顺序不影响结果,弹幕时间轴会按各段时长重新对齐。
|
||||||
|
|
||||||
|
### 转码
|
||||||
|
|
||||||
|
`转码`分区用来把录播或成片重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。
|
||||||
|
|
||||||
|
从文件列表里多选加入之后,每个文件一行,参数各自独立:
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 输出文件名 | 默认是`原名_转码.mp4`;这个默认名重名时会自动加`(2)`之类的后缀,自己改成别的名字就按填的来(**与已有文件同名时会被覆盖**) |
|
||||||
|
| 编码器 | `自动`按本机可用性挑(优先 Intel QSV 的 HEVC),也可以指定 HEVC 硬件 / H.264 硬件 / H.264 软件;本机没有的编码器会被挡住 |
|
||||||
|
| 码率控制 | `源片码率的比例`(默认 60%)/ `指定目标码率` / `质量模式`(CRF 或 ICQ,数值越小画质越好)/ `跟随源片码率` |
|
||||||
|
| 编码速度 | veryfast ~ slower,慢一点压得小一点 |
|
||||||
|
| 输出帧率 | 0 表示跟随源片;转码场景下可以按填的值升也可以降 |
|
||||||
|
| 分辨率 | 按高度缩放,只列出比源片低的档,不会把小图放大 |
|
||||||
|
| 音频 | 直接拷贝 / 重编码 128k 或 192k / 去掉音轨 |
|
||||||
|
|
||||||
|
每行下面实时显示**预估产物大小**和相对源片的百分比(体积是服务端按「码率 × 时长」算的,目标码率模式是准数,质量模式只给粗估并标注依据),列表底部还有一整批的合计。调好第一个之后点`把第一个的参数套到全部`可以一次性铺给其它行。
|
||||||
|
|
||||||
|
转码和合并共用同一张进度卡片、同一个任务位,所以一次只跑一个文件,排着的按列表顺序来,也能在`监控`页暂停和停止;停止只丢当前这个,已经在输出目录里落地的成品不受影响。
|
||||||
|
|
||||||
### 通知推送
|
### 通知推送
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -321,7 +321,7 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
|
|||||||
inputBytes += fileSizeOrZero(p)
|
inputBytes += fileSizeOrZero(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !jobStart("自动合并", filepath.Base(outFile), inputBytes, 0) {
|
if !jobStart(jobKindAutoMerge, filepath.Base(outFile), inputBytes, 0) {
|
||||||
// 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场
|
// 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场
|
||||||
return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过")
|
return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过")
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,8 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
|
|||||||
|
|
||||||
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
||||||
markFileActive(tmpOut, true)
|
markFileActive(tmpOut, true)
|
||||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
// 自动合并没有单独的参数,全部沿用全局设置
|
||||||
|
burnErr := burnDanmu(jsonl, video, tmpOut, offset, mergeOptions{})
|
||||||
markFileActive(tmpOut, false)
|
markFileActive(tmpOut, false)
|
||||||
if burnErr != nil {
|
if burnErr != nil {
|
||||||
_ = os.Remove(tmpOut)
|
_ = os.Remove(tmpOut)
|
||||||
|
|||||||
+96
-75
@@ -20,7 +20,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -147,6 +146,70 @@ type burnContext struct {
|
|||||||
// 透明头像层(无源片模式)默认的帧率
|
// 透明头像层(无源片模式)默认的帧率
|
||||||
const defaultLayerFps = 30
|
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 滤镜 —— 降帧只会更卡。
|
// 设置里的输出帧率 vs 源片帧率:只在目标**更高**时才加 fps 滤镜 —— 降帧只会更卡。
|
||||||
// 源片帧率探测不到(0)时也不动,免得把高帧率源压到某个值
|
// 源片帧率探测不到(0)时也不动,免得把高帧率源压到某个值
|
||||||
func outputFps(target int, source float64) int {
|
func outputFps(target int, source float64) int {
|
||||||
@@ -275,7 +338,7 @@ func runDanmuburn(danmuFile, videoFile, outFile string, offset float64) {
|
|||||||
}
|
}
|
||||||
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
|
// 还原结果写不进原目录时会落在系统临时目录里,收工前删掉。
|
||||||
// 这里到处都是 os.Exit,defer 不会被执行,所以显式调用
|
// 这里到处都是 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)
|
dropTempDanmu(src)
|
||||||
if err == errNoDanmu {
|
if err == errNoDanmu {
|
||||||
lPrintWarn(err.Error())
|
lPrintWarn(err.Error())
|
||||||
@@ -346,8 +409,9 @@ func fitDanmuToVideo(records []danmuRecord, offset, videoDuration float64) ([]da
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
|
// 合成一个 jsonl(和可选的视频)为带头像的弹幕层
|
||||||
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频
|
// videoFile 为空时输出透明头像层(ProRes 4444),否则把弹幕层压进视频。
|
||||||
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
// opt 是这一次的合成参数,不填的项用全局设置(见 mergeOptions)
|
||||||
|
func burnDanmu(jsonlFile, videoFile, outFile string, offset float64, opt mergeOptions) error {
|
||||||
jsonlFile, err := filepath.Abs(jsonlFile)
|
jsonlFile, err := filepath.Abs(jsonlFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -420,11 +484,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径")
|
return errors.New("找不到 ffmpeg,请先安装并加入 PATH,或把 ffmpeg 放在本程序所在文件夹,或在设置里填写 ffmpeg 路径")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 时长、分辨率、码率、帧率一次探测拿全(原来为了拿时长和视频流各起了一次 ffmpeg)
|
||||||
|
var vinfo mediaInfo
|
||||||
|
if burnMode {
|
||||||
|
vinfo = probeMedia(ffmpegFile, videoFile)
|
||||||
|
}
|
||||||
|
|
||||||
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉;
|
// 弹幕可能录得比视频久(视频中途中断过),超出视频时长的部分没法渲染,直接丢掉;
|
||||||
// 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来
|
// 时间基准整体偏移的 .ass(见 fitDanmuToVideo)在这一步救回来
|
||||||
videoDuration := 0.0
|
videoDuration := 0.0
|
||||||
if burnMode {
|
if burnMode {
|
||||||
videoDuration = probeDuration(ffmpegFile, videoFile)
|
videoDuration = vinfo.duration
|
||||||
if videoDuration > 0 {
|
if videoDuration > 0 {
|
||||||
records, offset = fitDanmuToVideo(records, offset, videoDuration)
|
records, offset = fitDanmuToVideo(records, offset, videoDuration)
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
@@ -434,17 +504,17 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
|
// 弹幕文件的坐标系和视频对不上时,统一到视频坐标系(见 unifyDanmuSpace);
|
||||||
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)
|
// 顺便拿到源片码率(用于「合成码率填 0」时的自动码率)和帧率(用于输出帧率)。
|
||||||
|
// 码率沿用「视频流自己的,没有就用容器总码率」的老口径(见 streamOrContainerKbps)
|
||||||
sourceKbps := 0
|
sourceKbps := 0
|
||||||
sourceFps := 0.0
|
sourceFps := 0.0
|
||||||
if burnMode {
|
if burnMode {
|
||||||
info := probeVideoStream(ffmpegFile, videoFile)
|
sourceKbps, sourceFps = streamOrContainerKbps(vinfo), vinfo.fps
|
||||||
sourceKbps, sourceFps = info.kbps, info.fps
|
unifyDanmuSpace(&meta, vinfo.width, vinfo.height)
|
||||||
unifyDanmuSpace(&meta, info.width, info.height)
|
|
||||||
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
|
lPrintf("视频编码器优先顺序:%s", strings.Join(availableEncoders(ffmpegFile), " → "))
|
||||||
}
|
}
|
||||||
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
|
// 字号在坐标系统一之后再定,此时 meta.PlayRes 就是成片画布
|
||||||
applyFontSize(&meta, config.DanmuFontSize)
|
applyFontSize(&meta, opt.fontSize())
|
||||||
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
||||||
relayoutLanes(records, meta)
|
relayoutLanes(records, meta)
|
||||||
|
|
||||||
@@ -476,7 +546,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
|
// 合成码率:填了值就用它;填 0(默认)按「源片码率 + 弹幕余量」算,探测不到才退回质量模式
|
||||||
bitrate := config.MergeBitrate + burnBitrate
|
bitrate := opt.bitrate() + burnBitrate
|
||||||
if bitrate == 0 && burnMode {
|
if bitrate == 0 && burnMode {
|
||||||
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
|
if auto := autoMergeBitrate(sourceKbps); auto > 0 {
|
||||||
lPrintf("合成码率填的 0,按源片 %d kbps + 弹幕余量 %d kbps 取 %d kbps", sourceKbps, danmuBitrateMargin, auto)
|
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)
|
wantFps := opt.fps()
|
||||||
if burnMode && config.MergeFps > 0 && outFps == 0 {
|
outFps := outputFps(wantFps, sourceFps)
|
||||||
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, config.MergeFps)
|
if burnMode && wantFps > 0 && outFps == 0 {
|
||||||
|
lPrintf("输出帧率:源片 %.2f fps 已经不低于设置的 %d fps,不做处理", sourceFps, wantFps)
|
||||||
}
|
}
|
||||||
if outFps > 0 {
|
if outFps > 0 {
|
||||||
lPrintf("输出帧率:源片 %.2f fps,按设置提到 %d fps(弹幕层会按新帧率重绘)", sourceFps, outFps)
|
lPrintf("输出帧率:源片 %.2f fps,按设置提到 %d fps(弹幕层会按新帧率重绘)", sourceFps, outFps)
|
||||||
@@ -515,7 +586,8 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
dir: workDir,
|
dir: workDir,
|
||||||
outFps: outFps,
|
outFps: outFps,
|
||||||
layerFps: layerFps(config.MergeFps),
|
layerFps: layerFps(wantFps),
|
||||||
|
encoder: opt.encoder(),
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks := splitDanmuChunks(records, offset, videoDuration)
|
chunks := splitDanmuChunks(records, offset, videoDuration)
|
||||||
@@ -724,50 +796,13 @@ type videoStreamInfo struct {
|
|||||||
fps float64 // 帧率
|
fps float64 // 帧率
|
||||||
}
|
}
|
||||||
|
|
||||||
// 探测视频流的分辨率、码率和帧率
|
// 探测视频流的分辨率、码率和帧率。
|
||||||
|
// 实际解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里只挑视频那几个字段
|
||||||
func probeVideoStream(ffmpegFile, path string) videoStreamInfo {
|
func probeVideoStream(ffmpegFile, path string) videoStreamInfo {
|
||||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
m := probeMedia(ffmpegFile, path)
|
||||||
var buf bytes.Buffer
|
// 口径跟以前一致:视频流自己没写码率就退回容器总码率,别改成扣掉音频的版本
|
||||||
cmd.Stderr = &buf
|
return videoStreamInfo{width: m.width, height: m.height, kbps: streamOrContainerKbps(m), fps: m.fps}
|
||||||
// 没指定输出文件时 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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,25 +825,11 @@ const (
|
|||||||
minMergeBitrate = 800
|
minMergeBitrate = 800
|
||||||
)
|
)
|
||||||
|
|
||||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0。
|
||||||
|
// 解析在 transcode.go 的 parseMediaInfo 里(一次拿全时长/画面/音轨),这里保留薄封装给老调用点
|
||||||
func probeDuration(ffmpegFile, path string) float64 {
|
func probeDuration(ffmpegFile, path string) float64 {
|
||||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
return probeMedia(ffmpegFile, path).duration
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationRe = regexp.MustCompile(`Duration: (\d+):(\d+):(\d+\.\d+)`)
|
|
||||||
|
|
||||||
// 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass
|
// 弹幕不多时直接一次渲染完,同样从 jsonl 生成字幕,不依赖录制时留下的 .nick.ass
|
||||||
func (c *burnContext) burnSingle(records []danmuRecord) error {
|
func (c *burnContext) burnSingle(records []danmuRecord) error {
|
||||||
|
|||||||
+158
-25
@@ -27,13 +27,39 @@ var mergeVideoExts = map[string]struct{}{
|
|||||||
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
|
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
|
||||||
|
|
||||||
// 手动合并的请求体
|
// 手动合并的请求体
|
||||||
|
//
|
||||||
|
// 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务,
|
||||||
|
// 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并
|
||||||
type mergeRequest struct {
|
type mergeRequest struct {
|
||||||
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
|
||||||
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
|
||||||
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
|
||||||
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
|
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 {
|
type mergeVideoInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -65,6 +91,7 @@ type mergePlan struct {
|
|||||||
out string // 成片的绝对路径
|
out string // 成片的绝对路径
|
||||||
meta danmuMeta
|
meta danmuMeta
|
||||||
offset *float64
|
offset *float64
|
||||||
|
opts mergeOptions // 这一组自己的合成参数
|
||||||
info mergeInfo
|
info mergeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +102,10 @@ var mergeRun struct {
|
|||||||
outName string
|
outName string
|
||||||
lastErr string
|
lastErr string
|
||||||
doneAt int64
|
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
|
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) {
|
func resolveMergeInput(value string) (string, error) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
@@ -359,9 +399,44 @@ func freeOutPath(dir, base string) string {
|
|||||||
return cand
|
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) {
|
func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
||||||
var plan mergePlan
|
var plan mergePlan
|
||||||
|
ffmpegFile := getFFmpeg()
|
||||||
|
plan.opts = req.Options
|
||||||
|
if msg := req.Options.validate(ffmpegFile); msg != "" {
|
||||||
|
return plan, errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// 视频:一行一个,允许夹带空行
|
// 视频:一行一个,允许夹带空行
|
||||||
var values []string
|
var values []string
|
||||||
@@ -431,7 +506,6 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
|||||||
DanmuHasAvatar: src.hasAvatar,
|
DanmuHasAvatar: src.hasAvatar,
|
||||||
Source: src.source,
|
Source: src.source,
|
||||||
}
|
}
|
||||||
ffmpegFile := getFFmpeg()
|
|
||||||
for _, f := range plan.videos {
|
for _, f := range plan.videos {
|
||||||
fi, err := os.Stat(f)
|
fi, err := os.Stat(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -489,8 +563,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
|
|||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片
|
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片。
|
||||||
func runManualMerge(plan mergePlan) error {
|
// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」
|
||||||
|
func runManualMerge(plan mergePlan, idx, total int) error {
|
||||||
ffmpegFile := getFFmpeg()
|
ffmpegFile := getFFmpeg()
|
||||||
if ffmpegFile == "" {
|
if ffmpegFile == "" {
|
||||||
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
|
||||||
@@ -501,8 +576,11 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
for _, v := range plan.info.Videos {
|
for _, v := range plan.info.Videos {
|
||||||
inputBytes += v.Size
|
inputBytes += v.Size
|
||||||
}
|
}
|
||||||
if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) {
|
if !jobStart(jobKindManualMerge, plan.info.OutName, inputBytes, plan.info.Duration) {
|
||||||
return errors.New("已经有别的合并任务在跑,请先等它结束")
|
return errJobNotStarted
|
||||||
|
}
|
||||||
|
if idx > 0 && total > 0 {
|
||||||
|
jobQueue(idx, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
src, err := prepareDanmuSource(plan.danmu, true)
|
src, err := prepareDanmuSource(plan.danmu, true)
|
||||||
@@ -561,13 +639,18 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
|
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
|
||||||
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
|
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)
|
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
|
||||||
|
|
||||||
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
||||||
markFileActive(tmpOut, true)
|
markFileActive(tmpOut, true)
|
||||||
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
burnErr := burnDanmu(jsonl, video, tmpOut, offset, plan.opts)
|
||||||
markFileActive(tmpOut, false)
|
markFileActive(tmpOut, false)
|
||||||
if burnErr != nil {
|
if burnErr != nil {
|
||||||
_ = os.Remove(tmpOut)
|
_ = os.Remove(tmpOut)
|
||||||
@@ -579,7 +662,7 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
lPrintln("手动合并完成:" + plan.out)
|
lPrintln("手动合并完成" + tag + ":" + plan.out)
|
||||||
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
|
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
|
||||||
uid := guessUIDByFileName(plan.out)
|
uid := guessUIDByFileName(plan.out)
|
||||||
if uid == 0 {
|
if uid == 0 {
|
||||||
@@ -589,24 +672,38 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
if uid > 0 {
|
if uid > 0 {
|
||||||
text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out
|
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)
|
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
|
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。
|
||||||
|
// 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info
|
||||||
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
|
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var req mergeRequest
|
var req mergeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
plan, err := buildMergePlan(req)
|
plans, err := buildMergePlans(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
lPrintErrf("输出合并检查结果失败:%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,7 +715,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
plan, err := buildMergePlan(req)
|
plans, err := buildMergePlans(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -642,10 +739,14 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mergeRun.busy = true
|
mergeRun.busy = true
|
||||||
mergeRun.outName = plan.info.OutName
|
mergeRun.outName = plans[0].info.OutName
|
||||||
mergeRun.lastErr = ""
|
mergeRun.lastErr = ""
|
||||||
|
mergeRun.total = len(plans)
|
||||||
|
mergeRun.done = 0
|
||||||
|
mergeRun.results = nil
|
||||||
mergeRun.Unlock()
|
mergeRun.Unlock()
|
||||||
|
|
||||||
|
total := len(plans)
|
||||||
safeGo("手动合并", func() {
|
safeGo("手动合并", func() {
|
||||||
defer releaseMergeSlot()
|
defer releaseMergeSlot()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -654,33 +755,65 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
mergeRun.doneAt = time.Now().Unix()
|
mergeRun.doneAt = time.Now().Unix()
|
||||||
mergeRun.Unlock()
|
mergeRun.Unlock()
|
||||||
}()
|
}()
|
||||||
err := runManualMerge(plan)
|
stopped := false
|
||||||
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上
|
for i, plan := range plans {
|
||||||
|
mergeRun.Lock()
|
||||||
|
mergeRun.outName = plan.info.OutName
|
||||||
|
mergeRun.done = i
|
||||||
|
mergeRun.Unlock()
|
||||||
|
|
||||||
|
err := runManualMerge(plan, i+1, total)
|
||||||
|
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上。
|
||||||
|
// 只有真的起了任务才收尾:没起成说明占着卡片的是别的任务,别把人家的进度清掉
|
||||||
|
if err != errJobNotStarted {
|
||||||
jobFinish(jobErrText(err))
|
jobFinish(jobErrText(err))
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
mergeRun.Lock()
|
||||||
|
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName})
|
||||||
|
mergeRun.Unlock()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
switch err {
|
switch err {
|
||||||
case errNoDanmu:
|
case errNoDanmu:
|
||||||
msg = "弹幕文件里没有弹幕"
|
msg = "弹幕文件里没有弹幕"
|
||||||
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
|
lPrintWarn("弹幕文件里没有弹幕,跳过这个任务")
|
||||||
case errJobStopped:
|
case errJobStopped:
|
||||||
// 手动停止不是失败,别让界面弹「合并失败」
|
// 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做
|
||||||
lPrintWarn("手动合并已被手动停止")
|
lPrintWarn("手动合并已被手动停止,剩下的任务不再继续")
|
||||||
mergeRun.Lock()
|
stopped = true
|
||||||
mergeRun.lastErr = ""
|
|
||||||
mergeRun.Unlock()
|
|
||||||
return
|
|
||||||
default:
|
default:
|
||||||
lPrintErrf("手动合并失败:%v", err)
|
lPrintErrf("手动合并失败:%v", err)
|
||||||
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
|
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
|
||||||
}
|
}
|
||||||
mergeRun.Lock()
|
mergeRun.Lock()
|
||||||
|
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg})
|
||||||
mergeRun.lastErr = msg
|
mergeRun.lastErr = msg
|
||||||
mergeRun.Unlock()
|
mergeRun.Unlock()
|
||||||
|
if stopped {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergeRun.Lock()
|
||||||
|
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.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) + `}`))
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-1
@@ -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 {
|
func execManualMerge(plan mergePlan) error {
|
||||||
jobFinish("")
|
jobFinish("")
|
||||||
err := runMergeExclusive(func() error { return runManualMerge(plan) })
|
err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) })
|
||||||
jobFinish(jobErrText(err))
|
jobFinish(jobErrText(err))
|
||||||
return 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -727,7 +727,7 @@ func TestManualMergeSendsMergeDoneNotify(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("检查失败:%v", err)
|
t.Fatalf("检查失败:%v", err)
|
||||||
}
|
}
|
||||||
if err := runManualMerge(plan); err != nil {
|
if err := runManualMerge(plan, 1, 1); err != nil {
|
||||||
t.Fatalf("手动合并失败:%v", err)
|
t.Fatalf("手动合并失败:%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-4
@@ -14,6 +14,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 任务类型。阶段划分按它来取(见 jobPhasePlanFor)
|
||||||
|
const (
|
||||||
|
jobKindManualMerge = "手动合并"
|
||||||
|
jobKindAutoMerge = "自动合并"
|
||||||
|
jobKindTranscode = "转码"
|
||||||
|
)
|
||||||
|
|
||||||
// 任务阶段,前端按这个顺序画步骤条
|
// 任务阶段,前端按这个顺序画步骤条
|
||||||
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
|
var jobPhaseNames = []string{"准备", "分段拼接", "获取头像", "画面渲染", "拼接与落盘"}
|
||||||
|
|
||||||
@@ -30,6 +37,36 @@ const (
|
|||||||
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
|
// 其余阶段按经验估:拼接分段与头像下载是分钟级,准备和落盘是秒级。
|
||||||
var jobPhaseWeights = []float64{0.02, 0.08, 0.10, 0.75, 0.05}
|
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 个,够画两分钟)
|
// 渲染进度的采样间隔与折线最多保留的点数(1 秒 1 个,够画两分钟)
|
||||||
const (
|
const (
|
||||||
jobSampleInterval = time.Second
|
jobSampleInterval = time.Second
|
||||||
@@ -39,6 +76,10 @@ const (
|
|||||||
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
|
// 任务被手动停止时各层返回这个错误:调用方据此清理临时文件,不再回退其它编码器
|
||||||
var errJobStopped = errors.New("任务已被手动停止")
|
var errJobStopped = errors.New("任务已被手动停止")
|
||||||
|
|
||||||
|
// 起任务时任务位被别人占着:这次没有真的开始,调用方据此跳过 jobFinish,
|
||||||
|
// 不然会把正在跑的那个任务的进度卡片清掉
|
||||||
|
var errJobNotStarted = errors.New("已经有别的任务在跑,请先等它结束")
|
||||||
|
|
||||||
// 速度折线上的一个点
|
// 速度折线上的一个点
|
||||||
type jobSample struct {
|
type jobSample struct {
|
||||||
T float64 `json:"t"` // 任务开始后的秒数
|
T float64 `json:"t"` // 任务开始后的秒数
|
||||||
@@ -64,6 +105,8 @@ type jobInfo struct {
|
|||||||
ChunkTotal int `json:"chunkTotal"`
|
ChunkTotal int `json:"chunkTotal"`
|
||||||
AvatarDone int `json:"avatarDone"`
|
AvatarDone int `json:"avatarDone"`
|
||||||
AvatarTotal int `json:"avatarTotal"`
|
AvatarTotal int `json:"avatarTotal"`
|
||||||
|
QueueDone int `json:"queueDone"` // 批量任务:已经做完第几个(从 1 开始),单任务为 0
|
||||||
|
QueueTotal int `json:"queueTotal"` // 批量任务:一共几个
|
||||||
InputBytes int64 `json:"inputBytes"` // 源视频总大小
|
InputBytes int64 `json:"inputBytes"` // 源视频总大小
|
||||||
EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小
|
EstOutBytes int64 `json:"estOutBytes"` // 预计成片大小
|
||||||
EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured
|
EstSource string `json:"estSource"` // 预计值的来源:bitrate / measured
|
||||||
@@ -90,6 +133,14 @@ type jobState struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
|
proc *exec.Cmd // 当前在跑的 ffmpeg,暂停/停止都作用在它身上
|
||||||
|
|
||||||
|
// 当前任务的阶段划分,随任务类型在 jobStart 时定下来
|
||||||
|
phases []string
|
||||||
|
weights []float64
|
||||||
|
renderIdx int
|
||||||
|
// 批量任务(手动合并队列、转码队列)的进度,单任务时都是 0
|
||||||
|
queueDone int
|
||||||
|
queueTotal int
|
||||||
|
|
||||||
totalSec float64
|
totalSec float64
|
||||||
doneSec float64
|
doneSec float64
|
||||||
speed float64
|
speed float64
|
||||||
@@ -163,6 +214,10 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
|||||||
job.active = true
|
job.active = true
|
||||||
job.kind = kind
|
job.kind = kind
|
||||||
job.outName = outName
|
job.outName = outName
|
||||||
|
plan := jobPhasePlanFor(kind)
|
||||||
|
job.phases = plan.names
|
||||||
|
job.weights = plan.weights
|
||||||
|
job.renderIdx = plan.render
|
||||||
job.phaseIdx = 0
|
job.phaseIdx = 0
|
||||||
job.phasePct = 0
|
job.phasePct = 0
|
||||||
job.started = time.Now()
|
job.started = time.Now()
|
||||||
@@ -175,6 +230,7 @@ func jobStart(kind, outName string, inputBytes int64, totalSec float64) bool {
|
|||||||
job.fps = 0
|
job.fps = 0
|
||||||
job.chunkDone, job.chunkTotal = 0, 0
|
job.chunkDone, job.chunkTotal = 0, 0
|
||||||
job.avatarDone, job.avatarTotal = 0, 0
|
job.avatarDone, job.avatarTotal = 0, 0
|
||||||
|
job.queueDone, job.queueTotal = 0, 0
|
||||||
job.inputBytes = inputBytes
|
job.inputBytes = inputBytes
|
||||||
job.planOutBytes = 0
|
job.planOutBytes = 0
|
||||||
job.liveOutBytes = 0
|
job.liveOutBytes = 0
|
||||||
@@ -217,7 +273,7 @@ func jobFinish(errText string) {
|
|||||||
job.doneAt = time.Now().Unix()
|
job.doneAt = time.Now().Unix()
|
||||||
switch errText {
|
switch errText {
|
||||||
case "":
|
case "":
|
||||||
job.phaseIdx = len(jobPhaseNames)
|
job.phaseIdx = len(job.phases)
|
||||||
job.phasePct = 1
|
job.phasePct = 1
|
||||||
job.message = "已完成"
|
job.message = "已完成"
|
||||||
case errJobStopped.Error():
|
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 和渲染百分比都靠它
|
// 设置任务总时长(视频秒数),ETA 和渲染百分比都靠它
|
||||||
func jobSetTotal(sec float64) {
|
func jobSetTotal(sec float64) {
|
||||||
job.Lock()
|
job.Lock()
|
||||||
@@ -481,8 +547,12 @@ func clamp01(v float64) float64 {
|
|||||||
|
|
||||||
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
|
// 总进度:已走完的阶段按权重全算,当前阶段按阶段内比例折算
|
||||||
func (j *jobState) percentLocked() float64 {
|
func (j *jobState) percentLocked() float64 {
|
||||||
|
weights := j.weights
|
||||||
|
if len(weights) == 0 {
|
||||||
|
weights = jobPhaseWeights
|
||||||
|
}
|
||||||
p := 0.0
|
p := 0.0
|
||||||
for i, w := range jobPhaseWeights {
|
for i, w := range weights {
|
||||||
switch {
|
switch {
|
||||||
case i < j.phaseIdx:
|
case i < j.phaseIdx:
|
||||||
p += w
|
p += w
|
||||||
@@ -498,7 +568,7 @@ func (j *jobState) etaLocked(now time.Time) float64 {
|
|||||||
if !j.active {
|
if !j.active {
|
||||||
return 0
|
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
|
return (j.totalSec - j.doneSec) / j.speed
|
||||||
}
|
}
|
||||||
pct := j.percentLocked()
|
pct := j.percentLocked()
|
||||||
@@ -518,7 +588,7 @@ func jobSnapshot() jobInfo {
|
|||||||
Kind: job.kind,
|
Kind: job.kind,
|
||||||
OutName: job.outName,
|
OutName: job.outName,
|
||||||
PhaseIdx: job.phaseIdx,
|
PhaseIdx: job.phaseIdx,
|
||||||
Phases: jobPhaseNames,
|
Phases: job.phases,
|
||||||
Paused: job.paused,
|
Paused: job.paused,
|
||||||
Stopping: job.stopped,
|
Stopping: job.stopped,
|
||||||
Speed: job.speed,
|
Speed: job.speed,
|
||||||
@@ -529,6 +599,8 @@ func jobSnapshot() jobInfo {
|
|||||||
ChunkTotal: job.chunkTotal,
|
ChunkTotal: job.chunkTotal,
|
||||||
AvatarDone: job.avatarDone,
|
AvatarDone: job.avatarDone,
|
||||||
AvatarTotal: job.avatarTotal,
|
AvatarTotal: job.avatarTotal,
|
||||||
|
QueueDone: job.queueDone,
|
||||||
|
QueueTotal: job.queueTotal,
|
||||||
InputBytes: job.inputBytes,
|
InputBytes: job.inputBytes,
|
||||||
OutBytes: job.outBytes,
|
OutBytes: job.outBytes,
|
||||||
Message: job.message,
|
Message: job.message,
|
||||||
|
|||||||
+1025
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -234,6 +234,27 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
.vbar .vout{background:var(--accent)}
|
.vbar .vout{background:var(--accent)}
|
||||||
.vlg{display:flex;gap:14px;font-size:11.5px;color:var(--sub);margin-top:4px}
|
.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}
|
.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() 在部分浏览器不生效 */
|
/* 速度折线:颜色走 CSS 变量,属性里写 var() 在部分浏览器不生效 */
|
||||||
.spark{display:block;width:100%;height:48px;margin-top:10px;border-radius:var(--radius-s);background:var(--soft2)}
|
.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}
|
.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 = `<!DOCTYPE html>
|
|||||||
<button class="tab on" data-tab="mon">监控</button>
|
<button class="tab on" data-tab="mon">监控</button>
|
||||||
<button class="tab" data-tab="rec">录制</button>
|
<button class="tab" data-tab="rec">录制</button>
|
||||||
<button class="tab" data-tab="mrg">合并</button>
|
<button class="tab" data-tab="mrg">合并</button>
|
||||||
|
<button class="tab" data-tab="trc">转码</button>
|
||||||
<button class="tab" data-tab="ntf">通知</button>
|
<button class="tab" data-tab="ntf">通知</button>
|
||||||
<button class="tab" data-tab="file">文件</button>
|
<button class="tab" data-tab="file">文件</button>
|
||||||
<button class="tab" data-tab="sys">设置</button>
|
<button class="tab" data-tab="sys">设置</button>
|
||||||
@@ -387,36 +409,22 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div>
|
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成,可以一次排多个</span></div>
|
||||||
<div class="hint" style="margin:0 0 12px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见「监控」页的运行日志。</div>
|
<div class="hint" style="margin:0 0 12px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。选多个视频会按文件名里的录制时间和标题自动归组:同一场断流分成的几段归到一个任务、先拼起来再合成,不同场次各排一个任务、依次合并出多个成片。每个任务的弹幕和参数都能单独改。</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="f">
|
|
||||||
<label>视频文件(一行一个,也可填绝对路径)</label>
|
|
||||||
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
|
|
||||||
<div class="pathf" style="margin-top:8px">
|
|
||||||
<button class="ghost" onclick="openPick('mrgVideos','file',1)">浏览…</button>
|
|
||||||
<span class="hint" style="margin:0;flex:1 1 auto;min-width:0">可多选,能换文件夹继续勾;选完追加到框里,不清掉已填内容</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="f">
|
|
||||||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl;选了视频会自动找同名的)</label>
|
|
||||||
<div class="pathf">
|
|
||||||
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
|
||||||
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
|
|
||||||
</div>
|
|
||||||
<datalist id="mrgDanmuList"></datalist>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid" style="margin-top:12px">
|
|
||||||
<div class="f">
|
<div class="f">
|
||||||
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
|
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
|
||||||
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
|
<select id="mrgVideoPick" multiple size="5" onchange="pickVideos()"></select>
|
||||||
|
<div class="pathf" style="margin-top:8px">
|
||||||
|
<button class="ghost" onclick="openPick(null,'file',1,function(v){addMergeVideos(v)})">浏览…</button>
|
||||||
|
<span class="hint" style="margin:0;flex:1 1 auto;min-width:0">也能从别的文件夹挑,选完追加成新任务</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="f">
|
<div class="f">
|
||||||
<label>偏移(秒,留空 = 自动推算)</label>
|
<label>待合并的任务</label>
|
||||||
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
|
<div id="mrgJobs"></div>
|
||||||
<label style="margin-top:10px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
|
<div class="off" id="mrgEmpty">还没有选视频</div>
|
||||||
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
|
<datalist id="mrgDanmuList"></datalist>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
@@ -428,6 +436,48 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 转码 -->
|
||||||
|
<section class="pane" data-pane="trc">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><h2>选择要转码的文件</h2><span class="sub">从录播和成片文件夹里挑</span></div>
|
||||||
|
<div class="bar" style="margin-top:0">
|
||||||
|
<select id="tcFilePick" multiple size="6" style="flex:1 1 260px;min-width:0"></select>
|
||||||
|
<div style="flex:1 1 160px;display:flex;flex-direction:column;gap:8px">
|
||||||
|
<button class="ghost" onclick="addTranscodeFiles()">加入列表</button>
|
||||||
|
<button class="ghost" onclick="clearTranscodeFiles()">清空列表</button>
|
||||||
|
<span class="hint" style="margin:0">按住 Ctrl 点可以一次选多个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><h2>每个文件的参数</h2><span class="sub" id="tcCount"></span></div>
|
||||||
|
<div id="tcRows"></div>
|
||||||
|
<div class="off" id="tcEmpty">还没有选文件</div>
|
||||||
|
<div class="bar">
|
||||||
|
<button class="ghost" onclick="applyTranscodeToAll()">把第一个的参数套到全部</button>
|
||||||
|
<span class="hint" id="tcTotal"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h"><h2>输出与执行</h2></div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="f"><label>输出目录(留空 = 成片文件夹)</label>
|
||||||
|
<div class="pathf">
|
||||||
|
<input type="text" id="tcOutDir" autocomplete="off" placeholder="留空用成片文件夹">
|
||||||
|
<button class="ghost" onclick="openPick('tcOutDir','dir')">浏览…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<button class="ghost" onclick="estimateTranscode()">重新估算体积</button>
|
||||||
|
<button id="tcGo" onclick="startTranscode()">开始转码</button>
|
||||||
|
<span class="hint" id="tcState"></span>
|
||||||
|
</div>
|
||||||
|
<div class="hint">转码和合并共用一个进度卡片,一次只跑一个文件,排着的按列表顺序来;进度见「监控」页,也可以在那里暂停和停止。</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 通知 -->
|
<!-- 通知 -->
|
||||||
<section class="pane" data-pane="ntf">
|
<section class="pane" data-pane="ntf">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -612,7 +662,7 @@ function api(url, opt){
|
|||||||
return fetch(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 = "";
|
var curTab = "";
|
||||||
function showTab(name){
|
function showTab(name){
|
||||||
if(!tabNames[name]) name = "mon";
|
if(!tabNames[name]) name = "mon";
|
||||||
@@ -630,6 +680,10 @@ function showTab(name){
|
|||||||
// 切过去的时候顺手刷新那个分区里的实时数据
|
// 切过去的时候顺手刷新那个分区里的实时数据
|
||||||
if(name === "file" && authed) loadFiles();
|
if(name === "file" && authed) loadFiles();
|
||||||
if(name === "mrg" && authed) fillMergePickers();
|
if(name === "mrg" && authed) fillMergePickers();
|
||||||
|
// 转码页的文件下拉靠文件列表填,切过去时列表还是空的就先拉一次
|
||||||
|
if(name === "trc" && authed){
|
||||||
|
if(fileList.length) fillTranscodePick(); else loadFiles();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.getElementById("tabs").addEventListener("click", function(e){
|
document.getElementById("tabs").addEventListener("click", function(e){
|
||||||
var t = e.target.closest(".tab");
|
var t = e.target.closest(".tab");
|
||||||
@@ -750,6 +804,7 @@ async function refresh(){
|
|||||||
setChk("mergeDelVideo", st.mergeDelVideo);
|
setChk("mergeDelVideo", st.mergeDelVideo);
|
||||||
setChk("mergeDelDanmu", st.mergeDelDanmu);
|
setChk("mergeDelDanmu", st.mergeDelDanmu);
|
||||||
syncMergeState(st);
|
syncMergeState(st);
|
||||||
|
syncTranscodeState(st);
|
||||||
// 顶栏的状态摘要
|
// 顶栏的状态摘要
|
||||||
var sl = st.streamers||[];
|
var sl = st.streamers||[];
|
||||||
var rec = 0, live = 0;
|
var rec = 0, live = 0;
|
||||||
@@ -913,8 +968,6 @@ document.getElementById("defaultQualityPref").addEventListener("change", functio
|
|||||||
dropDirty("defaultQualityPref");
|
dropDirty("defaultQualityPref");
|
||||||
saveSettings(null, true);
|
saveSettings(null, true);
|
||||||
});
|
});
|
||||||
// 手动合并的视频框:手填/粘贴完(失焦)也自动找一次同名弹幕文件
|
|
||||||
document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); });
|
|
||||||
// HTML 高度只对 html 类型有意义,选 text 时收起来
|
// HTML 高度只对 html 类型有意义,选 text 时收起来
|
||||||
function toggleMeowHeight(){
|
function toggleMeowHeight(){
|
||||||
var isHTML = document.getElementById("meowMsgType").value === "html";
|
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.classList.contains("br") || el.classList.contains("brnum")){ el.blur(); return; } // 档位相关的框失焦即提交
|
||||||
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
|
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
|
||||||
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); 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); }
|
if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); }
|
||||||
});
|
});
|
||||||
// 还有没保存的改动时提醒一句,别直接关掉页面
|
// 还有没保存的改动时提醒一句,别直接关掉页面
|
||||||
@@ -972,6 +1031,7 @@ async function loadFiles(b){
|
|||||||
fileList = d.files || [];
|
fileList = d.files || [];
|
||||||
recDir = d.recordDir || "";
|
recDir = d.recordDir || "";
|
||||||
fillMergePickers();
|
fillMergePickers();
|
||||||
|
fillTranscodePick();
|
||||||
document.getElementById("diskInfo").textContent =
|
document.getElementById("diskInfo").textContent =
|
||||||
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
|
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
|
||||||
" 录播:" + d.recordDir + " 成片:" + d.mergeDir;
|
" 录播:" + d.recordDir + " 成片:" + d.mergeDir;
|
||||||
@@ -1015,16 +1075,19 @@ var pickCur = ""; // 当前所在目录
|
|||||||
var pickParent = ""; // 上一级
|
var pickParent = ""; // 上一级
|
||||||
var pickFile = ""; // 单选时选中的那个
|
var pickFile = ""; // 单选时选中的那个
|
||||||
var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的)
|
var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的)
|
||||||
|
var pickCb = null; // 选完后交给它的回调,不为空时不再往输入框里填
|
||||||
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
|
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
|
||||||
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
|
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
|
||||||
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
|
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
|
||||||
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
|
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
|
||||||
|
|
||||||
function openPick(id, kind, multi){
|
// cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用)
|
||||||
var el = document.getElementById(id);
|
function openPick(id, kind, multi, cb){
|
||||||
|
var el = id ? document.getElementById(id) : null;
|
||||||
pickField = id;
|
pickField = id;
|
||||||
pickKind = kind === "file" ? "file" : "dir";
|
pickKind = kind === "file" ? "file" : "dir";
|
||||||
pickMulti = !!multi && pickKind === "file";
|
pickMulti = !!multi && pickKind === "file";
|
||||||
|
pickCb = cb || null;
|
||||||
pickFile = "";
|
pickFile = "";
|
||||||
pickSel = [];
|
pickSel = [];
|
||||||
document.getElementById("pickTitle").textContent = pickMulti ? "选择文件(可多选)" : (pickKind === "file" ? "选择文件" : "选择文件夹");
|
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() : "";
|
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);
|
pickGo(start);
|
||||||
}
|
}
|
||||||
function closePick(){ document.getElementById("pick").classList.remove("on"); }
|
function closePick(){ document.getElementById("pick").classList.remove("on"); }
|
||||||
@@ -1122,22 +1185,6 @@ function paintPickSel(){
|
|||||||
updatePickBar();
|
updatePickBar();
|
||||||
}
|
}
|
||||||
function pickClearSel(){ pickSel = []; paintPickSel(); }
|
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){
|
function pickFillValue(p){
|
||||||
if(!recDir) return p;
|
if(!recDir) return p;
|
||||||
@@ -1148,9 +1195,16 @@ function pickFillValue(p){
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
function pickConfirm(){
|
function pickConfirm(){
|
||||||
var el = document.getElementById(pickField);
|
var el = pickField ? document.getElementById(pickField) : null;
|
||||||
|
// 多选模式下要先把路径换成「录播目录里的只写文件名」,填框和走回调都一样
|
||||||
if(pickMulti){
|
if(pickMulti){
|
||||||
if(!pickSel.length) return;
|
if(!pickSel.length) return;
|
||||||
|
var picked = pickSel.map(function(p){ return pickFillValue(p); });
|
||||||
|
if(pickCb){
|
||||||
|
closePick();
|
||||||
|
pickCb(picked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(el){
|
if(el){
|
||||||
// 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复)
|
// 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复)
|
||||||
var lines = el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
|
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);
|
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||||||
}
|
}
|
||||||
closePick();
|
closePick();
|
||||||
if(pickField === "mrgVideos") autoMatchDanmu();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var v = pickKind === "file" ? pickFile : pickCur;
|
var v = pickKind === "file" ? pickFile : pickCur;
|
||||||
if(!v) return;
|
if(!v) return;
|
||||||
if(pickKind === "file") v = pickFillValue(v);
|
if(pickKind === "file") v = pickFillValue(v);
|
||||||
|
if(pickCb){
|
||||||
|
closePick();
|
||||||
|
pickCb([v]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(el){
|
if(el){
|
||||||
el.value = v;
|
el.value = v;
|
||||||
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||||||
@@ -1212,20 +1270,168 @@ document.addEventListener("keydown", function(e){
|
|||||||
});
|
});
|
||||||
// ========== 手动合并 ==========
|
// ========== 手动合并 ==========
|
||||||
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
|
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
|
||||||
|
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 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 '<div class="trow" data-i="' + i + '">'
|
||||||
|
+ '<div class="th"><span class="tn">第 ' + (i+1) + ' 个' + (job.videos.length > 1 ? '(' + job.videos.length + ' 段拼接)' : '') + '</span>'
|
||||||
|
+ (job.videos.length > 1 ? '<button class="mini" onclick="splitMergeJob(' + i + ')">拆成单独任务</button>' : '')
|
||||||
|
+ '<button class="mini" onclick="removeMergeJob(' + i + ')">移除</button></div>'
|
||||||
|
+ '<div class="tvid">' + job.videos.map(function(v){ return esc(v); }).join("<br>") + '</div>'
|
||||||
|
+ '<div class="tg">'
|
||||||
|
+ '<div class="tf"><label>弹幕文件</label><div class="pathf">'
|
||||||
|
+ '<input type="text" class="mj-danmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" value="' + esc(job.danmu) + '" placeholder="留空自动找同名的">'
|
||||||
|
+ '<button class="mini" onclick="pickDanmuFor(' + i + ')">…</button></div></div>'
|
||||||
|
+ '<div class="tf"><label>输出文件名</label><input type="text" class="mj-out" autocomplete="off" value="' + esc(job.out) + '" placeholder="留空自动"></div>'
|
||||||
|
+ '<div class="tf"><label>偏移(秒)</label><input type="text" class="mj-offset" inputmode="decimal" autocomplete="off" value="' + esc(job.offset) + '" placeholder="留空自动"></div>'
|
||||||
|
+ '<div class="tf"><label>码率(Kbps)</label><input type="text" class="mj-br" inputmode="numeric" autocomplete="off" value="' + esc(job.br) + '" placeholder="跟随设置"></div>'
|
||||||
|
+ '<div class="tf"><label>输出帧率</label><input type="text" class="mj-fps" inputmode="numeric" autocomplete="off" value="' + esc(job.fps) + '" placeholder="跟随设置"></div>'
|
||||||
|
+ '<div class="tf"><label>弹幕字号(像素)</label><input type="text" class="mj-fs" inputmode="numeric" autocomplete="off" value="' + esc(job.fs) + '" placeholder="跟随设置"></div>'
|
||||||
|
+ '<div class="tf"><label>编码器</label>' + encSelectHtml("mj-enc", job.enc) + '</div>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}).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(){
|
function mrgPayload(){
|
||||||
var videos = document.getElementById("mrgVideos").value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
|
var jobs = [];
|
||||||
var off = document.getElementById("mrgOffset").value.trim();
|
for(var i = 0; i < mrgJobs.length; i++){
|
||||||
var body = {
|
var job = mrgJobs[i];
|
||||||
videos: videos,
|
if(!job.videos.length){ showMsg(false, "第 " + (i+1) + " 个任务没有视频"); return null; }
|
||||||
danmu: document.getElementById("mrgDanmu").value.trim(),
|
var one = {videos: job.videos, danmu: job.danmu.trim(), outName: job.out.trim()};
|
||||||
outName: document.getElementById("mrgOut").value.trim()
|
var off = job.offset.trim();
|
||||||
};
|
|
||||||
if(off !== ""){
|
if(off !== ""){
|
||||||
var v = parseFloat(off);
|
var v = parseFloat(off);
|
||||||
if(isNaN(v)){ showMsg(false, "偏移必须是数字"); return null; }
|
if(isNaN(v)){ showMsg(false, "第 " + (i+1) + " 个任务的偏移必须是数字"); return null; }
|
||||||
body.offset = v;
|
one.offset = v;
|
||||||
}
|
}
|
||||||
return body;
|
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(){
|
function fillMergePickers(){
|
||||||
@@ -1249,37 +1455,28 @@ function fillMergePickers(){
|
|||||||
return '<option value="' + esc(f.name) + '"></option>';
|
return '<option value="' + esc(f.name) + '"></option>';
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
function pickVideos(){
|
function setMergeState(st){
|
||||||
var names = [];
|
st = st || {};
|
||||||
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(){
|
|
||||||
var go = document.getElementById("mrgGo");
|
var go = document.getElementById("mrgGo");
|
||||||
go.disabled = mrgBusy;
|
go.disabled = mrgBusy;
|
||||||
go.textContent = mrgBusy ? "合并中…" : "开始合并";
|
go.textContent = mrgBusy ? "合并中…" : "开始合并";
|
||||||
document.getElementById("mrgCheck").disabled = 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 = "正在合并,进度见「监控」页";
|
||||||
}
|
}
|
||||||
async function checkMerge(){
|
}
|
||||||
// 弹幕框还空着就先按视频文件名自动找一次(选了视频直接点检查也能用)
|
document.getElementById("mrgState").textContent = txt;
|
||||||
if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu();
|
}
|
||||||
var body = mrgPayload();
|
// 把检查结果按任务分段显示
|
||||||
if(!body) return;
|
function mergeInfoText(infos){
|
||||||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
|
||||||
var b = document.getElementById("mrgCheck");
|
|
||||||
b.disabled = true;
|
|
||||||
var r = null;
|
|
||||||
try{ r = await api("/api/merge/info", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
|
||||||
b.disabled = mrgBusy;
|
|
||||||
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 = [];
|
var lines = [];
|
||||||
|
infos.forEach(function(d, i){
|
||||||
|
if(infos.length > 1) lines.push("── 第 " + (i+1) + " 个 ──");
|
||||||
(d.videos||[]).forEach(function(v){
|
(d.videos||[]).forEach(function(v){
|
||||||
lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : ""));
|
lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : ""));
|
||||||
});
|
});
|
||||||
@@ -1288,14 +1485,32 @@ async function checkMerge(){
|
|||||||
lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")");
|
lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")");
|
||||||
lines.push("输出 " + d.outName);
|
lines.push("输出 " + d.outName);
|
||||||
(d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); });
|
(d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); });
|
||||||
document.getElementById("mrgInfo").textContent = lines.join("\n");
|
});
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
async function startMerge(){
|
async function checkMerge(){
|
||||||
if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu();
|
await autoMatchAllDanmu();
|
||||||
var body = mrgPayload();
|
var body = mrgPayload();
|
||||||
if(!body) return;
|
if(!body) return;
|
||||||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
var b = document.getElementById("mrgCheck");
|
||||||
if(!confirm("开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return;
|
b.disabled = true;
|
||||||
|
var r = null;
|
||||||
|
try{ r = await api("/api/merge/info", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
||||||
|
b.disabled = mrgBusy;
|
||||||
|
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||||||
|
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||||||
|
var d = await r.json();
|
||||||
|
var infos = d && d.jobs ? d.jobs : [d];
|
||||||
|
document.getElementById("mrgInfo").textContent = mergeInfoText(infos);
|
||||||
|
}
|
||||||
|
async function startMerge(){
|
||||||
|
await autoMatchAllDanmu();
|
||||||
|
var body = mrgPayload();
|
||||||
|
if(!body) return;
|
||||||
|
var n = body.jobs.length;
|
||||||
|
if(!confirm(n > 1
|
||||||
|
? ("开始合并这 " + n + " 个任务?会按列表顺序一个一个来,进度见「监控」页。")
|
||||||
|
: "开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return;
|
||||||
var b = document.getElementById("mrgGo");
|
var b = document.getElementById("mrgGo");
|
||||||
b.disabled = true;
|
b.disabled = true;
|
||||||
var r = null;
|
var r = null;
|
||||||
@@ -1308,19 +1523,312 @@ async function startMerge(){
|
|||||||
}
|
}
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
mrgBusy = true;
|
mrgBusy = true;
|
||||||
setMergeState();
|
setMergeState(lastSt);
|
||||||
showMsg(true, "已开始合并:" + d.outName);
|
showMsg(true, n > 1 ? ("已开始合并 " + n + " 个任务") : ("已开始合并:" + (d.outName || "")));
|
||||||
}
|
}
|
||||||
// 合并状态跟着 /api/status 一起刷新,只在「进行中 → 结束」这一刻提示一次
|
// 合并状态跟着 /api/status 一起刷新,只在「进行中 → 结束」这一刻提示一次
|
||||||
function syncMergeState(st){
|
function syncMergeState(st){
|
||||||
var busy = !!st.mergeBusy;
|
var busy = !!st.mergeBusy;
|
||||||
if(busy === mrgBusy) return;
|
var was = mrgBusy;
|
||||||
mrgBusy = busy;
|
mrgBusy = busy;
|
||||||
setMergeState();
|
setMergeState(st);
|
||||||
if(busy) return;
|
if(busy || !was) return;
|
||||||
loadFiles();
|
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);
|
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 '<option value="' + i + '">' + esc(f.name) + ' ' + fmtSize(f.size) + '</option>';
|
||||||
|
}).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 '<select class="' + cls + '"><option value="">自动(按本机可用性挑)</option>'
|
||||||
|
+ list.map(function(e){
|
||||||
|
var label = e === "hevc_qsv" ? "HEVC 硬件(hevc_qsv)"
|
||||||
|
: (e === "h264_qsv" ? "H.264 硬件(h264_qsv)" : (e === "libx264" ? "H.264 软件(libx264)" : e));
|
||||||
|
return '<option value="' + e + '"' + (cur === e ? " selected" : "") + '>' + label + '</option>';
|
||||||
|
}).join("") + '</select>';
|
||||||
|
}
|
||||||
|
// 码率控制模式决定那个数值框填的是什么
|
||||||
|
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 '<div class="trow" data-i="' + i + '">'
|
||||||
|
+ '<div class="th"><span class="tn">' + esc(job.name) + '</span>'
|
||||||
|
+ '<span class="tm">' + esc(meta) + '</span>'
|
||||||
|
+ '<button class="mini" onclick="removeTranscodeFile(' + i + ')">移除</button></div>'
|
||||||
|
+ '<div class="tg">'
|
||||||
|
+ '<div class="tf"><label>输出文件名</label><input type="text" class="tc-out" autocomplete="off" value="' + esc(job.out) + '"></div>'
|
||||||
|
+ '<div class="tf"><label>编码器</label>' + encSelectHtml("tc-enc", o.encoder) + '</div>'
|
||||||
|
+ '<div class="tf"><label>码率控制</label><select class="tc-mode">'
|
||||||
|
+ '<option value="percent"' + (o.mode === "percent" ? " selected" : "") + '>源片码率的比例</option>'
|
||||||
|
+ '<option value="bitrate"' + (o.mode === "bitrate" ? " selected" : "") + '>指定目标码率</option>'
|
||||||
|
+ '<option value="quality"' + (o.mode === "quality" ? " selected" : "") + '>质量模式(CRF / ICQ)</option>'
|
||||||
|
+ '<option value="source"' + (o.mode === "source" ? " selected" : "") + '>跟随源片码率</option>'
|
||||||
|
+ '</select></div>'
|
||||||
|
+ '<div class="tf"><label>' + num.label + '</label><input type="text" class="tc-num tn-num" inputmode="numeric" autocomplete="off" value="' + esc(String(num.val)) + '" placeholder="' + num.ph + '"' + (o.mode === "source" ? " disabled" : "") + '></div>'
|
||||||
|
+ '<div class="tf"><label>编码速度</label><select class="tc-preset">'
|
||||||
|
+ ["veryfast","faster","fast","medium","slow","slower"].map(function(p){
|
||||||
|
return '<option value="' + p + '"' + (o.preset === p ? " selected" : "") + '>' + p + '</option>';
|
||||||
|
}).join("")
|
||||||
|
+ '</select></div>'
|
||||||
|
+ '<div class="tf"><label>输出帧率(0=跟随)</label><input type="text" class="tc-fps tn-num" inputmode="numeric" autocomplete="off" value="' + esc(String(o.fps || 0)) + '"></div>'
|
||||||
|
+ '<div class="tf"><label>分辨率(按高度)</label><select class="tc-scale">'
|
||||||
|
+ scales.map(function(h){
|
||||||
|
return '<option value="' + h + '"' + (o.scale === h ? " selected" : "") + '>' + (h ? (h + "p") : "原样") + '</option>';
|
||||||
|
}).join("")
|
||||||
|
+ '</select></div>'
|
||||||
|
+ '<div class="tf"><label>音频</label><select class="tc-audio">'
|
||||||
|
+ '<option value="copy"' + (o.audio === "copy" ? " selected" : "") + '>直接拷贝</option>'
|
||||||
|
+ '<option value="aac192"' + (o.audio === "aac192" ? " selected" : "") + '>重编码 192k</option>'
|
||||||
|
+ '<option value="aac128"' + (o.audio === "aac128" ? " selected" : "") + '>重编码 128k</option>'
|
||||||
|
+ '<option value="none"' + (o.audio === "none" ? " selected" : "") + '>去掉音轨</option>'
|
||||||
|
+ '</select></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="tr"><span>预估产物 <b class="tc-est">—</b></span>'
|
||||||
|
+ '<span class="tc-rate"></span><span class="tc-note"></span><span class="tc-badge"></span></div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).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
|
||||||
|
? '<span class="tbadge bad">失败</span> ' + esc(res.err)
|
||||||
|
: '<span class="tbadge ok">完成</span> ' + fmtSize(res.bytes || 0);
|
||||||
|
rows[k].classList.toggle("done", !res.err);
|
||||||
|
}else{
|
||||||
|
if(busy && k === (st.transcodeDone || 0)) badge.innerHTML = '<span class="tbadge">进行中</span>';
|
||||||
|
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){
|
async function testNotify(b, channel){
|
||||||
if(b) b.disabled = true;
|
if(b) b.disabled = true;
|
||||||
@@ -1498,6 +2006,8 @@ setInterval(refresh, 3000);
|
|||||||
var JOB_LINGER_MS = 20000;
|
var JOB_LINGER_MS = 20000;
|
||||||
// 自动任务结束后要播报一下,同一次任务只报一次
|
// 自动任务结束后要播报一下,同一次任务只报一次
|
||||||
var jobAnnounced = "";
|
var jobAnnounced = "";
|
||||||
|
// 最近一次的任务快照,窗口尺寸变化时用它重画折线
|
||||||
|
var lastJob = null;
|
||||||
|
|
||||||
function fmtBytes(n){
|
function fmtBytes(n){
|
||||||
if(!n || n <= 0) return "—";
|
if(!n || n <= 0) return "—";
|
||||||
@@ -1522,6 +2032,7 @@ function fmtEta(sec){
|
|||||||
return "剩余约 " + h + " 小时 " + (m%60) + " 分";
|
return "剩余约 " + h + " 小时 " + (m%60) + " 分";
|
||||||
}
|
}
|
||||||
function renderJob(j){
|
function renderJob(j){
|
||||||
|
lastJob = j; // 窗口宽度变了要按新宽度重画折线,留一份最近的数据
|
||||||
var card = document.getElementById("jobCard");
|
var card = document.getElementById("jobCard");
|
||||||
var topEl = document.getElementById("jobTop");
|
var topEl = document.getElementById("jobTop");
|
||||||
var recent = j.doneAt > 0 && (Date.now() - j.doneAt*1000) < JOB_LINGER_MS;
|
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 failed = !!j.err && j.err !== "任务已被手动停止";
|
||||||
|
|
||||||
|
// 批量任务(多个手动合并 / 多个转码)标出这是队列里的第几个
|
||||||
|
var q = (j.queueTotal > 1) ? "(第 " + Math.min((j.queueDone || 0) + 1, j.queueTotal) + "/" + j.queueTotal + " 个)" : "";
|
||||||
document.getElementById("jobOut").textContent =
|
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");
|
var pb = document.getElementById("jobPause"), sb = document.getElementById("jobStop");
|
||||||
@@ -1621,7 +2134,12 @@ function renderJob(j){
|
|||||||
var wrap = document.getElementById("jobSparkWrap");
|
var wrap = document.getElementById("jobSparkWrap");
|
||||||
if(samples.length >= 2){
|
if(samples.length >= 2){
|
||||||
wrap.style.display = "";
|
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; });
|
samples.forEach(function(s){ if(s.speed > maxS) maxS = s.speed; });
|
||||||
if(maxS <= 0) maxS = 1;
|
if(maxS <= 0) maxS = 1;
|
||||||
var t0 = samples[0].t, span = Math.max(samples[samples.length-1].t - t0, 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);
|
return x.toFixed(1) + "," + y.toFixed(1);
|
||||||
}).join(" ");
|
}).join(" ");
|
||||||
var last = samples[samples.length-1];
|
var last = samples[samples.length-1];
|
||||||
document.getElementById("jobSpark").innerHTML =
|
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
||||||
|
svg.innerHTML =
|
||||||
'<polyline points="' + pts + '"/>' +
|
'<polyline points="' + pts + '"/>' +
|
||||||
'<text x="' + PAD + '" y="12">峰值 ' + maxS.toFixed(2) + 'x</text>' +
|
'<text x="' + PAD + '" y="12">峰值 ' + maxS.toFixed(2) + 'x</text>' +
|
||||||
'<text x="' + (W - PAD) + '" y="12" text-anchor="end">当前 ' + (last.speed || 0).toFixed(2) + 'x</text>';
|
'<text x="' + (W - PAD) + '" y="12" text-anchor="end">当前 ' + (last.speed || 0).toFixed(2) + 'x</text>';
|
||||||
@@ -1677,6 +2196,8 @@ async function pollJob(){
|
|||||||
setTimeout(pollJob, (j && j.active) ? 1000 : 4000);
|
setTimeout(pollJob, (j && j.active) ? 1000 : 4000);
|
||||||
}
|
}
|
||||||
pollJob();
|
pollJob();
|
||||||
|
// 折线图的 viewBox 是按元素宽度算的,窗口尺寸一变就得重画,否则文字又被拉扁
|
||||||
|
window.addEventListener("resize", function(){ if(lastJob) renderJob(lastJob); });
|
||||||
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
|
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -1737,6 +2258,16 @@ type webStatus struct {
|
|||||||
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
|
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
|
||||||
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
|
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
|
||||||
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
|
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"`
|
Streamers []webStreamer `json:"streamers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,6 +2295,8 @@ func webuiHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
func statusHandler(w http.ResponseWriter, r *http.Request) {
|
func statusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿
|
// 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿
|
||||||
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
|
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
|
||||||
|
_, mergeDone, mergeTotal, mergeResults := mergeQueueSnapshot()
|
||||||
|
tcBusy, tcDone, tcTotal, tcCurrent, tcResults := transcodeQueueSnapshot()
|
||||||
streamers.RLock()
|
streamers.RLock()
|
||||||
status := webStatus{
|
status := webStatus{
|
||||||
UIDs: append(uidList{}, config.UIDs...),
|
UIDs: append(uidList{}, config.UIDs...),
|
||||||
@@ -1801,6 +2334,14 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
MergeOutName: mergeOut,
|
MergeOutName: mergeOut,
|
||||||
MergeErr: mergeErr,
|
MergeErr: mergeErr,
|
||||||
MergeDoneAt: mergeDoneAt,
|
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)),
|
Streamers: make([]webStreamer, 0, len(streamers.crt)),
|
||||||
}
|
}
|
||||||
for _, s := range 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("POST /api/merge/info", mergeInfoHandler)
|
||||||
mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler)
|
mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler)
|
||||||
mux.HandleFunc("POST /api/merge", mergeHandler)
|
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("GET /api/job", jobHandler)
|
||||||
mux.HandleFunc("POST /api/job/control", jobControlHandler)
|
mux.HandleFunc("POST /api/job/control", jobControlHandler)
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -349,10 +349,8 @@ func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
|
|||||||
"pickHidden": "路径选择弹窗的内部状态",
|
"pickHidden": "路径选择弹窗的内部状态",
|
||||||
"pickPath": "路径选择弹窗的内部状态",
|
"pickPath": "路径选择弹窗的内部状态",
|
||||||
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
|
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
|
||||||
"mrgVideos": "手动合并:视频路径多行框,随合并请求提交",
|
"tcFilePick": "转码:待选文件列表,随转码请求提交",
|
||||||
"mrgDanmu": "手动合并:弹幕文件,随合并请求提交",
|
"tcOutDir": "转码:输出目录,随转码请求提交",
|
||||||
"mrgOffset": "手动合并:偏移秒数,随合并请求提交",
|
|
||||||
"mrgOut": "手动合并:成片名字,随合并请求提交",
|
|
||||||
}
|
}
|
||||||
for id := range ids {
|
for id := range ids {
|
||||||
if settings[id] {
|
if settings[id] {
|
||||||
|
|||||||
Reference in New Issue
Block a user