exec.Command does not invoke a shell, so the wrapping " characters were inserted into the muxed tag value verbatim (e.g. TITLE read as "Snatch" instead of Snatch). Use unquoted Sprintf format strings.
361 lines
10 KiB
Go
361 lines
10 KiB
Go
package encoder
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"videnc-vibe/pkg/types"
|
|
)
|
|
|
|
var idetSummaryRegex = regexp.MustCompile(`Multi frame detection:\s+TFF:\s*(\d+)\s+BFF:\s*(\d+)\s+Progressive:\s*(\d+)\s+Undetermined:\s*(\d+)`)
|
|
|
|
type Encoder struct {
|
|
ffmpegPath string
|
|
ffprobePath string
|
|
opusencPath string
|
|
}
|
|
|
|
type StreamInfo struct {
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
SampleAspectRatio string `json:"sample_aspect_ratio"`
|
|
DisplayAspectRatio string `json:"display_aspect_ratio"`
|
|
CodecName string `json:"codec_name"`
|
|
}
|
|
|
|
type ProbeResult struct {
|
|
Streams []StreamInfo `json:"streams"`
|
|
}
|
|
|
|
type StreamMetadata struct {
|
|
Index int `json:"index"`
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
Language string `json:"tags"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (e *Encoder) GetStreamLanguages(path string) ([]StreamMetadata, error) {
|
|
cmd := exec.Command(e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffprobe error: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Streams []struct {
|
|
Index int `json:"index"`
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"streams"`
|
|
}
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return nil, fmt.Errorf("parsing ffprobe: %w", err)
|
|
}
|
|
|
|
var streams []StreamMetadata
|
|
for _, s := range result.Streams {
|
|
lang := ""
|
|
title := ""
|
|
if s.Tags != nil {
|
|
lang = s.Tags["language"]
|
|
title = s.Tags["title"]
|
|
}
|
|
streams = append(streams, StreamMetadata{
|
|
Index: s.Index,
|
|
CodecType: s.CodecType,
|
|
CodecName: s.CodecName,
|
|
Language: lang,
|
|
Title: title,
|
|
})
|
|
}
|
|
|
|
fmt.Printf("DEBUG stream languages: %+v\n", streams)
|
|
return streams, nil
|
|
}
|
|
|
|
func New() *Encoder {
|
|
return &Encoder{
|
|
ffmpegPath: "ffmpeg",
|
|
ffprobePath: "ffprobe",
|
|
opusencPath: "opusenc",
|
|
}
|
|
}
|
|
|
|
func (e *Encoder) CheckDeps() error {
|
|
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
|
|
return fmt.Errorf("ffmpeg not found")
|
|
}
|
|
if _, err := exec.LookPath(e.ffprobePath); err != nil {
|
|
return fmt.Errorf("ffprobe not found")
|
|
}
|
|
if _, err := exec.LookPath(e.opusencPath); err != nil {
|
|
return fmt.Errorf("opusenc not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Encoder) GetMediaInfo(path string) (width, height int, interlaced bool, err error) {
|
|
cmd := exec.Command(e.ffprobePath, "-v", "error", "-show_streams", "-print_format", "json", path)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return 0, 0, false, fmt.Errorf("ffprobe error: %w", err)
|
|
}
|
|
|
|
fmt.Printf("DEBUG ffprobe output: %s\n", string(output))
|
|
|
|
var result ProbeResult
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return 0, 0, false, fmt.Errorf("parsing ffprobe output: %w", err)
|
|
}
|
|
|
|
for _, stream := range result.Streams {
|
|
if stream.CodecName == "mpeg2video" || stream.CodecName == "h264" || stream.CodecName == "hevc" {
|
|
width = stream.Width
|
|
height = stream.Height
|
|
fmt.Printf("DEBUG detected video: %dx%d, SAR=%s\n", width, height, stream.SampleAspectRatio)
|
|
break
|
|
}
|
|
}
|
|
|
|
cmd = exec.Command(e.ffmpegPath,
|
|
"-hide_banner",
|
|
"-nostats",
|
|
"-i", path,
|
|
"-vf", "idet",
|
|
"-frames:v", "400",
|
|
"-an", "-sn",
|
|
"-f", "null", "-",
|
|
)
|
|
idetOut, _ := cmd.CombinedOutput()
|
|
interlaced = detectInterlaced(string(idetOut))
|
|
|
|
fmt.Printf("DEBUG interlaced: %v\n", interlaced)
|
|
|
|
return width, height, interlaced, nil
|
|
}
|
|
|
|
// detectInterlaced parses ffmpeg idet's "Multi frame detection" summary line
|
|
// and returns true when TFF+BFF clearly outnumber Progressive frames among
|
|
// the decided frames. Undetermined frames are ignored. If the summary line
|
|
// is missing, returns false (default to progressive).
|
|
func detectInterlaced(idetOutput string) bool {
|
|
m := idetSummaryRegex.FindStringSubmatch(idetOutput)
|
|
if m == nil {
|
|
return false
|
|
}
|
|
tff, _ := strconv.Atoi(m[1])
|
|
bff, _ := strconv.Atoi(m[2])
|
|
prog, _ := strconv.Atoi(m[3])
|
|
interlaced := tff + bff
|
|
decided := interlaced + prog
|
|
if decided == 0 {
|
|
return false
|
|
}
|
|
return interlaced > prog
|
|
}
|
|
|
|
func (e *Encoder) calculateZscaleWidth(path string, originalHeight int) (string, int, error) {
|
|
cmd := exec.Command(e.ffprobePath, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,sample_aspect_ratio", "-print_format", "json", path)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("ffprobe error: %w", err)
|
|
}
|
|
|
|
fmt.Printf("DEBUG ffprobe for zscale: %s\n", string(output))
|
|
|
|
var result ProbeResult
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return "", 0, fmt.Errorf("parsing ffprobe: %w", err)
|
|
}
|
|
|
|
if len(result.Streams) == 0 {
|
|
return "", 0, fmt.Errorf("no video streams found")
|
|
}
|
|
|
|
stream := result.Streams[0]
|
|
width := stream.Width
|
|
height := stream.Height
|
|
|
|
sar := stream.SampleAspectRatio
|
|
fmt.Printf("DEBUG stream: width=%d, height=%d, sar=%s\n", width, height, sar)
|
|
|
|
newWidth := width
|
|
|
|
if sar != "1:1" && sar != "N/A" && sar != "" {
|
|
parts := strings.Split(sar, ":")
|
|
if len(parts) == 2 {
|
|
num, err1 := strconv.Atoi(parts[0])
|
|
den, err2 := strconv.Atoi(parts[1])
|
|
if err1 == nil && err2 == nil && den != 0 {
|
|
newWidth = (width * num) / den
|
|
fmt.Printf("DEBUG calculated new width: %d (sar=%s)\n", newWidth, sar)
|
|
}
|
|
}
|
|
}
|
|
|
|
zscale := fmt.Sprintf("zscale=w=%d:h=%d:filter=spline36", newWidth, height)
|
|
return zscale, newWidth, nil
|
|
}
|
|
|
|
func (e *Encoder) Transcode(input string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
|
|
dir := filepath.Dir(input)
|
|
audioWavs, err := e.extractAudio(input, dir)
|
|
if err != nil {
|
|
return fmt.Errorf("extracting audio: %w", err)
|
|
}
|
|
defer e.cleanupWavs(audioWavs)
|
|
|
|
opusFiles, err := e.encodeOpus(audioWavs, dir)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding opus: %w", err)
|
|
}
|
|
defer e.cleanupOpus(opusFiles)
|
|
|
|
if err := e.encodeVideo(input, opusFiles, job, metadata, interlaced, streamLangs); err != nil {
|
|
return fmt.Errorf("encoding video: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Encoder) extractAudio(input, dir string) ([]string, error) {
|
|
cmd := exec.Command(e.ffmpegPath, "-i", input,
|
|
"-vn", "-c:a", "pcm_s16le", "-ar", "48000",
|
|
"-f", "wav", filepath.Join(dir, "audio.wav"))
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg extract: %s %w", out, err)
|
|
}
|
|
|
|
matches, _ := filepath.Glob(filepath.Join(dir, "audio.wav"))
|
|
return matches, nil
|
|
}
|
|
|
|
func (e *Encoder) encodeOpus(wavs []string, dir string) ([]string, error) {
|
|
var opusFiles []string
|
|
for _, wav := range wavs {
|
|
out := strings.Replace(wav, ".wav", ".opus", 1)
|
|
cmd := exec.Command(e.opusencPath, "--bitrate", "128k", wav, out)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return nil, fmt.Errorf("opusenc: %s %w", out, err)
|
|
}
|
|
opusFiles = append(opusFiles, out)
|
|
}
|
|
return opusFiles, nil
|
|
}
|
|
|
|
func (e *Encoder) encodeVideo(input string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
|
|
dir := filepath.Dir(input)
|
|
outFile := filepath.Join(dir, "output.mkv")
|
|
|
|
svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s")
|
|
|
|
zscaleStr, newWidth, err := e.calculateZscaleWidth(input, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("calculating zscale: %w", err)
|
|
}
|
|
|
|
fmt.Printf("DEBUG zscale: %s (newWidth=%d)\n", zscaleStr, newWidth)
|
|
|
|
vf := zscaleStr
|
|
if interlaced {
|
|
vf = "bwdif=mode=0:par=-1:-1," + vf
|
|
}
|
|
|
|
fmt.Printf("DEBUG final vf: %s\n", vf)
|
|
|
|
args := []string{
|
|
"-y",
|
|
"-i", input,
|
|
}
|
|
|
|
for _, opus := range opusFiles {
|
|
args = append(args, "-i", opus)
|
|
}
|
|
|
|
args = append(args, "-vf", vf)
|
|
args = append(args, "-c:v", "libsvtav1")
|
|
args = append(args, "-crf", strconv.Itoa(job.CRF))
|
|
args = append(args, "-preset", strconv.Itoa(job.Preset))
|
|
args = append(args, "-svtav1-params", svtParams)
|
|
args = append(args, "-pix_fmt", "yuv420p10le")
|
|
|
|
args = append(args, "-map", "0:v")
|
|
args = append(args, "-map", "0:s?")
|
|
|
|
for i := range opusFiles {
|
|
args = append(args, "-map", fmt.Sprintf("%d:a", i+1))
|
|
}
|
|
|
|
args = append(args, "-c:a", "copy")
|
|
args = append(args, "-c:s", "copy")
|
|
args = append(args, "-map_metadata", "-1")
|
|
|
|
for _, stream := range streamLangs {
|
|
if stream.CodecType == "audio" && stream.Language != "" {
|
|
idx := 1
|
|
for _, s := range streamLangs {
|
|
if s.CodecType == "audio" && s.Index < stream.Index {
|
|
idx++
|
|
}
|
|
}
|
|
args = append(args, fmt.Sprintf("-metadata:s:a:%d", idx-1), fmt.Sprintf("language=%s", stream.Language))
|
|
fmt.Printf("DEBUG setting audio language: stream %d -> language=%s\n", idx-1, stream.Language)
|
|
}
|
|
if stream.CodecType == "subtitle" && stream.Language != "" {
|
|
idx := 0
|
|
for _, s := range streamLangs {
|
|
if s.CodecType == "subtitle" && s.Index < stream.Index {
|
|
idx++
|
|
}
|
|
}
|
|
args = append(args, fmt.Sprintf("-metadata:s:s:%d", idx), fmt.Sprintf("language=%s", stream.Language))
|
|
fmt.Printf("DEBUG setting subtitle language: stream %d -> language=%s\n", idx, stream.Language)
|
|
}
|
|
}
|
|
|
|
args = append(args, "-metadata", fmt.Sprintf("TITLE=%s", metadata.Title))
|
|
args = append(args, "-metadata", fmt.Sprintf("DATE_RELEASED=%s", metadata.DateReleased))
|
|
args = append(args, "-metadata", fmt.Sprintf("IMDBID=%s", metadata.IMDBID))
|
|
args = append(args, "-metadata", fmt.Sprintf("ORIGINAL_MEDIA_TYPE=%s", metadata.OriginalMedia))
|
|
|
|
if metadata.IsSeries {
|
|
args = append(args, "-metadata", fmt.Sprintf("COLLECTION=%s", metadata.Collection))
|
|
args = append(args, "-metadata", fmt.Sprintf("SEASON=%s", metadata.Season))
|
|
args = append(args, "-metadata", fmt.Sprintf("EPISODE=%s", metadata.Episode))
|
|
if metadata.TVMazeID != "" {
|
|
args = append(args, "-metadata", fmt.Sprintf("TVMAZE_ID=%s", metadata.TVMazeID))
|
|
}
|
|
}
|
|
|
|
args = append(args, outFile)
|
|
|
|
args = append([]string{"-hide_banner", "-v", "error"}, args...)
|
|
cmd := exec.Command(e.ffmpegPath, args...)
|
|
fmt.Printf("DEBUG FFmpeg command: ffmpeg %s\n", strings.Join(args, " "))
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("ffmpeg encode: %s %w", out, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Encoder) cleanupWavs(files []string) {
|
|
for _, f := range files {
|
|
os.Remove(f)
|
|
}
|
|
}
|
|
|
|
func (e *Encoder) cleanupOpus(files []string) {
|
|
for _, f := range files {
|
|
os.Remove(f)
|
|
}
|
|
}
|