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 != "" { 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) } }