FALCON / internal /ffmpeg /ffmpeg.go
st3v3x's picture
Actualizar nodo OmniVision
6681c7b verified
Raw History Blame Contribute Delete
18.3 kB
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
}