diff --git a/ffmpeg.go b/ffmpeg.go index f321a21..0ee1190 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -3,8 +3,8 @@ package main import ( "bufio" "errors" + "fmt" "io" - "io/ioutil" "math" "os" "os/exec" @@ -36,10 +36,22 @@ const HLSChunkLength = 10 // Standard video resolutions for transcoding videos // These are the widths associated with the standard 16:9 resolutions -var videoResolutions = []int64{426, 640, 854, 1280, 1920} - -// These are the standard heights for 16:9 videos -var videoResolutionsStr = []string{"240", "360", "480", "720", "1080"} +var standardVideoWidths = []int64{426, 640, 854, 1280, 1920} +var standardVideoHeigths = []int64{240, 360, 480, 720, 1080} +var resolutionBitRates = map[int]string{ + 240: "500k", + 360: "1M", + 480: "2M", + 720: "3M", + 1080: "5M", +} +var resolutionBufferSizes = map[int]string{ + 240: "1M", + 360: "2M", + 480: "4M", + 720: "6M", + 1080: "10M", +} // Videos Currently being processed var encodingVideos EncodingVideos @@ -90,92 +102,115 @@ func getVideoResolution(videoFile string) (string, error) { return strings.TrimSpace(string(out)), nil } -// Builds the array of arguments necessary for ffmpeg to properly transcode the given video -// This downscales the video to the maximum and below of a horizontal width of 1920, 1280, 854, 640, 426 -func buildFfmpegCommand(videoFile, videoFolder string) ([]string, int, error) { - // Initial arguments for formatting ffmpeg's output - ffmpegArgs := []string{"-i", videoFile, "-loglevel", "error", "-progress", "-", "-nostats"} +// FFMPEG command building - // Get the resolution of the current video - videoResolution, err := getVideoResolution(videoFile) - if err != nil { - return nil, 0, err - } +func buildFfmpegFilter(numResolutions int) []string { + ffmpegFilter := []string{"-filter_complex"} + filterString := fmt.Sprintf("[0:v]split=%d", numResolutions) - videoWidth, err := strconv.ParseInt(strings.Split(videoResolution, "x")[0], 10, 64) - if err != nil { - return nil, 0, err + // Split the video into numResolutions parts + for i := 0; i < numResolutions; i++ { + filterString += fmt.Sprintf("[v%d]", i+1) } - videoHeight, err := strconv.ParseInt(strings.Split(videoResolution, "x")[1], 10, 64) - if err != nil { - return nil, 0, err + + filterString += "; " + + // Scale each stream to the appropriate resolution + for i := 0; i < numResolutions; i++ { + filterString += fmt.Sprintf("[v%d]scale=h=%d:-2[v%dout]", i+1, standardVideoHeigths[i], i+1) + if (i + 1) < numResolutions { + filterString += "; " + } } - // Find its aspect ratio for calculating the scaled resolutions - aspectRatio := float64(videoHeight) / float64(videoWidth) + ffmpegFilter = append(ffmpegFilter, filterString) - // Find the maximum resolution to scale the video to - maxResolutionIndex := 0 - for ; maxResolutionIndex < len(videoResolutions)-1 && videoWidth > int64(videoResolutions[maxResolutionIndex]); maxResolutionIndex++ { + return ffmpegFilter +} + +func buildFfmpegVideoStreamParams(numResolutions int) []string { + ffmpegVideoStreamParams := []string{} + + for i := 0; i < numResolutions; i++ { + ffmpegVideoStreamParams = append(ffmpegVideoStreamParams, "-map", fmt.Sprintf("[v%dout]", i+1), fmt.Sprintf("-c:v:%d", i), "libx264", fmt.Sprintf("-b:v:%d", i), resolutionBitRates[int(standardVideoHeigths[i])], fmt.Sprintf("-maxrate:v:%d", i), resolutionBitRates[int(standardVideoHeigths[i])], fmt.Sprintf("-minrate:v:%d", i), resolutionBitRates[int(standardVideoHeigths[i])], fmt.Sprintf("-bufsize:v:%d", i), resolutionBufferSizes[int(standardVideoHeigths[i])], "-preset", "ultrafast", "-crf", "23", "-g", "48", "-sc_threshold", "0", "-keyint_min", "48") } - // Include the current resolution if the resolution matches - if videoWidth == int64(videoResolutions[maxResolutionIndex]) { - maxResolutionIndex++ + return ffmpegVideoStreamParams +} + +// -map a:0 -c:a:0 aac -b:a:0 96k -ac 2 +func buildFfmpegAudioStreamParams(numResolutions int) []string { + ffmpegAudioStreamParams := []string{} + + for i := 0; i < numResolutions; i++ { + ffmpegAudioStreamParams = append(ffmpegAudioStreamParams, "-map", "a:0", fmt.Sprintf("-c:a:%d", i), "aac" /*fmt.Sprintf("-b:a:%d", i), "96k",*/, "-ac", "2") } - outputResolutions := videoResolutions[0:maxResolutionIndex] - numResolutions := len(outputResolutions) + return ffmpegAudioStreamParams +} + +func buildFfmpegHLSParams(videoFolder string) []string { + ffmpegHLSParams := []string{"-f", "hls", "-hls_time", "2", "-hls_playlist_type", "vod", "-hls_flags", "independent_segments", "-hls_segment_type", "mpegts", "-hls_segment_filename", path.Join(videoFolder, "stream_%v-data%02d.ts"), "-master_pl_name", "master.m3u8"} + return ffmpegHLSParams +} + +func buildFfmpegVarStreamMapParams(numResolutions int) []string { + ffmpegVarStreamMapParams := []string{"-var_stream_map"} + streamMap := "" - // Add a series of parameters for each resolution for i := 0; i < numResolutions; i++ { - resolutionHeight := int64(math.Floor(float64(videoResolutions[i]) * aspectRatio)) - if resolutionHeight%2 != 0 { - resolutionHeight++ + streamMap += fmt.Sprintf("v:%d,a:%d", i, i) + if (i + 1) < numResolutions { + streamMap += " " } - - ffmpegArgs = append(ffmpegArgs, "-s", strconv.FormatInt(videoResolutions[i], 10)+"x"+strconv.FormatInt(resolutionHeight, 10), "-hls_playlist_type", "vod", "-hls_flags", "independent_segments", "-hls_segment_type", "mpegts", "-hls_segment_filename", path.Join(videoFolder, "stream_"+videoResolutionsStr[i]+"_data%02d.ts"), "-hls_time", "10", "-master_pl_name", "master"+videoResolutionsStr[i]+".m3u8", "-f", "hls", path.Join(videoFolder, "stream_"+videoResolutionsStr[i]+".m3u8")) } - return ffmpegArgs, maxResolutionIndex, nil + ffmpegVarStreamMapParams = append(ffmpegVarStreamMapParams, streamMap) + + return ffmpegVarStreamMapParams } -// Combines the master playlists generated by ffmpeg into a single master playlist -func combineMasterPlaylists(videoFolder string, maxResolutionIndex int) error { - // Create the master playlist file - destination, err := os.Create(path.Join(videoFolder, "master.m3u8")) +// Builds the array of arguments necessary for ffmpeg to properly transcode the given video +func buildFfmpegCommand(videoFile, videoFolder string) ([]string, error) { + // Initial arguments for formatting ffmpeg's output + ffmpegArgs := []string{"-i", videoFile, "-loglevel", "error", "-progress", "-", "-nostats"} + + maxResolutionIndex, err := determineMaxResolutionIndex(videoFile, videoFolder) if err != nil { - return err + return nil, err } - defer destination.Close() - // Copy the important parts of each resolution's playlist file to the master - for i := 0; i < maxResolutionIndex; i++ { - file, err := ioutil.ReadFile(path.Join(videoFolder, "master"+videoResolutionsStr[i]+".m3u8")) - if err != nil { - return err - } + outputResolutions := standardVideoWidths[0:maxResolutionIndex] + numResolutions := len(outputResolutions) - // Write the entire first file to the master to get the necesssary headers - if i == 0 { - destination.Write(file) - } else { - lines := strings.Split(string(file), "\n") - - // Currently the way ffmpeg creates these files, the third and fourth lines contain the data specific to the stream in question - destination.WriteString(lines[2]) - destination.WriteString("\n") - destination.WriteString(lines[3]) - destination.WriteString("\n") - } + ffmpegArgs = append(ffmpegArgs, buildFfmpegFilter(numResolutions)...) + ffmpegArgs = append(ffmpegArgs, buildFfmpegVideoStreamParams(numResolutions)...) + ffmpegArgs = append(ffmpegArgs, buildFfmpegAudioStreamParams(numResolutions)...) + ffmpegArgs = append(ffmpegArgs, buildFfmpegHLSParams(videoFolder)...) + ffmpegArgs = append(ffmpegArgs, buildFfmpegVarStreamMapParams(numResolutions)...) + ffmpegArgs = append(ffmpegArgs, path.Join(videoFolder, "stream_%v.m3u8")) + + return ffmpegArgs, nil +} + +func determineMaxResolutionIndex(videoFile, videoFolder string) (int, error) { + // Get the resolution of the current video + videoResolution, err := getVideoResolution(videoFile) + if err != nil { + return 0, err + } - // Delete the file after it has been added to the master - os.Remove(path.Join(videoFolder, "master"+videoResolutionsStr[i]+".m3u8")) + videoWidth, err := strconv.ParseInt(strings.Split(videoResolution, "x")[0], 10, 64) + if err != nil { + return 0, err + } - destination.WriteString("\n") + // Find the maximum resolution to scale the video to + maxResolutionIndex := 0 + for ; maxResolutionIndex < len(standardVideoWidths)-1 && videoWidth > int64(standardVideoWidths[maxResolutionIndex]); maxResolutionIndex++ { } - return nil + return maxResolutionIndex, nil } // Converts the given video to HLS chunks and places them in a folder named with the video's UUID @@ -188,10 +223,11 @@ func convertToHLS(videoFile, videoUUID string) (videoFolder string, err error) { } // Build the ffmpeg command that transcodes the given video to multiple HLS streams of different resolutions - ffmpegArgs, maxResolutionIndex, err := buildFfmpegCommand(videoFile, videoFolder) + ffmpegArgs, err := buildFfmpegCommand(videoFile, videoFolder) if err != nil { return "", errors.New("Failed to build ffmpeg command: " + err.Error()) } + log.Debug().Msg(strings.Join(ffmpegArgs, " ")) // Convert video cmd := exec.Command(viper.GetString("ffmpeg.ffmpegDir"), ffmpegArgs...) @@ -219,11 +255,6 @@ func convertToHLS(videoFile, videoUUID string) (videoFolder string, err error) { return "", err } - err = combineMasterPlaylists(videoFolder, maxResolutionIndex) - if err != nil { - return "", err - } - return videoFolder, nil } @@ -250,7 +281,7 @@ func updateEncodeFrameProgress(ffmpegStdOut io.ReadCloser, videoUUID string) { } // Take the frame count out of the output of ffmpeg - output := string(buf) + output := strings.Trim(string(buf), "\u0000") log.Debug().Str("ffmpeg", "stdout").Msg(output) frameLine := strings.Split(output, "\n")[0] frameCountStr := strings.Split(frameLine, "=")[1] diff --git a/restAPI.go b/restAPI.go index 364ca73..bf8e350 100644 --- a/restAPI.go +++ b/restAPI.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "path" - "path/filepath" "strings" "github.com/google/uuid" @@ -169,6 +168,9 @@ func uploadVideo(w http.ResponseWriter, r *http.Request) { return } + // Remove scratch thumbnail file + os.Remove(thumbnailFilename) + json.NewEncoder(w).Encode(VideoStartEncodingResponse{ID: videoUUID, ThumbnailCID: thumbnailCID}) log.Trace().Msgf("Finished video pre-processing. Starting encoding of %s", videoFilename) @@ -214,6 +216,9 @@ func uploadThumbnail(w http.ResponseWriter, r *http.Request) { return } + // Remove scratch thumbnail file + os.Remove(thumbnailFilename) + json.NewEncoder(w).Encode(ThumbnailUploadResponse{CID: thumbnailCID}) } @@ -258,16 +263,6 @@ func asyncVideoUpload(video, thumbnail, videoUUID string) { // Remove scratch video file os.Remove(video) - // Copy the thumbnail into the transcoded video folder - thumbnailFileExtension := filepath.Ext(thumbnail) - if err = fileCopy(thumbnail, path.Join(videoFolder, "thumbnail"+thumbnailFileExtension)); err != nil { - log.Error().Msgf("Unable to copy thumbnail file: %s\n", err) - return - } - - // Remove scratch thumbnail file - os.Remove(thumbnail) - // Add video folder to IPFS videoCID, err := addFolderToIPFS(ctx, videoFolder) if err != nil { @@ -303,32 +298,3 @@ func writeMultiPartFormDataToDisk(multipartFormData io.ReadCloser, destFile stri return nil } - -// Simple file copy function -func fileCopy(src, dst string) error { - sourceFileStat, err := os.Stat(src) - if err != nil { - return err - } - - if !sourceFileStat.Mode().IsRegular() { - return fmt.Errorf("%s is not a regular file", src) - } - - source, err := os.Open(src) - if err != nil { - return err - } - defer source.Close() - - destination, err := os.Create(dst) - if err != nil { - return err - } - defer destination.Close() - _, err = io.Copy(destination, source) - if err != nil { - return err - } - return nil -}