package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "os/exec" "strings" "time" "github.com/aws/aws-sdk-go/aws/credentials" "math/rand" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/gin-gonic/gin" "github.com/joho/godotenv" ffmpeg "github.com/u2takey/ffmpeg-go" ) const CLOUD_PREFIX string = "https://s.repetitor.ru" const OUTPUT_CLOUD_PREFIX string = "https://s.repetitor.ru/video/encode/" const RELATIVE_CLOUD_PATH string = "/video/encode/" const VIDEO_WIDTH = 1280 const VIDEO_HEIGHT = 720 // VideoMetadata содержит информацию о ширине и высоте видео type VideoMetadata struct { Streams []struct { Width int `json:"width"` Height int `json:"height"` } `json:"streams"` } type RequestBody struct { Url string } const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" func randomString(n int) string { sb := strings.Builder{} sb.Grow(n) for i := 0; i < n; i++ { sb.WriteByte(charset[rand.Intn(len(charset))]) } return sb.String() } func main() { rand.Seed(time.Now().UnixNano()) // Load .env file SetConfig() // Retrieve configurations from environment variables accessKey := os.Getenv("AWS_ACCESS_KEY_ID") secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") region := os.Getenv("AWS_DEFAULT_REGION") endpoint := os.Getenv("AWS_ENDPOINT_URL") bucketName := os.Getenv("AWS_S3_BUCKET_NAME") mode := os.Getenv("GIN_MODE") port := os.Getenv("SERVER_PORT") // Validate required AWS configurations if accessKey == "" || secretKey == "" || region == "" || endpoint == "" || bucketName == "" { log.Fatal("Missing required environment variables for AWS configuration") } // Set Gin mode if mode == "release" { gin.SetMode(gin.ReleaseMode) } else { gin.SetMode(gin.DebugMode) } // Default port if not set if port == "" { port = "8080" } // Create AWS session with custom endpoint cfg := &aws.Config{ Region: aws.String(region), Endpoint: aws.String(endpoint), Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""), } sess, err := session.NewSession(cfg) if err != nil { log.Fatalf("Failed to create AWS session: %v", err) } client := s3.New(sess) // Router setup router := gin.Default() router.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "pong", }) }) router.POST("/decode/", AuthMiddleware(), func(c *gin.Context) { var requestBody RequestBody if err := c.BindJSON(&requestBody); err != nil { log.Fatalf("Can't bind request body: %s", err) } url, _ := strings.CutPrefix( requestBody.Url, CLOUD_PREFIX, ) splitUrl := strings.Split(url, "/") filename := splitUrl[len(splitUrl)-1] log.Printf("url: %s", url) log.Printf("filename: %s", filename) if url == "" { c.JSON(http.StatusBadRequest, gin.H{ "message": "Url is required", }) return } log.Printf("Decoding video: %s...", url) outputFilename := CodeVideo(client, url, filename) log.Printf("Work Done!") c.IndentedJSON(http.StatusCreated, gin.H{ "url": fmt.Sprintf("%s%s", OUTPUT_CLOUD_PREFIX, outputFilename), }) }) log.Printf("Server started on port: %s, in mode: %s", port, mode) router.Run() } func SetConfig() { err := godotenv.Load() if err != nil { log.Fatalf("Config file not found with error: %s", err) } log.Printf("Config file loaded: %s", ".env") } // AuthMiddleware проверяет secret-key из заголовка Authorization func AuthMiddleware() gin.HandlerFunc { secretKey := os.Getenv("SECRET_KEY") if secretKey == "" { log.Fatal("Secret key not found") } return func(c *gin.Context) { // Извлекаем заголовок Authorization authHeader := c.GetHeader("Authorization") if authHeader == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) c.Abort() return } // Проверяем, совпадает ли ключ if authHeader != secretKey { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid secret key"}) c.Abort() return } // Если ключ валиден, продолжаем выполнение c.Next() } } // GetVideo загружает видео из S3 и сохраняет его во временный файл func GetVideo(client *s3.S3, url, filename string) string { bucket := os.Getenv("AWS_S3_BUCKET_NAME") response, err := client.GetObject(&s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(fmt.Sprintf("/%s", url)), }) if err != nil { log.Fatalf("Failed to download file from S3: %v", err) } defer func(Body io.ReadCloser) { err := Body.Close() if err != nil { log.Fatalf("Failed to close response body: %v", err) } }(response.Body) tempFile, err := os.Create(fmt.Sprintf("downloaded_%s_%s", randomString(5), filename)) if err != nil { log.Fatalf("Failed to create temporary file: %v", err) } defer func(tempFile *os.File) { err := tempFile.Close() if err != nil { log.Fatalf("Failed to close temporary file: %v", err) } }(tempFile) _, err = io.Copy(tempFile, response.Body) if err != nil { log.Fatalf("Failed to save file locally: %v", err) } log.Printf("File downloaded and saved to: %s", tempFile.Name()) return tempFile.Name() } // UploadVideo загружает обработанное видео обратно в S3 func UploadVideo(client *s3.S3, localFile string) { bucket := os.Getenv("AWS_S3_BUCKET_NAME") // Открываем локальный файл file, err := os.Open(localFile) if err != nil { log.Fatalf("Failed to open local file: %v", err) } defer func(file *os.File) { err := file.Close() if err != nil { log.Fatalf("Failed to close local file: %v", err) } }(file) // Закрываем файл после завершения функции // Загружаем файл в S3 напрямую из потока _, err = client.PutObject(&s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(fmt.Sprintf("%s%s", RELATIVE_CLOUD_PATH, localFile)), Body: file, // Передаем поток файла напрямую ContentType: aws.String(http.DetectContentType([]byte(localFile))), ContentDisposition: aws.String("attachment"), }) if err != nil { log.Fatalf("Failed to upload file to S3: %v", err) } log.Printf("File uploaded to S3 with key: video/encode/%s", localFile) } func splitByLastDot(s string) string { lastDotIndex := strings.LastIndex(s, ".") if lastDotIndex == -1 { return s } firstPart := s[:lastDotIndex] return firstPart } // CodeVideo обрабатывает видео и сохраняет его обратно в S3 func CodeVideo(client *s3.S3, url, filename string) string { // Скачиваем видео из S3 inputFile := GetVideo(client, url, filename) // Создаем временный файл для обработанного видео outputFile, err := os.Create(filename) if err != nil { log.Fatalf("Failed to create temporary output file: %v", err) return "" } outputFileName := splitByLastDot(outputFile.Name()) + randomString(5) + ".mp4" // Извлекаем ширину и высоту видео // width, height, err := getVideoDimensions() if err != nil { log.Fatalf("Ошибка извлечения размеров видео: %v", err) return "" } cropParams, err := detectCropParams(inputFile) if err != nil { log.Printf("Crop detection failed: %v. Proceeding without cropping.", err) return "" } else { log.Printf("Detected crop parameters: %s", cropParams) } croppedFile := "cropped_temp" + randomString(5) + ".mp4" if cropParams != "" { err = ffmpeg.Input(inputFile). Filter("crop", ffmpeg.Args{cropParams}). Output(croppedFile, ffmpeg.KwArgs{"c:v": "libx264", "c:a": "copy", "map": "0:a"}). OverWriteOutput(). Run() if err != nil { log.Fatalf("Не удалось применить обрезку: %v", err) return "" } log.Println("Видео успешно обрезано и сохранено во временный файл:", croppedFile) } else { // Если обрезка не требуется, используем исходный файл croppedFile = inputFile } width, height, err := getVideoDimensions(croppedFile) if err != nil { log.Fatalf("Video Resolution can't be got, err: %s", err) return "" } // Рассчитываем фильтр масштабирования log.Printf("resolution: %d:%d", width, height) scaleFilter := calculateScaleFilter(width, height) err = ffmpeg.Input(croppedFile). Filter("scale", ffmpeg.Args{scaleFilter}). Output(outputFileName, ffmpeg.KwArgs{ "c:v": "libx264", // H.264 codec "pix_fmt": "yuv420p", // compatible pixel format "preset": "fast", "movflags": "+faststart", // essential for browser playback "c:a": "aac", // re-encode audio to AAC "map": "0:a", "b:a": "128k", // audio bitrate }). OverWriteOutput(). Run() if err != nil { log.Fatalf("Video encoding error: %v", err) return "" } log.Printf("Encoding completed successfully. Processed file saved to: %s", outputFileName) // Загружаем обработанное видео обратно в S3 UploadVideo(client, outputFileName) // Удаляем временные файлы err = os.Remove(inputFile) if err != nil { log.Printf("Failed to remove temporary input file: %v", err) return "" } err = os.Remove(outputFileName) if err != nil { log.Printf("Failed to remove temporary output file: %v", err) return "" } // Удаляем временный файл (если он был создан) if cropParams != "" { err = exec.Command("rm", croppedFile).Run() if err != nil { log.Printf("Не удалось удалить временный файл: %v", err) return "" } else { log.Println("Временный файл удален:", croppedFile) } } return outputFileName } // Function to detect crop parameters using ffmpeg's cropdetect filter func detectCropParams(inputFile string) (string, error) { // Run ffmpeg to detect crop parameters cmd := exec.Command( "ffmpeg", "-i", inputFile, "-vf", "cropdetect", "-f", "null", "-", ) // Capture the output output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("failed to detect crop parameters: %v", err) } // Parse the output to extract crop parameters cropParams := parseCropParams(string(output)) if cropParams == "" { return "", fmt.Errorf("no crop parameters detected") } return cropParams, nil } // Function to parse crop parameters from ffmpeg output func parseCropParams(output string) string { lines := strings.Split(output, "\n") for _, line := range lines { if strings.Contains(line, "crop=") { parts := strings.Split(line, "crop=") if len(parts) > 1 { return strings.TrimSpace(parts[1]) } } } return "" } // getVideoDimensions использует ffprobe для получения размеров видео func getVideoDimensions(inputFile string) (int, int, error) { cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", inputFile) var out bytes.Buffer cmd.Stdout = &out err := cmd.Run() if err != nil { log.Fatalf("ffprobe error: %s", err) } // Парсим JSON-ответ var metadata VideoMetadata if err := json.Unmarshal(out.Bytes(), &metadata); err != nil { log.Fatalf("parse JSON error: %s", err) } // Проверяем, что данные о ширине и высоте найдены if len(metadata.Streams) == 0 || metadata.Streams[0].Width == 0 || metadata.Streams[0].Height == 0 { log.Fatalf("get video resolution error") } return metadata.Streams[0].Width, metadata.Streams[0].Height, nil } // calculateScaleFilter рассчитывает фильтр масштабирования func calculateScaleFilter(width, height int) string { var targetWidth, targetHeight int if width <= targetWidth && height <= targetHeight { return fmt.Sprintf("%d:%d", width, height) } if width > height { // Горизонтальное видео log.Printf("Video is Horizontal") targetWidth = VIDEO_WIDTH targetHeight = VIDEO_HEIGHT log.Printf("Target resolution: %d:%d", targetWidth, targetHeight) diff_width := width - targetWidth // 1 (width_initial-width_target) log.Printf("difference in initial width and target: %d", diff_width) var proportion float64 = float64(diff_width) / float64(width) // 2 ([1] / width) log.Printf("how much we need to scale video to reach target resolution: %f", proportion) diff_height := proportion * float64(height) // 3 (height_initial * [2]) log.Printf("how much we need to subtract from initial height to save proportion: %f", diff_height) newHeight := ensureEven(height - int(diff_height)) // 4 (height_initial - [3]) log.Printf("subtract proportioned height from initial: %d", newHeight) log.Printf("%d:%d", targetWidth, newHeight) return fmt.Sprintf("%d:%d", targetWidth, newHeight) } else { // Вертикальное видео log.Printf("Video is Vertical") targetWidth = VIDEO_HEIGHT targetHeight = VIDEO_WIDTH log.Printf("Target resolution: %d:%d", targetWidth, targetHeight) diff_height := height - targetHeight // 1 (height_initial-height_target) log.Printf("difference in initial height and target: %d", diff_height) var proportion_coefficient float64 = float64(diff_height) / float64(height) // 2 ([1] / height) log.Printf("how much we need to scale video to reach target resolution: %f", proportion_coefficient) diff_width := float64(width) * proportion_coefficient // 3 (width_initial * [2]) log.Printf("how much we need to subtract from initial width to save proportion: %f", diff_width) newWidth := ensureEven(width - int(diff_width)) // 4 (width_initial - [3]) log.Printf("subtract proportioned width from initial: %d", newWidth) log.Printf("%d:%d", targetWidth, newWidth) return fmt.Sprintf("%d:%d", newWidth, targetHeight) } } // ensureEven гарантирует, что число чётное func ensureEven(value int) int { if value%2 != 0 { return value + 1 } return value }