mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 23:22:26 -05:00 
			
		
		
		
	This will be either an mp1, mp2 or mp3 file. In practice it'll probably be mp3, but this handles mp1 too for good measure. We don't advertise audio/mp1 as a supported media type since best I can tell that was never a MIME type that's been used. This also changes the returned MIME-type for mp2 and mp3 to audio/mpeg, to match what's expected and supported by most things nowadays. Fixes: #3531
		
			
				
	
	
		
			725 lines
		
	
	
	
		
			18 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			725 lines
		
	
	
	
		
			18 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // GoToSocial
 | |
| // Copyright (C) GoToSocial Authors admin@gotosocial.org
 | |
| // SPDX-License-Identifier: AGPL-3.0-or-later
 | |
| //
 | |
| // This program is free software: you can redistribute it and/or modify
 | |
| // it under the terms of the GNU Affero General Public License as published by
 | |
| // the Free Software Foundation, either version 3 of the License, or
 | |
| // (at your option) any later version.
 | |
| //
 | |
| // This program is distributed in the hope that it will be useful,
 | |
| // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | |
| // GNU Affero General Public License for more details.
 | |
| //
 | |
| // You should have received a copy of the GNU Affero General Public License
 | |
| // along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | |
| 
 | |
| package media
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"os"
 | |
| 	"path"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 
 | |
| 	"codeberg.org/gruf/go-byteutil"
 | |
| 
 | |
| 	_ffmpeg "github.com/superseriousbusiness/gotosocial/internal/media/ffmpeg"
 | |
| 
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 | |
| 	"github.com/tetratelabs/wazero"
 | |
| )
 | |
| 
 | |
| // ffmpegClearMetadata generates a copy of input media with all metadata cleared.
 | |
| // NOTE: given that we are not performing an encode, this only clears global level metadata,
 | |
| // any metadata encoded into the media stream itself will not be cleared. This is the best we
 | |
| // can do without absolutely tanking performance by requiring transcodes :(
 | |
| func ffmpegClearMetadata(ctx context.Context, outpath, inpath string) error {
 | |
| 	return ffmpeg(ctx, inpath, outpath,
 | |
| 
 | |
| 		// Only log errors.
 | |
| 		"-loglevel", "error",
 | |
| 
 | |
| 		// Input file path.
 | |
| 		"-i", inpath,
 | |
| 
 | |
| 		// Drop all metadata.
 | |
| 		"-map_metadata", "-1",
 | |
| 
 | |
| 		// Copy input codecs,
 | |
| 		// i.e. no transcode.
 | |
| 		"-codec", "copy",
 | |
| 
 | |
| 		// Overwrite.
 | |
| 		"-y",
 | |
| 
 | |
| 		// Output.
 | |
| 		outpath,
 | |
| 	)
 | |
| }
 | |
| 
 | |
| // ffmpegGenerateWebpThumb generates a thumbnail webp from input media of any type, useful for any media.
 | |
| func ffmpegGenerateWebpThumb(ctx context.Context, inpath, outpath string, width, height int, pixfmt string) error {
 | |
| 	// Generate thumb with ffmpeg.
 | |
| 	return ffmpeg(ctx, inpath, outpath,
 | |
| 
 | |
| 		// Only log errors.
 | |
| 		"-loglevel", "error",
 | |
| 
 | |
| 		// Input file.
 | |
| 		"-i", inpath,
 | |
| 
 | |
| 		// Encode using libwebp.
 | |
| 		// (NOT as libwebp_anim).
 | |
| 		"-codec:v", "libwebp",
 | |
| 
 | |
| 		// Only one frame
 | |
| 		"-frames:v", "1",
 | |
| 
 | |
| 		// Scale to dimensions
 | |
| 		// (scale filter: https://ffmpeg.org/ffmpeg-filters.html#scale)
 | |
| 		"-filter:v", "scale="+strconv.Itoa(width)+":"+strconv.Itoa(height)+","+
 | |
| 
 | |
| 			// Attempt to use original pixel format
 | |
| 			// (format filter: https://ffmpeg.org/ffmpeg-filters.html#format)
 | |
| 			"format=pix_fmts="+pixfmt,
 | |
| 
 | |
| 		// Quality not specified,
 | |
| 		// i.e. use default which
 | |
| 		// should be 75% webp quality.
 | |
| 		// (codec options: https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options)
 | |
| 		// (libwebp codec: https://ffmpeg.org/ffmpeg-codecs.html#Options-36)
 | |
| 		// "-qscale:v", "75",
 | |
| 
 | |
| 		// Overwrite.
 | |
| 		"-y",
 | |
| 
 | |
| 		// Output.
 | |
| 		outpath,
 | |
| 	)
 | |
| }
 | |
