mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-23 12:12:39 +08:00
4dd9015bd7
Add a transcode control to each storage in the admin drives page, modeled after the cover/preview generation controls: - Manual start/stop button per storage; transcoding is off by default and never runs automatically (not triggered by scans or the nightly pipeline) - New transcode worker probes candidates (non mp4/webm extensions) with ffprobe: already-compatible files are marked skipped; AVI with H.264 is remuxed losslessly; incompatible codecs (MPEG-4 Part 2, WMV, RMVB, HEVC...) are transcoded to H.264/AAC MP4 with +faststart - Transcoded output is uploaded back to the same storage under a "91转码" directory which is auto-added to the drive's scan skip list so the scanner never re-imports the artifacts - Playback source automatically prefers the transcoded file once ready, keeping the 302 direct-link mode for cloud drives - videos table gains transcode_status/error/file_id/size columns via startup migration; counts and live task status surface in the admin drives API and generation panel UI - Stop semantics: per-drive stop button, drive-level "stop all tasks" and global stop all include the transcode task; interrupted videos keep their candidate status and resume on next start Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
5.2 KiB
Go
179 lines
5.2 KiB
Go
// Package transcode 实现"浏览器兼容性转码":把网盘/本地存储中浏览器
|
|
// <video> 播不动的视频(AVI/WMV/FLV、MPEG-4 Part 2、RMVB 等)转成
|
|
// H.264 + AAC 的 MP4,并把产物上传回同一存储,播放源切到产物文件。
|
|
//
|
|
// 与封面/预览生成不同,转码不会自动运行——只能由管理员在网盘管理页
|
|
// 手动开启,也可以随时手动停止。
|
|
package transcode
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MediaInfo 是 ffprobe 探测出来的、做兼容性判定所需的最小信息。
|
|
type MediaInfo struct {
|
|
// FormatName 是 ffprobe 的 format_name,逗号分隔的 demuxer 别名,
|
|
// 例如 "mov,mp4,m4a,3gp,3g2,mj2" / "avi" / "matroska,webm"。
|
|
FormatName string
|
|
VideoCodecs []string
|
|
AudioCodecs []string
|
|
}
|
|
|
|
// browserCompatibleVideoCodecs 是主流浏览器 <video> 普遍可解码的视频编码。
|
|
// HEVC/H.265 只有部分平台支持,保守起见不算兼容。
|
|
var browserCompatibleVideoCodecs = map[string]bool{
|
|
"h264": true,
|
|
"vp8": true,
|
|
"vp9": true,
|
|
"av1": true,
|
|
}
|
|
|
|
// browserCompatibleAudioCodecs 是主流浏览器普遍可解码的音频编码。
|
|
var browserCompatibleAudioCodecs = map[string]bool{
|
|
"aac": true,
|
|
"mp3": true,
|
|
"opus": true,
|
|
"vorbis": true,
|
|
"flac": true,
|
|
}
|
|
|
|
// NeedsTranscode 判断这个文件是否需要转码才能在浏览器里播放。
|
|
// ext 是 catalog 里记录的扩展名(小写、不带点),用来区分 mkv 和 webm
|
|
// (两者的 format_name 都是 "matroska,webm")。
|
|
func NeedsTranscode(info MediaInfo, ext string) bool {
|
|
if !containerCompatible(info.FormatName, ext) {
|
|
return true
|
|
}
|
|
for _, codec := range info.VideoCodecs {
|
|
if !browserCompatibleVideoCodecs[strings.ToLower(codec)] {
|
|
return true
|
|
}
|
|
}
|
|
for _, codec := range info.AudioCodecs {
|
|
if !browserCompatibleAudioCodecs[strings.ToLower(codec)] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containerCompatible(formatName, ext string) bool {
|
|
format := strings.ToLower(formatName)
|
|
for _, name := range strings.Split(format, ",") {
|
|
if name == "mp4" {
|
|
return true
|
|
}
|
|
}
|
|
// matroska,webm:只有真 .webm 信任为浏览器可播容器;.mkv 保守转码。
|
|
if strings.Contains(format, "webm") && strings.EqualFold(ext, "webm") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ProbeFile 用 ffprobe 探测本地文件的容器与音视频编码。
|
|
func ProbeFile(ctx context.Context, ffprobePath, path string) (MediaInfo, error) {
|
|
ctx2, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx2, ffprobePath,
|
|
"-v", "error",
|
|
"-show_entries", "format=format_name",
|
|
"-show_entries", "stream=codec_type,codec_name",
|
|
"-of", "json",
|
|
path,
|
|
)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return MediaInfo{}, fmt.Errorf("transcode: ffprobe: %w", err)
|
|
}
|
|
var parsed struct {
|
|
Format struct {
|
|
FormatName string `json:"format_name"`
|
|
} `json:"format"`
|
|
Streams []struct {
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
} `json:"streams"`
|
|
}
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
return MediaInfo{}, fmt.Errorf("transcode: parse ffprobe output: %w", err)
|
|
}
|
|
info := MediaInfo{FormatName: parsed.Format.FormatName}
|
|
for _, s := range parsed.Streams {
|
|
switch s.CodecType {
|
|
case "video":
|
|
info.VideoCodecs = append(info.VideoCodecs, s.CodecName)
|
|
case "audio":
|
|
info.AudioCodecs = append(info.AudioCodecs, s.CodecName)
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// buildFFmpegArgs 按探测结果生成转码参数:
|
|
// - 编码本就兼容、只是容器不行(如 AVI 里装 H.264)→ 流拷贝 remux,零质量损失;
|
|
// - 否则视频转 H.264(裁到偶数尺寸 + yuv420p 保证兼容性)、音频转 AAC。
|
|
//
|
|
// 两种情况都加 +faststart 把 moov 提前,便于边下边播。
|
|
func buildFFmpegArgs(info MediaInfo, inPath, outPath string) []string {
|
|
args := []string{"-y", "-i", inPath}
|
|
videoOK := true
|
|
for _, codec := range info.VideoCodecs {
|
|
if !browserCompatibleVideoCodecs[strings.ToLower(codec)] {
|
|
videoOK = false
|
|
break
|
|
}
|
|
}
|
|
audioOK := true
|
|
for _, codec := range info.AudioCodecs {
|
|
if !browserCompatibleAudioCodecs[strings.ToLower(codec)] {
|
|
audioOK = false
|
|
break
|
|
}
|
|
}
|
|
if videoOK {
|
|
args = append(args, "-c:v", "copy")
|
|
} else {
|
|
args = append(args,
|
|
"-c:v", "libx264",
|
|
"-preset", "veryfast",
|
|
"-crf", "23",
|
|
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
|
"-pix_fmt", "yuv420p",
|
|
)
|
|
}
|
|
if len(info.AudioCodecs) == 0 {
|
|
args = append(args, "-an")
|
|
} else if audioOK {
|
|
args = append(args, "-c:a", "copy")
|
|
} else {
|
|
args = append(args, "-c:a", "aac", "-b:a", "128k")
|
|
}
|
|
args = append(args, "-movflags", "+faststart", "-f", "mp4", outPath)
|
|
return args
|
|
}
|
|
|
|
// TranscodeFile 把本地输入文件转成浏览器可播的 MP4 写到 outPath。
|
|
func TranscodeFile(ctx context.Context, ffmpegPath string, info MediaInfo, inPath, outPath string) error {
|
|
args := buildFFmpegArgs(info, inPath, outPath)
|
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("transcode: ffmpeg: %w: %s", err, tailOf(string(out), 400))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tailOf(s string, n int) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[len(s)-n:]
|
|
}
|