Download internal/ffmpeg/ffmpeg.go from st3v3x/FALCON: direct link, hf CLI and curl.
- Browser
- Download file 18.3 kB
-
https://huggingface.co/spaces/st3v3x/FALCON/resolve/main/internal/ffmpeg/ffmpeg.go
- Command line
-
hf download hf://spaces/st3v3x/FALCON/internal/ffmpeg/ffmpeg.go
-
curl -L -o ffmpeg.go https://huggingface.co/spaces/st3v3x/FALCON/resolve/main/internal/ffmpeg/ffmpeg.go
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 | |
| } | |