| 
 | |
| // ffmpegGenerateStatic generates a static png from input image of any type, useful for emoji.
 | |
| func ffmpegGenerateStatic(ctx context.Context, inpath string) (string, error) {
 | |
| 	var outpath string
 | |
| 
 | |
| 	// Generate thumb output path REPLACING extension.
 | |
| 	if i := strings.IndexByte(inpath, '.'); i != -1 {
 | |
| 		outpath = inpath[:i] + "_static.png"
 | |
| 	} else {
 | |
| 		return "", gtserror.New("input file missing extension")
 | |
| 	}
 | |
| 
 | |
| 	// Generate static with ffmpeg.
 | |
| 	if err := ffmpeg(ctx, inpath, outpath,
 | |
| 
 | |
| 		// Only log errors.
 | |
| 		"-loglevel", "error",
 | |
| 
 | |
| 		// Input file.
 | |
| 		"-i", inpath,
 | |
| 
 | |
| 		// Only first frame.
 | |
| 		"-frames:v", "1",
 | |
| 
 | |
| 		// Encode using png.
 | |
| 		// (NOT as apng).
 | |
| 		"-codec:v", "png",
 | |
| 
 | |
| 		// Overwrite.
 | |
| 		"-y",
 | |
| 
 | |
| 		// Output.
 | |
| 		outpath,
 | |
| 	); err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	return outpath, nil
 | |
| }
 | |
| 
 | |
| // ffmpeg calls `ffmpeg [args...]` (WASM) with in + out paths mounted in runtime.
 | |
