package ffmpeg import ( "bufio" "context" "fmt" "log" "math" "os" "os/exec" "regexp" "strconv" "strings" "time" "hf-worker/internal/protocol" ) var ( timeRegex = regexp.MustCompile(`time=(\d+):(\d+):(\d+\.\d+)`) speedRegex = regexp.MustCompile(`speed=\s*([\d\.]+)x`) ) // GetDuration obtiene la duración en segundos del video usando ffprobe. // Aplica un timeout de 30s para que un ffprobe colgado no inmovilice la tarea. func GetDuration(ctx context.Context, filePath string) (float64, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filePath, ) out, err := cmd.Output() if err != nil { return 0, fmt.Errorf("error ejecutando ffprobe: %w", err) } str := strings.TrimSpace(string(out)) dur, err := strconv.ParseFloat(str, 64) if err != nil { return 0, fmt.Errorf("error convirtiendo duración '%s': %w", str, err) } return dur, nil } func findFontFile() string { candidates := []string{ "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/ttf-dejavu/DejaVuSans.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf", "C:\\Windows\\Fonts\\arial.ttf", } for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c } } return "" } // GetTextSubtitleStreams detecta las pistas de subtítulos de texto soportadas (srt, ass, ssa, mov_text, webvtt) func GetTextSubtitleStreams(ctx context.Context, filePath string) []int { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "s", "-show_entries", "stream=index,codec_name", "-of", "csv=p=0", filePath, ) out, err := cmd.Output() if err != nil { return nil } supportedCodecs := map[string]bool{ "subrip": true, "srt": true, "ass": true, "ssa": true, "mov_text": true, "webvtt": true, "text": true, } var streamIndices []int lines := strings.Split(strings.TrimSpace(string(out)), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) >= 2 { idx, err := strconv.Atoi(strings.TrimSpace(parts[0])) codec := strings.ToLower(strings.TrimSpace(parts[1])) if err == nil && supportedCodecs[codec] { streamIndices = append(streamIndices, idx) } } } return streamIndices } const padFilter = "pad=ceil(iw/2)*2:ceil(ih/2)*2" var ( presetResRegex = regexp.MustCompile(`^\d{2,4}p?$`) customResRegex = regexp.MustCompile(`^\d{2,5}[x:]\d{2,5}$`) ) // BuildScaleFilter genera el filtro de video adaptado inteligentemente a videos horizontales y verticales. // Solo acepta formatos blancos ("720p", "854x480", "1280:720"); cualquier otra entrada cae al // valor seguro de solo-pad, porque la cadena se interpola en el filtergraph de FFmpeg. func BuildScaleFilter(res string) string { res = strings.TrimSpace(res) if res == "" || strings.EqualFold(res, "original") { return padFilter } lower := strings.ToLower(res) if presetResRegex.MatchString(lower) { cleanNum := strings.TrimSuffix(lower, "p") h, err := strconv.Atoi(cleanNum) if err == nil && h > 0 && h <= 4320 { // Escala inteligente según orientación: // Horizontal (iw >= ih): ancho automático -2, altura H // Vertical / Shorts / Reels (iw < ih): ancho H, altura automática -2 return fmt.Sprintf("scale='if(gte(iw,ih),-2,%d)':'if(gte(iw,ih),%d,-2)',%s", h, h, padFilter) } return padFilter } if customResRegex.MatchString(lower) { cleanRes := strings.ReplaceAll(lower, "x", ":") return fmt.Sprintf("scale=%s:force_original_aspect_ratio=decrease,%s", cleanRes, padFilter) } return padFilter } // IsValidResolution indica si la cadena tiene un formato de resolución aceptado // por BuildScaleFilter ("720p", "854x480", "1280:720", "original" o vacío). func IsValidResolution(res string) bool { res = strings.TrimSpace(res) if res == "" || strings.EqualFold(res, "original") { return true } lower := strings.ToLower(res) return presetResRegex.MatchString(lower) || customResRegex.MatchString(lower) } // SanitizeWatermark deja el texto de la marca de agua en un conjunto de caracteres // inofensivo para el filtergraph de FFmpeg (drawtext se interpola entre comillas). func SanitizeWatermark(text string) string { var b strings.Builder b.Grow(len(text)) for _, r := range text { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) case r >= 0xC0 && r <= 0x2AF: // letras latinas acentuadas y extendidas b.WriteRune(r) case strings.ContainsRune(" -_.()!?¡¿#@&+=", r): b.WriteRune(r) default: b.WriteByte(' ') } } runes := []rune(strings.TrimSpace(b.String())) if len(runes) > 60 { runes = runes[:60] } return string(runes) } // escapeFontPath adapta una ruta de fuente al filtergraph: forward slashes y sin comillas. func escapeFontPath(p string) string { p = strings.ReplaceAll(p, "\\", "/") return strings.ReplaceAll(p, "'", "") } var ( validCodecs = map[string]bool{"libx264": true, "libx265": true, "h264_qsv": true, "hevc_qsv": true, "h264_nvenc": true, "hevc_nvenc": true, "h264_amf": true, "hevc_amf": true, "libvpx-vp9": true, "libaom-av1": true, "libsvtav1": true, "mpeg4": true, "copy": true} validPresets = map[string]bool{"ultrafast": true, "superfast": true, "veryfast": true, "faster": true, "fast": true, "medium": true, "slow": true, "slower": true, "veryslow": true} posNumRegex = regexp.MustCompile(`^\d{1,3}(\.\d{1,2})?$`) bitrateRegex = regexp.MustCompile(`^\d{1,4}k?$`) ) // SanitizeSettings devuelve una copia de los ajustes con cada campo validado contra // su dominio; los valores inválidos caen a defaults seguros. Defensa en profundidad: // los ajustes vienen de usuarios de Telegram y pasan por Mongo antes de llegar aquí. func SanitizeSettings(s protocol.VideoSettings) protocol.VideoSettings { s.Resolution = strings.TrimSpace(s.Resolution) s.Watermark = SanitizeWatermark(s.Watermark) s.Format = strings.ToLower(strings.TrimSpace(s.Format)) if s.Format != "avi" && s.Format != "mp4" { s.Format = "mp4" } s.Codec = strings.ToLower(strings.TrimSpace(s.Codec)) if s.Codec != "" && !validCodecs[s.Codec] { s.Codec = "" } s.Preset = strings.ToLower(strings.TrimSpace(s.Preset)) if s.Preset != "" && !validPresets[s.Preset] { s.Preset = "" } s.CRF = strings.TrimSpace(s.CRF) if s.CRF != "" { if !posNumRegex.MatchString(s.CRF) { s.CRF = "" } else if v, err := strconv.ParseFloat(s.CRF, 64); err != nil || v > 63 { s.CRF = "" } } s.FPS = strings.TrimSpace(s.FPS) if s.FPS != "" { if !posNumRegex.MatchString(s.FPS) { s.FPS = "" } else if v, err := strconv.ParseFloat(s.FPS, 64); err != nil || v < 1 || v > 120 { s.FPS = "" } } s.AudioBitrate = strings.ToLower(strings.TrimSpace(s.AudioBitrate)) if s.AudioBitrate != "" && !bitrateRegex.MatchString(s.AudioBitrate) { s.AudioBitrate = "" } return s } // wmTextPosition devuelve las expresiones x/y de drawtext para la posición elegida // ("tr" arriba-derecha, "tl" arriba-izquierda, "br" abajo-derecha, "bl" abajo-izquierda). func wmTextPosition(pos string) (x, y string) { switch strings.ToLower(strings.TrimSpace(pos)) { case "tl": return "10", "10" case "br": return "w-tw-10", "h-th-10" case "bl": return "10", "h-th-10" default: // "tr" y cualquier valor vacío/desconocido return "w-tw-10", "10" } } // wmOverlayExpr devuelve la expresión del filtro overlay para la marca de agua de imagen. func wmOverlayExpr(pos string) string { switch strings.ToLower(strings.TrimSpace(pos)) { case "tl": return "10:10" case "br": return "W-w-10:H-h-10" case "bl": return "10:H-h-10" default: return "W-w-10:10" } } // Compress ejecuta la compresión usando FFmpeg y reporta el progreso en vivo. func Compress( ctx context.Context, inputPath, outputPath string, settings protocol.VideoSettings, wmImagePath string, totalDuration float64, onProgress func(p protocol.TaskProgress), ) error { return CompressWithCmd(ctx, inputPath, outputPath, settings, wmImagePath, totalDuration, nil, onProgress) } // isSubtitleOrMuxError comprueba si el error de stderr de FFmpeg está relacionado // con pistas de subtítulos corruptas o fallos de multiplexado por timestamps inválidos. func isSubtitleOrMuxError(errStr string) bool { lower := strings.ToLower(errStr) indicators := []string{ "application provided duration", "mov_text", "subtitle", "av_interleaved_write_frame", "invalid argument", "error while writing stream", "pts has no value", "out of range for mov/mp4", "invalid dts", "invalid pts", "muxing", } for _, ind := range indicators { if strings.Contains(lower, ind) { return true } } return false } // buildCompressArgs genera la lista de argumentos para FFmpeg, permitiendo habilitar o deshabilitar subtítulos. func buildCompressArgs( ctx context.Context, inputPath, outputPath string, settings protocol.VideoSettings, wmImagePath string, includeSubtitles bool, ) ([]string, bool) { settings = SanitizeSettings(settings) args := []string{ "-y", "-i", inputPath, } useImageWM := wmImagePath != "" if useImageWM { args = append(args, "-i", wmImagePath) } if useImageWM { // Cadena con filter_complex: escala/pad del video principal, logo // escalado a máx. 150px de ancho y overlay en la posición elegida. chain := "[0:v]" + BuildScaleFilter(settings.Resolution) + "[vbase];" + "[1:v]scale='if(gt(iw,150),150,iw)':-1[wm];" + "[vbase][wm]overlay=" + wmOverlayExpr(settings.WatermarkPos) + "[v]" args = append(args, "-filter_complex", chain, "-map", "[v]", "-map", "0:a?", ) } else { var vfParts []string vfParts = append(vfParts, BuildScaleFilter(settings.Resolution)) if settings.Watermark != "" { x, y := wmTextPosition(settings.WatermarkPos) fontPath := findFontFile() if fontPath != "" { vfParts = append(vfParts, fmt.Sprintf("drawtext=fontfile='%s':text='%s':x=%s:y=%s:fontsize=20:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=4", escapeFontPath(fontPath), settings.Watermark, x, y)) } else { vfParts = append(vfParts, fmt.Sprintf("drawtext=font='Sans':text='%s':x=%s:y=%s:fontsize=20:fontcolor=white", settings.Watermark, x, y)) } } vfFilter := strings.Join(vfParts, ",") args = append(args, "-map", "0:v:0", "-map", "0:a?") if vfFilter != "" { args = append(args, "-vf", vfFilter) } } isAVI := strings.EqualFold(settings.Format, "avi") || strings.HasSuffix(strings.ToLower(outputPath), ".avi") hasSubtitles := false // Preservar subtítulos de texto si existen en el contenedor original (solo MP4/MKV, mov_text es incompatible con AVI) if !isAVI && includeSubtitles { textSubStreams := GetTextSubtitleStreams(ctx, inputPath) for _, idx := range textSubStreams { args = append(args, "-map", fmt.Sprintf("0:%d", idx)) } if len(textSubStreams) > 0 { args = append(args, "-c:s", "mov_text") hasSubtitles = true } } codec := settings.Codec if codec == "" { codec = "libx264" } args = append(args, "-c:v", codec) if settings.Preset != "" && codec != "copy" { args = append(args, "-preset", settings.Preset) } if settings.CRF != "" && codec != "copy" { args = append(args, "-crf", settings.CRF) } if settings.FPS != "" && codec != "copy" { args = append(args, "-r", settings.FPS) } audioBitrate := settings.AudioBitrate if audioBitrate == "" { audioBitrate = "128k" } // Optimización Mono para audio de voz ≤ 64k (mejora nitidez y ahorra ancho de banda) acChannels := "2" cleanBitrate := strings.TrimSuffix(strings.ToLower(audioBitrate), "k") if brVal, err := strconv.Atoi(cleanBitrate); err == nil && brVal <= 64 { acChannels = "1" } audioCodec := "aac" if isAVI { // En contenedor AVI, mp3 (libmp3lame) es el códec de audio estándar universal audioCodec = "libmp3lame" } args = append(args, "-pix_fmt", "yuv420p", "-c:a", audioCodec, "-b:a", audioBitrate, "-ac", acChannels, ) // -movflags +faststart es exclusivo de contenedores MP4/MOV; falla en AVI if !isAVI { args = append(args, "-movflags", "+faststart") } args = append(args, "-threads", "0", outputPath, ) return args, hasSubtitles } // executeFFmpegCmd inicia y monitorea el proceso de compresión de FFmpeg. func executeFFmpegCmd( ctx context.Context, args []string, totalDuration float64, onCmd func(cmd *exec.Cmd), onProgress func(p protocol.TaskProgress), ) error { cmd := exec.CommandContext(ctx, "ffmpeg", args...) cmd.Cancel = func() error { if cmd.Process != nil { return cmd.Process.Kill() } return nil } cmd.WaitDelay = 1 * time.Second stderr, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("error obteniendo stderr pipe de ffmpeg: %w", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("error iniciando ffmpeg: %w", err) } if onCmd != nil { onCmd(cmd) } stopWatch := make(chan struct{}) defer close(stopWatch) go func() { select { case <-ctx.Done(): if cmd.Process != nil { _ = cmd.Process.Kill() } case <-stopWatch: } }() scanner := bufio.NewScanner(stderr) // FFmpeg usa retorno de carro '\r' para actualizar la línea de estado scanner.Split(scanCRLFOrCR) var lastReport time.Time var lastStderrLines []string for scanner.Scan() { line := scanner.Text() lastStderrLines = append(lastStderrLines, line) if len(lastStderrLines) > 30 { lastStderrLines = lastStderrLines[1:] } // Buscar time=HH:MM:SS.ms matches := timeRegex.FindStringSubmatch(line) if len(matches) == 4 { hours, _ := strconv.ParseFloat(matches[1], 64) mins, _ := strconv.ParseFloat(matches[2], 64) secs, _ := strconv.ParseFloat(matches[3], 64) currentTimeSec := hours*3600 + mins*60 + secs percent := 0.0 if totalDuration > 0 { percent = math.Min(100.0, (currentTimeSec/totalDuration)*100.0) } // Extraer velocidad si está disponible speed := "1.0x" speedMatches := speedRegex.FindStringSubmatch(line) if len(speedMatches) == 2 { speed = speedMatches[1] + "x" } // Calcular ETA var etaStr string if totalDuration > 0 && currentTimeSec > 0 { remainingSec := math.Max(0, totalDuration-currentTimeSec) speedVal, err := strconv.ParseFloat(strings.TrimSuffix(speed, "x"), 64) if err == nil && speedVal > 0 { realRemainingSec := remainingSec / speedVal rem := int(realRemainingSec) etaStr = fmt.Sprintf("%02d:%02d:%02d", rem/3600, (rem%3600)/60, rem%60) } } timeStr := fmt.Sprintf("%02d:%02d:%02d", int(hours), int(mins), int(secs)) // Limitar reportes a 1 por segundo para no saturar la red if time.Since(lastReport) >= 1*time.Second { lastReport = time.Now() if onProgress != nil { onProgress(protocol.TaskProgress{ Percent: math.Round(percent*10) / 10, Speed: speed, Time: timeStr, ETA: etaStr, }) } } } } if err := cmd.Wait(); err != nil { if ctx.Err() != nil { return fmt.Errorf("compresión cancelada") } detail := strings.Join(lastStderrLines, "\n") return fmt.Errorf("ffmpeg terminó con error (%w):\n%s", err, detail) } return nil } // CompressWithCmd ejecuta la compresión y permite capturar el puntero a *exec.Cmd para terminación forzosa inmediata. // Incluye fallback automático: si la compresión falla debido a subtítulos corruptos o tiempos inválidos, // reintenta automáticamente descartando los subtítulos para no fallar la tarea. func CompressWithCmd( ctx context.Context, inputPath, outputPath string, settings protocol.VideoSettings, wmImagePath string, totalDuration float64, onCmd func(cmd *exec.Cmd), onProgress func(p protocol.TaskProgress), ) error { // 1. Intento primario conservando subtítulos de texto si existen args, hasSubtitles := buildCompressArgs(ctx, inputPath, outputPath, settings, wmImagePath, true) err := executeFFmpegCmd(ctx, args, totalDuration, onCmd, onProgress) if err == nil { return nil } if ctx.Err() != nil { return err } // 2. Fallback: si tenía subtítulos y falló por corrupción en subtítulos/muxing, reintentar sin subtítulos if hasSubtitles && isSubtitleOrMuxError(err.Error()) { log.Printf("[ffmpeg] Advertencia: falló compresión con subtítulos (%v). Reintentando sin subtítulos...", err) _ = os.Remove(outputPath) retryArgs, _ := buildCompressArgs(ctx, inputPath, outputPath, settings, wmImagePath, false) retryErr := executeFFmpegCmd(ctx, retryArgs, totalDuration, onCmd, onProgress) if retryErr == nil { log.Printf("[ffmpeg] Compresión completada con éxito tras descartar subtítulos corruptos.") return nil } return retryErr } return err } // GenerateThumbnail extrae un frame del video para usarlo como miniatura en Telegram func GenerateThumbnail(ctx context.Context, videoPath, thumbPath string, atSeconds float64) error { ss := fmt.Sprintf("%.2f", atSeconds) cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-ss", ss, "-i", videoPath, "-vframes", "1", "-q:v", "2", thumbPath, ) return cmd.Run() } // NormalizeThumbnailImage convierte cualquier imagen a JPEG 320x320 con recorte // centrado (requisito de Telegram para miniaturas de documentos). func NormalizeThumbnailImage(ctx context.Context, srcPath, dstPath string) error { cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", srcPath, "-vf", "scale=320:320:force_original_aspect_ratio=increase:flags=lanczos,crop=320:320", "-frames:v", "1", "-q:v", "3", dstPath, ) return cmd.Run() } // scanCRLFOrCR divide tokens por \n o por \r func scanCRLFOrCR(data []byte, atEOF bool) (advance int, token []byte, err error) { if atEOF && len(data) == 0 { return 0, nil, nil } for i := 0; i < len(data); i++ { if data[i] == '\r' || data[i] == '\n' { return i + 1, data[0:i], nil } } if atEOF { return len(data), data, nil } return 0, nil, nil }