Bug #2: calculateZscaleWidth previously emitted a zscale filter even when no rescale was needed (SAR 1:1, N/A, empty, or computed width equal to the source). encodeVideo then unconditionally appended it to -vf, forcing a pointless colorspace round-trip. Return an empty filter string in those cases and build the -vf chain conditionally; omit -vf entirely when no filters apply. Bug #23: the SAR-to-width math used integer truncation, producing odd or off-by-one widths (e.g. 853 instead of 854 for 32:27 at 1920), and the guard accepted SAR 0:N which zeroed the output width. Reject zero-numerator SARs and round to the nearest integer then mask to an even width for AV1/H.264 mod-2 alignment.
382 lines
11 KiB
Go
382 lines
11 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"`
|
|
CodecType string `json:"codec_type"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
foundVideo := false
|
|
for _, stream := range result.Streams {
|
|
if stream.CodecType == "video" {
|
|
width = stream.Width
|
|
height = stream.Height
|
|
foundVideo = true
|
|
fmt.Printf("DEBUG detected video: %dx%d, codec=%s, SAR=%s\n", width, height, stream.CodecName, stream.SampleAspectRatio)
|
|
break
|
|
}
|
|
}
|
|
if !foundVideo {
|
|
return 0, 0, false, fmt.Errorf("no video stream found")
|
|
}
|
|
|
|
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 == "" {
|
|
return "", newWidth, nil
|
|
}
|
|
|
|
parts := strings.Split(sar, ":")
|
|
if len(parts) != 2 {
|
|
return "", newWidth, nil
|
|
}
|
|
num, err1 := strconv.Atoi(parts[0])
|
|
den, err2 := strconv.Atoi(parts[1])
|
|
if err1 != nil || err2 != nil || num == 0 || den == 0 {
|
|
return "", newWidth, nil
|
|
}
|
|
|
|
// Round to nearest, then snap down to even (mod-2 widths preferred by AV1/H.264).
|
|
newWidth = ((width*num + den/2) / den) &^ 1
|
|
fmt.Printf("DEBUG calculated new width: %d (sar=%s)\n", newWidth, sar)
|
|
|
|
if newWidth == width {
|
|
return "", newWidth, nil
|
|
}
|
|
|
|
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)
|
|
|
|
var filters []string
|
|
if interlaced {
|
|
filters = append(filters, "bwdif=mode=0:par=-1:-1")
|
|
}
|
|
if zscaleStr != "" {
|
|
filters = append(filters, zscaleStr)
|
|
}
|
|
|
|
fmt.Printf("DEBUG final vf: %s\n", strings.Join(filters, ","))
|
|
|
|
args := []string{
|
|
"-y",
|
|
"-i", input,
|
|
}
|
|
|
|
for _, opus := range opusFiles {
|
|
args = append(args, "-i", opus)
|
|
}
|
|
|
|
if len(filters) > 0 {
|
|
args = append(args, "-vf", strings.Join(filters, ","))
|
|
}
|
|
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)
|
|
}
|
|
}
|