| func ffmpeg(ctx context.Context, inpath string, outpath string, args ...string) error {
 | |
| 	var stderr byteutil.Buffer
 | |
| 	rc, err := _ffmpeg.Ffmpeg(ctx, _ffmpeg.Args{
 | |
| 		Stderr: &stderr,
 | |
| 		Args:   args,
 | |
| 		Config: func(modcfg wazero.ModuleConfig) wazero.ModuleConfig {
 | |
| 			fscfg := wazero.NewFSConfig()
 | |
| 
 | |
| 			// Needs read-only access to
 | |
| 			// /dev/urandom for some types.
 | |
| 			urandom := &allowFiles{
 | |
| 				{
 | |
| 					abs:  "/dev/urandom",
 | |
| 					flag: os.O_RDONLY,
 | |
| 					perm: 0,
 | |
| 				},
 | |
| 			}
 | |
| 			fscfg = fscfg.WithFSMount(urandom, "/dev")
 | |
| 
 | |
| 			// In+out dirs are always the same (tmp),
 | |
| 			// so we can share one file system for
 | |
| 			// both + grant different perms to inpath
 | |
| 			// (read only) and outpath (read+write).
 | |
| 			shared := &allowFiles{
 | |
| 				{
 | |
| 					abs:  inpath,
 | |
| 					flag: os.O_RDONLY,
 | |
| 					perm: 0,
 | |
| 				},
 | |
| 				{
 | |
| 					abs:  outpath,
 | |
| 					flag: os.O_RDWR | os.O_CREATE | os.O_TRUNC,
 | |
| 					perm: 0666,
 | |
| 				},
 | |
| 			}
 | |
| 			fscfg = fscfg.WithFSMount(shared, path.Dir(inpath))
 | |
| 
 | |
| 			// Set anonymous module name.
 | |
| 			modcfg = modcfg.WithName("")
 | |
| 
 | |
| 			// Update with prepared fs config.
 | |
| 			return modcfg.WithFSConfig(fscfg)
 | |
| 		},
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return gtserror.Newf("error running: %w", err)
 | |
| 	} else if rc != 0 {
 | |
| 		return gtserror.Newf("non-zero return code %d (%s)", rc, stderr.B)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // ffprobe calls `ffprobe` (WASM) on filepath, returning parsed JSON output.
 | |
| func ffprobe(ctx context.Context, filepath string) (*result, error) {
 | |
| 	var stdout byteutil.Buffer
 | |
| 
 | |
| 	// Run ffprobe on our given file at path.
 | |
| 	_, err := _ffmpeg.Ffprobe(ctx, _ffmpeg.Args{
 | |
| 		Stdout: &stdout,
 | |
| 
 | |
| 		Args: []string{
 | |
| 			// Don't show any excess logging
 | |
| 			// information, all goes in JSON.
 | |
| 			"-loglevel", "quiet",
 | |
| 
 | |
| 			// Print in compact JSON format.
 | |
| 			"-print_format", "json=compact=1",
 | |
| 
 | |
| 			// Show error in our
 | |
| 			// chosen format type.
 | |
| 			"-show_error",
 | |
| 
 | |
| 			// Show specifically container format, total duration and bitrate.
 | |
| 			"-show_entries", "format=format_name,duration,bit_rate" + ":" +
 | |
| 
 | |
| 				// Show specifically stream codec names, types, frame rate, duration, dimens, and pixel format.
 | |
| 				"stream=codec_name,codec_type,r_frame_rate,duration_ts,width,height,pix_fmt" + ":" +
 | |
| 
 | |
| 				// Show orientation tag.
 | |
| 				"tags=orientation" + ":" +
 | |
| 
 | |
| 				// Show rotation data.
 | |
| 				"side_data=rotation",
 | |
| 
 | |
| 			// Limit to reading the first
 | |
| 			// 1s of data looking for "rotation"
 | |
| 			// side_data tags (expensive part).
 | |
| 			"-read_intervals", "%+1",
 | |
| 
 | |
| 			// Input file.
 | |
| 			"-i", filepath,
 | |
| 		},
 | |
| 
 | |
| 		Config: func(modcfg wazero.ModuleConfig) wazero.ModuleConfig {
 | |
| 			fscfg := wazero.NewFSConfig()
 | |
| 
 | |
| 			// Needs read-only access
 | |
| 			// to file being probed.
 | |
| 			in := &allowFiles{
 | |
| 				{
 | |
| 					abs:  filepath,
 | |
| 					flag: os.O_RDONLY,
 | |
| 					perm: 0,
 | |
| 				},
 | |
| 			}
 | |
| 			fscfg = fscfg.WithFSMount(in, path.Dir(filepath))
 | |
| 
 | |
| 			// Set anonymous module name.
 | |
| 			modcfg = modcfg.WithName("")
 | |
| 
 | |
| 			// Update with prepared fs config.
 | |
| 			return modcfg.WithFSConfig(fscfg)
 | |
| 		},
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return nil, gtserror.Newf("error running: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	var result ffprobeResult
 | |
| 
 | |
| 	// Unmarshal the ffprobe output as our result type.
 | |
| 	if err := json.Unmarshal(stdout.B, &result); err != nil {
 | |
| 		return nil, gtserror.Newf("error unmarshaling json: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Convert raw result data.
 | |
| 	res, err := result.Process()
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return res, nil
 | |
| }
 | |
| 
 | |
| const (
 | |
| 	// possible orientation values
 | |
| 	// specified in "orientation"
 | |
| 	// tag of images.
 | |
| 	//
 | |
| 	// FlipH      = flips horizontally
 | |
| 	// FlipV      = flips vertically
 | |
| 	// Transpose  = flips horizontally and rotates 90 counter-clockwise.
 | |
| 	// Transverse = flips vertically and rotates 90 counter-clockwise.
 | |
| 	orientationUnspecified = 0
 | |
| 	orientationNormal      = 1
 | |
| 	orientationFlipH       = 2
 | |
| 	orientationRotate180   = 3
 | |
| 	orientationFlipV       = 4
 | |
| 	orientationTranspose   = 5
 | |
| 	orientationRotate270   = 6
 | |
| 	orientationTransverse  = 7
 | |
| 	orientationRotate90    = 8
 | |
| )
 | |
| 
 | |
| // result contains parsed ffprobe result
 | |
| // data in a more useful data format.
 | |
| type result struct {
 | |
| 	format      string
 | |
| 	audio       []audioStream
 | |
| 	video       []videoStream
 | |
| 	duration    float64
 | |
| 	bitrate     uint64
 | |
| 	orientation int
 | |
| }
 | |
| 
 | |
| type stream struct {
 | |
| 	codec string
 | |
| }
 | |
| 
 | |
| type audioStream struct {
 | |
| 	stream
 | |
| }
 | |
| 
 | |
| type videoStream struct {
 | |
| 	stream
 | |
| 	pixfmt    string
 | |
| 	width     int
 | |
| 	height    int
 | |
| 	framerate float32
 | |
| }
 | |
| 
 | |
| // GetFileType determines file type and extension to use for media data. This
 | |
| // function helps to abstract away the horrible complexities that are possible
 | |
| // media container (i.e. the file) types and and possible sub-types within that.
 | |
| //
 | |
| // Note the checks for (len(res.video) > 0) may catch some audio files with embedded
 | |
| // album art as video, but i blame that on the hellscape that is media filetypes.
 | |
| func (res *result) GetFileType() (gtsmodel.FileType, string, string) {
 | |
| 	switch res.format {
 | |
| 	case "mpeg":
 | |
| 		return gtsmodel.FileTypeVideo,
 | |
| 			"video/mpeg", "mpeg"
 | |
| 	case "mjpeg":
 | |
| 		return gtsmodel.FileTypeVideo,
 | |
| 			"video/x-motion-jpeg", "mjpeg"
 | |
| 	case "mov,mp4,m4a,3gp,3g2,mj2":
 | |
| 		switch {
 | |
| 		case len(res.video) > 0:
 | |
| 			if len(res.audio) == 0 &&
 | |
| 				res.duration <= 30 {
 | |
| 				// Short, soundless
 | |
| 				// video file aka gifv.
 | |
| 				return gtsmodel.FileTypeGifv,
 | |
| 					"video/mp4", "mp4"
 | |
| 			} else {
 | |
| 				// Video file (with or without audio).
 | |
| 				return gtsmodel.FileTypeVideo,
 | |
| 					"video/mp4", "mp4"
 | |
| 			}
 | |
| 		case len(res.audio) > 0 &&
 | |
| 			res.audio[0].codec == "aac":
 | |
| 			// m4a only supports [aac] audio.
 | |
| 			return gtsmodel.FileTypeAudio,
 | |
| 				"audio/mp4", "m4a"
 | |
| 		}
 | |
| 	case "apng":
 | |
| 		return gtsmodel.FileTypeImage,
 | |
| 			"image/apng", "apng"
 | |
| 	case "png_pipe":
 | |
| 		return gtsmodel.FileTypeImage,
 | |
| 			"image/png", "png"
 | |
| 	case "image2", "image2pipe", "jpeg_pipe":
 | |
| 		return gtsmodel.FileTypeImage,
 | |
| 			"image/jpeg", "jpeg"
 | |
| 	case "webp", "webp_pipe":
 | |
| 		return gtsmodel.FileTypeImage,
 | |
| 			"image/webp", "webp"
 | |
| 	case "gif":
 | |
| 		return gtsmodel.FileTypeImage,
 | |
| 			"image/gif", "gif"
 | |
| 	case "mp3":
 | |
| 		if len(res.audio) > 0 {
 | |
| 			switch res.audio[0].codec {
 | |
| 			case "mp1":
 | |
| 				return gtsmodel.FileTypeAudio,
 | |
| 					"audio/mpeg", "mp1"
 | |
| 			case "mp2":
 | |
| 				return gtsmodel.FileTypeAudio,
 | |
| 					"audio/mpeg", "mp2"
 | |
| 			case "mp3":
 | |
| 				return gtsmodel.FileTypeAudio,
 | |
| 					"audio/mpeg", "mp3"
 | |
| 			}
 | |
| 		}
 | |
| 	case "asf":
 | |
| 		switch {
 | |
| 		case len(res.video) > 0:
 | |
| 			return gtsmodel.FileTypeVideo,
 | |
| 				"video/x-ms-wmv", "wmv"
 | |
| 		case len(res.audio) > 0:
 | |
| 			return gtsmodel.FileTypeAudio,
 | |
| 				"audio/x-ms-wma", "wma"
 | |
| 		}
 | |
| 	case "ogg":
 | |
| 		if len(res.video) > 0 {
 | |
| 			switch res.video[0].codec {
 | |
| 			case "theora", "dirac": // daala, tarkin
 | |
| 				return gtsmodel.FileTypeVideo,
 | |
| 					"video/ogg", "ogv"
 | |
| 			}
 | |
| 		}
 | |
| 		if len(res.audio) > 0 {
 | |
| 			switch res.audio[0].codec {
 | |
| 			case "opus", "libopus":
 | |
| 				return gtsmodel.FileTypeAudio,
 | |
| 					"audio/opus", "opus"
 | |
| 			default:
 | |
| 				return gtsmodel.FileTypeAudio,
 | |
| 					"audio/ogg", "ogg"
 | |
| 			}
 | |
| 		}
 | |
| 	case "matroska,webm":
 | |
| 		switch {
 | |
| 		case len(res.video) > 0:
 | |
| 			var isWebm bool
 | |
| 
 | |
| 			switch res.video[0].codec {
 | |
| 			case "vp8", "vp9", "av1":
 | |
| 				if len(res.audio) > 0 {
 | |
| 					switch res.audio[0].codec {
 | |
| 					case "vorbis", "opus", "libopus":
 | |
| 						// webm only supports [VP8/VP9/AV1] +
 | |
| 						//                    [vorbis/opus]
 | |
| 						isWebm = true
 | |
| 					}
 | |
| 				} else {
 | |
| 					// no audio with correct
 | |
| 					// video codec also fine.
 | |
| 					isWebm = true
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			if isWebm {
 | |
| 				// Check valid webm codec config.
 | |
| 				return gtsmodel.FileTypeVideo,
 | |
| 					"video/webm", "webm"
 | |
| 			}
 | |
| 
 | |
| 			// All else falls under generic mkv.
 | |
| 			return gtsmodel.FileTypeVideo,
 | |
| 				"video/x-matroska", "mkv"
 | |
| 		case len(res.audio) > 0:
 | |
| 			return gtsmodel.FileTypeAudio,
 | |
| 				"audio/x-matroska", "mka"
 | |
| 		}
 | |
| 	case "avi":
 | |
| 		return gtsmodel.FileTypeVideo,
 | |
| 			"video/x-msvideo", "avi"
 | |
| 	case "flac":
 | |
| 		return gtsmodel.FileTypeAudio,
 | |
| 			"audio/flac", "flac"
 | |
| 	}
 | |
| 	return gtsmodel.FileTypeUnknown,
 | |
| 		"", res.format
 | |
| }
 | |
| 
 | |
| // ImageMeta extracts image metadata contained within ffprobe'd media result streams.
 | |
| func (res *result) ImageMeta() (width int, height int, framerate float32) {
 | |
| 	for _, stream := range res.video {
 | |
| 		// Use widest found width.
 | |
| 		if stream.width > width {
 | |
| 			width = stream.width
 | |
| 		}
 | |
| 
 | |
| 		// Use tallest found height.
 | |
| 		if stream.height > height {
 | |
| 			height = stream.height
 | |
| 		}
 | |
| 
 | |
| 		// Use lowest non-zero (valid) framerate.
 | |
| 		if fr := float32(stream.framerate); fr > 0 {
 | |
| 			if framerate == 0 || fr < framerate {
 | |
| 				framerate = fr
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// If image is rotated by
 | |
| 	// any odd multiples of 90,
 | |
| 	// flip width / height to
 | |
| 	// get the correct scale.
 | |
| 	switch res.orientation {
 | |
| 	case orientationRotate90,
 | |
| 		orientationRotate270,
 | |
| 		orientationTransverse,
 | |
| 		orientationTranspose:
 | |
| 		width, height = height, width
 | |
| 	}
 | |
| 
 | |
| 	return
 | |
| }
 | |
| 
 | |
| // PixFmt returns the first valid pixel format
 | |
| // contained among the result vidoe streams.
 | |
| func (res *result) PixFmt() string {
 | |
| 	for _, str := range res.video {
 | |
| 		if str.pixfmt != "" {
 | |
| 			return str.pixfmt
 | |
| 		}
 | |
| 	}
 | |
| 	return ""
 | |
| }
 | |
| 
 | |
| // Process converts raw ffprobe result data into our more usable result{} type.
 | |
| func (res *ffprobeResult) Process() (*result, error) {
 | |
| 	if res.Error != nil {
 | |
| 		return nil, res.Error
 | |
| 	}
 | |
| 
 | |
| 	if res.Format == nil {
 | |
| 		return nil, errors.New("missing format data")
 | |
| 	}
 | |
| 
 | |
| 	var r result
 | |
| 	var err error
 | |
| 
 | |
| 	// Copy over container format.
 | |
| 	r.format = res.Format.FormatName
 | |
| 
 | |
| 	// Parsed media bitrate (if it was set).
 | |
| 	if str := res.Format.BitRate; str != "" {
 | |
| 		r.bitrate, err = strconv.ParseUint(str, 10, 64)
 | |
| 		if err != nil {
 | |
| 			return nil, gtserror.Newf("invalid bitrate %s: %w", str, err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Parse media duration (if it was set).
 | |
| 	if str := res.Format.Duration; str != "" {
 | |
| 		r.duration, err = strconv.ParseFloat(str, 32)
 | |
| 		if err != nil {
 | |
| 			return nil, gtserror.Newf("invalid duration %s: %w", str, err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Check extra packet / frame information
 | |
| 	// for provided orientation (if provided).
 | |
| 	for _, pf := range res.PacketsAndFrames {
 | |
| 
 | |
| 		// Ensure frame contains tags.
 | |
| 		if pf.Tags.Orientation == "" {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Trim any space from orientation value.
 | |
| 		str := strings.TrimSpace(pf.Tags.Orientation)
 | |
| 
 | |
| 		// Parse as integer value.
 | |
| 		orient, _ := strconv.Atoi(str)
 | |
| 		if orient < 0 || orient >= 9 {
 | |
| 			return nil, errors.New("invalid orientation data")
 | |
| 		}
 | |
| 
 | |
| 		// Ensure different value has
 | |
| 		// not already been specified.
 | |
| 		if r.orientation != 0 &&
 | |
| 			orient != r.orientation {
 | |
| 			return nil, errors.New("multiple sets of orientation / rotation data")
 | |
| 		}
 | |
| 
 | |
| 		// Set new orientation.
 | |
| 		r.orientation = orient
 | |
| 	}
 | |
| 
 | |
| 	// Preallocate streams to max possible lengths.
 | |
| 	r.audio = make([]audioStream, 0, len(res.Streams))
 | |
| 	r.video = make([]videoStream, 0, len(res.Streams))
 | |
| 
 | |
| 	// Convert streams to separate types.
 | |
| 	for _, s := range res.Streams {
 | |
| 		switch s.CodecType {
 | |
| 		case "audio":
 | |
| 			// Append audio stream data to result.
 | |
| 			r.audio = append(r.audio, audioStream{
 | |
| 				stream: stream{codec: s.CodecName},
 | |
| 			})
 | |
| 		case "video":
 | |
| 			// Parse stream framerate, bearing in
 | |
| 			// mind that some static container formats
 | |
| 			// (e.g. jpeg) still return a framerate, so
 | |
| 			// we also check for a non-1 timebase (dts).
 | |
| 			var framerate float32
 | |
| 			if str := s.RFrameRate; str != "" &&
 | |
| 				s.DurationTS > 1 {
 | |
| 				var num, den uint32
 | |
| 				den = 1
 | |
| 
 | |
| 				// Check for inequality (numerator / denominator).
 | |
| 				if p := strings.SplitN(str, "/", 2); len(p) == 2 {
 | |
| 					n, _ := strconv.ParseUint(p[0], 10, 32)
 | |
| 					d, _ := strconv.ParseUint(p[1], 10, 32)
 | |
| 					num, den = uint32(n), uint32(d) // #nosec G115 -- ParseUint is configured to check
 | |
| 				} else {
 | |
| 					n, _ := strconv.ParseUint(p[0], 10, 32)
 | |
| 					num = uint32(n) // #nosec G115 -- ParseUint is configured to check
 | |
| 				}
 | |
| 
 | |
| 				// Set final divised framerate.
 | |
| 				framerate = float32(num / den)
 | |
| 			}
 | |
| 
 | |
| 			// Check for embedded sidedata
 | |
| 			// which may contain rotation data.
 | |
| 			for _, d := range s.SideDataList {
 | |
| 
 | |
| 				// Ensure frame side
 | |
| 				// data IS rotation data.
 | |
| 				if d.Rotation == 0 {
 | |
| 					continue
 | |
| 				}
 | |
| 
 | |
| 				// Drop any decimal
 | |
| 				// rotation value.
 | |
| 				rot := int(d.Rotation)
 | |
| 
 | |
| 				// Round rotation to multiple of 90.
 | |
| 				// More granularity is not needed.
 | |
| 				if q := rot % 90; q > 45 {
 | |
| 					rot += (90 - q)
 | |
| 				} else {
 | |
| 					rot -= q
 | |
| 				}
 | |
| 
 | |
| 				// Drop any value above 360
 | |
| 				// or below -360, these are
 | |
| 				// just repeat full turns.
 | |
| 				//
 | |
| 				// Then convert to
 | |
| 				// orientation value.
 | |
| 				var orient int
 | |
| 				switch rot % 360 {
 | |
| 				case 0:
 | |
| 					orient = orientationNormal
 | |
| 				case 90, -270:
 | |
| 					orient = orientationRotate90
 | |
| 				case 180:
 | |
| 					orient = orientationRotate180
 | |
| 				case 270, -90:
 | |
| 					orient = orientationRotate270
 | |
| 				}
 | |
| 
 | |
| 				// Ensure different value has
 | |
| 				// not already been specified.
 | |
| 				if r.orientation != 0 &&
 | |
| 					orient != r.orientation {
 | |
| 					return nil, errors.New("multiple sets of orientation / rotation data")
 | |
| 				}
 | |
| 
 | |
| 				// Set new orientation.
 | |
| 				r.orientation = orient
 | |
| 			}
 | |
| 
 | |
| 			// Append video stream data to result.
 | |
| 			r.video = append(r.video, videoStream{
 | |
| 				stream:    stream{codec: s.CodecName},
 | |
| 				pixfmt:    s.PixFmt,
 | |
| 				width:     s.Width,
 | |
| 				height:    s.Height,
 | |
| 				framerate: framerate,
 | |
| 			})
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return &r, nil
 | |
| }
 | |
| 
 | |
| // ffprobeResult contains parsed JSON data from
 | |
| // result of calling `ffprobe` on a media file.
 | |
| type ffprobeResult struct {
 | |
| 	PacketsAndFrames []ffprobePacketOrFrame `json:"packets_and_frames"`
 | |
| 	Streams          []ffprobeStream        `json:"streams"`
 | |
| 	Format           *ffprobeFormat         `json:"format"`
 | |
| 	Error            *ffprobeError          `json:"error"`
 | |
| }
 | |
| 
 | |
| type ffprobePacketOrFrame struct {
 | |
| 	Type string      `json:"type"`
 | |
| 	Tags ffprobeTags `json:"tags"`
 | |
| 	// SideDataList []ffprobeSideData `json:"side_data_list"`
 | |
| }
 | |
| 
 | |
| type ffprobeTags struct {
 | |
| 	Orientation string `json:"orientation"`
 | |
| }
 | |
| 
 | |
| type ffprobeStream struct {
 | |
| 	CodecName    string            `json:"codec_name"`
 | |
| 	CodecType    string            `json:"codec_type"`
 | |
| 	PixFmt       string            `json:"pix_fmt"`
 | |
| 	RFrameRate   string            `json:"r_frame_rate"`
 | |
| 	DurationTS   uint              `json:"duration_ts"`
 | |
| 	Width        int               `json:"width"`
 | |
| 	Height       int               `json:"height"`
 | |
| 	SideDataList []ffprobeSideData `json:"side_data_list"`
 | |
| }
 | |
| 
 | |
| type ffprobeSideData struct {
 | |
| 	Rotation float64 `json:"rotation"`
 | |
| }
 | |
| 
 | |
| type ffprobeFormat struct {
 | |
| 	FormatName string `json:"format_name"`
 | |
| 	Duration   string `json:"duration"`
 | |
| 	BitRate    string `json:"bit_rate"`
 | |
| }
 | |
| 
 | |
| type ffprobeError struct {
 | |
| 	Code   int    `json:"code"`
 | |
| 	String string `json:"string"`
 | |
| }
 | |
| 
 | |
| func isUnsupportedTypeErr(err error) bool {
 | |
| 	ffprobeErr, ok := err.(*ffprobeError)
 | |
| 	return ok && ffprobeErr.Code == -1094995529
 | |
| }
 | |
| 
 | |
| func (err *ffprobeError) Error() string {
 | |
| 	return err.String + " (" + strconv.Itoa(err.Code) + ")"
 | |
| }
 |