- dashboard.html: remove standalone "Mounted Disk" input panel; show all disks from GET /api/disks (watcher), auto-refresh every 5s - detect.go: use avg_frame_rate when r_frame_rate is unrealistic (>120 fps or 0), fixes MJPEG/mjpeg showing 90000fps - transcoder.go: parse fps= from ffmpeg progress output and expose in Progress struct - copier.go: update task message with real-time encoding fps (@ 45.3 fps), clear speed_bps/eta during transcoding to avoid showing stale copy speed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
4.0 KiB
Go
179 lines
4.0 KiB
Go
package transcoder
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"jukebox_maker/internal/disk"
|
||
)
|
||
|
||
type VideoInfo struct {
|
||
Codec string
|
||
Width int
|
||
Height int
|
||
FPS float64
|
||
DurationSec float64
|
||
VideoBitrate int64
|
||
AudioCodec string
|
||
AudioChannels int
|
||
}
|
||
|
||
func ProbeVideo(path string) (VideoInfo, error) {
|
||
out, err := exec.Command("ffprobe",
|
||
"-v", "quiet",
|
||
"-print_format", "json",
|
||
"-show_streams",
|
||
"-show_format",
|
||
path,
|
||
).Output()
|
||
if err != nil {
|
||
return VideoInfo{}, err
|
||
}
|
||
|
||
var raw struct {
|
||
Streams []struct {
|
||
CodecType string `json:"codec_type"`
|
||
CodecName string `json:"codec_name"`
|
||
Width int `json:"width"`
|
||
Height int `json:"height"`
|
||
RFrameRate string `json:"r_frame_rate"`
|
||
AvgFrameRate string `json:"avg_frame_rate"`
|
||
BitRate string `json:"bit_rate"`
|
||
Channels int `json:"channels"`
|
||
} `json:"streams"`
|
||
Format struct {
|
||
Duration string `json:"duration"`
|
||
BitRate string `json:"bit_rate"`
|
||
} `json:"format"`
|
||
}
|
||
if err := json.Unmarshal(out, &raw); err != nil {
|
||
return VideoInfo{}, err
|
||
}
|
||
|
||
var info VideoInfo
|
||
for _, s := range raw.Streams {
|
||
switch s.CodecType {
|
||
case "video":
|
||
info.Codec = s.CodecName
|
||
info.Width = s.Width
|
||
info.Height = s.Height
|
||
// avg_frame_rate надёжнее для MJPEG и кодеков с нестандартным таймбейсом
|
||
fps := parseFraction(s.RFrameRate)
|
||
if avg := parseFraction(s.AvgFrameRate); avg > 0 && (fps <= 0 || fps > 120) {
|
||
fps = avg
|
||
}
|
||
info.FPS = fps
|
||
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
|
||
info.VideoBitrate = br
|
||
}
|
||
case "audio":
|
||
if info.AudioCodec == "" {
|
||
info.AudioCodec = s.CodecName
|
||
info.AudioChannels = s.Channels
|
||
}
|
||
}
|
||
}
|
||
if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil {
|
||
info.DurationSec = d
|
||
}
|
||
// Если стрим-битрейт не известен — используем битрейт контейнера
|
||
if info.VideoBitrate == 0 {
|
||
if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil {
|
||
info.VideoBitrate = br
|
||
}
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
func NeedsTranscode(info VideoInfo, p *disk.TranscodeProfile) bool {
|
||
// Несовместимый видеокодек
|
||
if normalizeCodec(info.Codec) != p.VideoCodec {
|
||
return true
|
||
}
|
||
|
||
// Разрешение превышает лимит
|
||
if maxH := maxHeight(p.MaxResolution); maxH > 0 && info.Height > maxH {
|
||
return true
|
||
}
|
||
|
||
// Битрейт видео превышает лимит
|
||
if p.MaxVideoBitrate != "" {
|
||
if maxBR := parseBitrate(p.MaxVideoBitrate); maxBR > 0 && info.VideoBitrate > maxBR {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// FPS превышает лимит
|
||
if p.MaxFPS > 0 && info.FPS > float64(p.MaxFPS)+0.01 {
|
||
return true
|
||
}
|
||
|
||
// Несовместимый аудиокодек
|
||
if p.AudioCodec != "" && normalizeCodec(info.AudioCodec) != p.AudioCodec {
|
||
return true
|
||
}
|
||
|
||
// Каналов больше лимита
|
||
if p.MaxAudioChannels > 0 && info.AudioChannels > p.MaxAudioChannels {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
func normalizeCodec(codec string) string {
|
||
switch strings.ToLower(codec) {
|
||
case "hevc", "h265":
|
||
return "h265"
|
||
case "h264", "avc", "avc1":
|
||
return "h264"
|
||
case "mpeg4", "mp4v":
|
||
return "mpeg4"
|
||
case "aac":
|
||
return "aac"
|
||
case "mp3":
|
||
return "mp3"
|
||
default:
|
||
return strings.ToLower(codec)
|
||
}
|
||
}
|
||
|
||
func maxHeight(res string) int {
|
||
switch res {
|
||
case "480p":
|
||
return 480
|
||
case "720p":
|
||
return 720
|
||
case "1080p":
|
||
return 1080
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func parseBitrate(s string) int64 {
|
||
s = strings.ToLower(strings.TrimSpace(s))
|
||
if strings.HasSuffix(s, "k") {
|
||
v, _ := strconv.ParseInt(s[:len(s)-1], 10, 64)
|
||
return v * 1000
|
||
}
|
||
v, _ := strconv.ParseInt(s, 10, 64)
|
||
return v
|
||
}
|
||
|
||
func parseFraction(s string) float64 {
|
||
parts := strings.SplitN(s, "/", 2)
|
||
if len(parts) != 2 {
|
||
v, _ := strconv.ParseFloat(s, 64)
|
||
return v
|
||
}
|
||
num, _ := strconv.ParseFloat(parts[0], 64)
|
||
den, _ := strconv.ParseFloat(parts[1], 64)
|
||
if den == 0 {
|
||
return 0
|
||
}
|
||
return num / den
|
||
}
|