[performance] move thumbnail generation to go code where possible (#3183)
* wrap thumbnailing code to handle generation natively where possible * more code comments! * add even more code comments! * add code comments about blurhash generation * maintain image rotation if contained in exif data * move rotation before resizing * ensure pix_fmt actually selected by ffprobe, check for alpha layer with gifs * use linear instead of nearest-neighbour for resizing * work with image "orientation" instead of "rotation". use default 75% quality for both webp and jpeg generation * add header to new file * use thumb extension when getting thumb mime type * update test models and tests with new media processing * add suggested code comments * add note about thumbnail filter count reducing memory usage
|  | @ -858,7 +858,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() { | ||||||
|   "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/small/`+instanceAccount.AvatarMediaAttachment.ID+`.webp",`+` |   "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/small/`+instanceAccount.AvatarMediaAttachment.ID+`.webp",`+` | ||||||
|   "thumbnail_static_type": "image/webp", |   "thumbnail_static_type": "image/webp", | ||||||
|   "thumbnail_description": "A bouncing little green peglin.", |   "thumbnail_description": "A bouncing little green peglin.", | ||||||
|   "blurhash": "LE9kG#M}4YtO%dRkWEt5Dmoxx?WC" |   "blurhash": "LE9as6M}4YtO%dRlWEt6Dmoxx?WC" | ||||||
| }`, string(instanceV2ThumbnailJson)) | }`, string(instanceV2ThumbnailJson)) | ||||||
| 
 | 
 | ||||||
| 	// double extra special bonus: now update the image description without changing the image | 	// double extra special bonus: now update the image description without changing the image | ||||||
|  |  | ||||||
|  | @ -206,7 +206,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { | ||||||
| 			Y: 0.5, | 			Y: 0.5, | ||||||
| 		}, | 		}, | ||||||
| 	}, *attachmentReply.Meta) | 	}, *attachmentReply.Meta) | ||||||
| 	suite.Equal("LcBzLU#6RkRn~qvzRjWF?urqV@jc", *attachmentReply.Blurhash) | 	suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", *attachmentReply.Blurhash) | ||||||
| 	suite.NotEmpty(attachmentReply.ID) | 	suite.NotEmpty(attachmentReply.ID) | ||||||
| 	suite.NotEmpty(attachmentReply.URL) | 	suite.NotEmpty(attachmentReply.URL) | ||||||
| 	suite.NotEmpty(attachmentReply.PreviewURL) | 	suite.NotEmpty(attachmentReply.PreviewURL) | ||||||
|  | @ -291,7 +291,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() { | ||||||
| 			Y: 0.5, | 			Y: 0.5, | ||||||
| 		}, | 		}, | ||||||
| 	}, *attachmentReply.Meta) | 	}, *attachmentReply.Meta) | ||||||
| 	suite.Equal("LcBzLU#6RkRn~qvzRjWF?urqV@jc", *attachmentReply.Blurhash) | 	suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", *attachmentReply.Blurhash) | ||||||
| 	suite.NotEmpty(attachmentReply.ID) | 	suite.NotEmpty(attachmentReply.ID) | ||||||
| 	suite.Nil(attachmentReply.URL) | 	suite.Nil(attachmentReply.URL) | ||||||
| 	suite.NotEmpty(attachmentReply.PreviewURL) | 	suite.NotEmpty(attachmentReply.PreviewURL) | ||||||
|  |  | ||||||
|  | @ -166,7 +166,7 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileOK() { | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	suite.Equal(http.StatusOK, code) | 	suite.Equal(http.StatusOK, code) | ||||||
| 	suite.Equal("image/webp", headers.Get("content-type")) | 	suite.Equal("image/jpeg", headers.Get("content-type")) | ||||||
| 	suite.Equal(fileInStorage, body) | 	suite.Equal(fileInStorage, body) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -212,7 +212,7 @@ func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecache() { | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	suite.Equal(http.StatusOK, code) | 	suite.Equal(http.StatusOK, code) | ||||||
| 	suite.Equal("image/webp", headers.Get("content-type")) | 	suite.Equal("image/jpeg", headers.Get("content-type")) | ||||||
| 	suite.Equal(fileInStorage, body) | 	suite.Equal(fileInStorage, body) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -66,26 +66,13 @@ func ffmpegClearMetadata(ctx context.Context, outpath, inpath string) error { | ||||||
| 	) | 	) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ffmpegGenerateThumb generates a thumbnail webp from input media of any type, useful for any media. | // ffmpegGenerateWebpThumb generates a thumbnail webp from input media of any type, useful for any media. | ||||||
| func ffmpegGenerateThumb(ctx context.Context, filepath string, width, height int) (string, error) { | func ffmpegGenerateWebpThumb(ctx context.Context, filepath, outpath string, width, height int, pixfmt string) error { | ||||||
| 	var outpath string |  | ||||||
| 
 |  | ||||||
| 	// Generate thumb output path REPLACING extension. |  | ||||||
| 	if i := strings.IndexByte(filepath, '.'); i != -1 { |  | ||||||
| 		outpath = filepath[:i] + "_thumb.webp" |  | ||||||
| 	} else { |  | ||||||
| 		return "", gtserror.New("input file missing extension") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Get directory from filepath. | 	// Get directory from filepath. | ||||||
| 	dirpath := path.Dir(filepath) | 	dirpath := path.Dir(filepath) | ||||||
| 
 | 
 | ||||||
| 	// Thumbnail size scaling argument. |  | ||||||
| 	scale := strconv.Itoa(width) + ":" + |  | ||||||
| 		strconv.Itoa(height) |  | ||||||
| 
 |  | ||||||
| 	// Generate thumb with ffmpeg. | 	// Generate thumb with ffmpeg. | ||||||
| 	if err := ffmpeg(ctx, dirpath, | 	return ffmpeg(ctx, dirpath, | ||||||
| 
 | 
 | ||||||
| 		// Only log errors. | 		// Only log errors. | ||||||
| 		"-loglevel", "error", | 		"-loglevel", "error", | ||||||
|  | @ -97,36 +84,36 @@ func ffmpegGenerateThumb(ctx context.Context, filepath string, width, height int | ||||||
| 		// (NOT as libwebp_anim). | 		// (NOT as libwebp_anim). | ||||||
| 		"-codec:v", "libwebp", | 		"-codec:v", "libwebp", | ||||||
| 
 | 
 | ||||||
| 		// Select thumb from first 10 frames | 		// Select thumb from first 7 frames. | ||||||
|  | 		// (in particular <= 7 reduced memory usage, marginally) | ||||||
| 		// (thumb filter: https://ffmpeg.org/ffmpeg-filters.html#thumbnail) | 		// (thumb filter: https://ffmpeg.org/ffmpeg-filters.html#thumbnail) | ||||||
| 		"-filter:v", "thumbnail=n=10,"+ | 		"-filter:v", "thumbnail=n=7,"+ | ||||||
| 
 | 
 | ||||||
| 			// scale to dimensions | 			// Scale to dimensions | ||||||
| 			// (scale filter: https://ffmpeg.org/ffmpeg-filters.html#scale) | 			// (scale filter: https://ffmpeg.org/ffmpeg-filters.html#scale) | ||||||
| 			"scale="+scale+","+ | 			"scale="+strconv.Itoa(width)+ | ||||||
|  | 			":"+strconv.Itoa(height)+","+ | ||||||
| 
 | 
 | ||||||
| 			// YUVA 4:2:0 pixel format | 			// Attempt to use original pixel format | ||||||
| 			// (format filter: https://ffmpeg.org/ffmpeg-filters.html#format) | 			// (format filter: https://ffmpeg.org/ffmpeg-filters.html#format) | ||||||
| 			"format=pix_fmts=yuva420p", | 			"format=pix_fmts="+pixfmt, | ||||||
| 
 | 
 | ||||||
| 		// Only one frame | 		// Only one frame | ||||||
| 		"-frames:v", "1", | 		"-frames:v", "1", | ||||||
| 
 | 
 | ||||||
| 		// ~40% webp quality | 		// Quality not specified, | ||||||
|  | 		// i.e. use default which | ||||||
|  | 		// should be 75% webp quality. | ||||||
| 		// (codec options: https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options) | 		// (codec options: https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options) | ||||||
| 		// (libwebp codec: https://ffmpeg.org/ffmpeg-codecs.html#Options-36) | 		// (libwebp codec: https://ffmpeg.org/ffmpeg-codecs.html#Options-36) | ||||||
| 		"-qscale:v", "40", | 		// "-qscale:v", "75", | ||||||
| 
 | 
 | ||||||
| 		// Overwrite. | 		// Overwrite. | ||||||
| 		"-y", | 		"-y", | ||||||
| 
 | 
 | ||||||
| 		// Output. | 		// Output. | ||||||
| 		outpath, | 		outpath, | ||||||
| 	); err != nil { | 	) | ||||||
| 		return "", err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return outpath, nil |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ffmpegGenerateStatic generates a static png from input image of any type, useful for emoji. | // ffmpegGenerateStatic generates a static png from input image of any type, useful for emoji. | ||||||
|  | @ -219,12 +206,11 @@ func ffprobe(ctx context.Context, filepath string) (*result, error) { | ||||||
| 			// Show specifically container format, total duration and bitrate. | 			// Show specifically container format, total duration and bitrate. | ||||||
| 			"-show_entries", "format=format_name,duration,bit_rate" + ":" + | 			"-show_entries", "format=format_name,duration,bit_rate" + ":" + | ||||||
| 
 | 
 | ||||||
| 				// Show specifically stream codec names, types, frame rate, duration and dimens. | 				// 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" + ":" + | 				"stream=codec_name,codec_type,r_frame_rate,duration_ts,width,height,pix_fmt" + ":" + | ||||||
| 
 | 
 | ||||||
| 				// Show any rotation | 				// Show orientation. | ||||||
| 				// side data stored. | 				"tags=orientation", | ||||||
| 				"side_data=rotation", |  | ||||||
| 
 | 
 | ||||||
| 			// Limit to reading the first | 			// Limit to reading the first | ||||||
| 			// 1s of data looking for "rotation" | 			// 1s of data looking for "rotation" | ||||||
|  | @ -262,6 +248,26 @@ func ffprobe(ctx context.Context, filepath string) (*result, error) { | ||||||
| 	return res, nil | 	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 | // result contains parsed ffprobe result | ||||||
| // data in a more useful data format. | // data in a more useful data format. | ||||||
| type result struct { | type result struct { | ||||||
|  | @ -270,7 +276,7 @@ type result struct { | ||||||
| 	video       []videoStream | 	video       []videoStream | ||||||
| 	duration    float64 | 	duration    float64 | ||||||
| 	bitrate     uint64 | 	bitrate     uint64 | ||||||
| 	rotation int | 	orientation int | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type stream struct { | type stream struct { | ||||||
|  | @ -283,6 +289,7 @@ type audioStream struct { | ||||||
| 
 | 
 | ||||||
| type videoStream struct { | type videoStream struct { | ||||||
| 	stream | 	stream | ||||||
|  | 	pixfmt    string | ||||||
| 	width     int | 	width     int | ||||||
| 	height    int | 	height    int | ||||||
| 	framerate float32 | 	framerate float32 | ||||||
|  | @ -403,14 +410,28 @@ func (res *result) ImageMeta() (width int, height int, framerate float32) { | ||||||
| 	// any odd multiples of 90, | 	// any odd multiples of 90, | ||||||
| 	// flip width / height to | 	// flip width / height to | ||||||
| 	// get the correct scale. | 	// get the correct scale. | ||||||
| 	switch res.rotation { | 	switch res.orientation { | ||||||
| 	case -90, 90, -270, 270: | 	case orientationRotate90, | ||||||
|  | 		orientationRotate270, | ||||||
|  | 		orientationTransverse, | ||||||
|  | 		orientationTranspose: | ||||||
| 		width, height = height, width | 		width, height = height, width | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return | 	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. | // Process converts raw ffprobe result data into our more usable result{} type. | ||||||
| func (res *ffprobeResult) Process() (*result, error) { | func (res *ffprobeResult) Process() (*result, error) { | ||||||
| 	if res.Error != nil { | 	if res.Error != nil { | ||||||
|  | @ -446,37 +467,29 @@ func (res *ffprobeResult) Process() (*result, error) { | ||||||
| 	// Check extra packet / frame information | 	// Check extra packet / frame information | ||||||
| 	// for provided orientation (not always set). | 	// for provided orientation (not always set). | ||||||
| 	for _, pf := range res.PacketsAndFrames { | 	for _, pf := range res.PacketsAndFrames { | ||||||
| 		for _, d := range pf.SideDataList { |  | ||||||
| 
 | 
 | ||||||
| 			// Ensure frame side | 		// Ensure frame contains tags. | ||||||
| 			// data IS rotation data. | 		if pf.Tags.Orientation == "" { | ||||||
| 			if d.Rotation == 0 { |  | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 			// Ensure rotation not | 		// Ensure orientation not | ||||||
| 		// already been specified. | 		// already been specified. | ||||||
| 			if r.rotation != 0 { | 		if r.orientation != 0 { | ||||||
| 				return nil, errors.New("multiple sets of rotation data") | 			return nil, errors.New("multiple sets of orientation data") | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 			// Drop any decimal | 		// Trim any space from orientation value. | ||||||
| 			// rotation value. | 		str := strings.TrimSpace(pf.Tags.Orientation) | ||||||
| 			rot := int(d.Rotation) |  | ||||||
| 
 | 
 | ||||||
| 			// Round rotation to multiple of 90. | 		// Parse as integer value. | ||||||
| 			// More granularity is not needed. | 		i, _ := strconv.Atoi(str) | ||||||
| 			if q := rot % 90; q > 45 { | 		if i <= 0 || i >= 9 { | ||||||
| 				rot += (90 - q) | 			return nil, errors.New("invalid orientation data") | ||||||
| 			} else { |  | ||||||
| 				rot -= q |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 			// Drop any value above 360 | 		// Set orientation. | ||||||
| 			// or below -360, these are | 		r.orientation = i | ||||||
| 			// just repeat full turns. |  | ||||||
| 			r.rotation = (rot % 360) |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Preallocate streams to max possible lengths. | 	// Preallocate streams to max possible lengths. | ||||||
|  | @ -519,6 +532,7 @@ func (res *ffprobeResult) Process() (*result, error) { | ||||||
| 			// Append video stream data to result. | 			// Append video stream data to result. | ||||||
| 			r.video = append(r.video, videoStream{ | 			r.video = append(r.video, videoStream{ | ||||||
| 				stream:    stream{codec: s.CodecName}, | 				stream:    stream{codec: s.CodecName}, | ||||||
|  | 				pixfmt:    s.PixFmt, | ||||||
| 				width:     s.Width, | 				width:     s.Width, | ||||||
| 				height:    s.Height, | 				height:    s.Height, | ||||||
| 				framerate: framerate, | 				framerate: framerate, | ||||||
|  | @ -540,16 +554,17 @@ type ffprobeResult struct { | ||||||
| 
 | 
 | ||||||
| type ffprobePacketOrFrame struct { | type ffprobePacketOrFrame struct { | ||||||
| 	Type string      `json:"type"` | 	Type string      `json:"type"` | ||||||
| 	SideDataList []ffprobeSideData `json:"side_data_list"` | 	Tags ffprobeTags `json:"tags"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ffprobeSideData struct { | type ffprobeTags struct { | ||||||
| 	Rotation float64 `json:"rotation"` | 	Orientation string `json:"orientation"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ffprobeStream struct { | type ffprobeStream struct { | ||||||
| 	CodecName  string `json:"codec_name"` | 	CodecName  string `json:"codec_name"` | ||||||
| 	CodecType  string `json:"codec_type"` | 	CodecType  string `json:"codec_type"` | ||||||
|  | 	PixFmt     string `json:"pix_fmt"` | ||||||
| 	RFrameRate string `json:"r_frame_rate"` | 	RFrameRate string `json:"r_frame_rate"` | ||||||
| 	DurationTS uint   `json:"duration_ts"` | 	DurationTS uint   `json:"duration_ts"` | ||||||
| 	Width      int    `json:"width"` | 	Width      int    `json:"width"` | ||||||
|  |  | ||||||
|  | @ -273,10 +273,10 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcess() { | ||||||
| 		Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, | 		Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, | ||||||
| 	}, attachment.FileMeta.Small) | 	}, attachment.FileMeta.Small) | ||||||
| 	suite.Equal("image/jpeg", attachment.File.ContentType) | 	suite.Equal("image/jpeg", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(269739, attachment.File.FileSize) | 	suite.Equal(269739, attachment.File.FileSize) | ||||||
| 	suite.Equal(8536, attachment.Thumbnail.FileSize) | 	suite.Equal(22858, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LcBzLU#6RkRn~qvzRjWF?urqV@jc", attachment.Blurhash) | 	suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -285,7 +285,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcess() { | ||||||
| 
 | 
 | ||||||
| 	// ensure the files contain the expected data. | 	// ensure the files contain the expected data. | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg") | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.webp") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.jpeg") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *ManagerTestSuite) TestSimpleJpegProcessTooLarge() { | func (suite *ManagerTestSuite) TestSimpleJpegProcessTooLarge() { | ||||||
|  | @ -428,8 +428,8 @@ func (suite *ManagerTestSuite) TestSlothVineProcess() { | ||||||
| 	suite.Equal("video/mp4", attachment.File.ContentType) | 	suite.Equal("video/mp4", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(312453, attachment.File.FileSize) | 	suite.Equal(312453, attachment.File.FileSize) | ||||||
| 	suite.Equal(3746, attachment.Thumbnail.FileSize) | 	suite.Equal(5648, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LhIrNMt6Nsj[t7aybFj[_4WBspoe", attachment.Blurhash) | 	suite.Equal("LhIrNMt6Nsj[t7ayW.j[_4WBsWkB", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -488,8 +488,8 @@ func (suite *ManagerTestSuite) TestLongerMp4Process() { | ||||||
| 	suite.Equal("video/mp4", attachment.File.ContentType) | 	suite.Equal("video/mp4", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(109569, attachment.File.FileSize) | 	suite.Equal(109569, attachment.File.FileSize) | ||||||
| 	suite.Equal(2128, attachment.Thumbnail.FileSize) | 	suite.Equal(2976, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("L8Q0aP~qnM_3~qD%ozRjRiofWXRj", attachment.Blurhash) | 	suite.Equal("L8QJfm~qD%_3_3D%t7RjM{j[ofRj", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -548,8 +548,8 @@ func (suite *ManagerTestSuite) TestBirdnestMp4Process() { | ||||||
| 	suite.Equal("video/mp4", attachment.File.ContentType) | 	suite.Equal("video/mp4", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(1409625, attachment.File.FileSize) | 	suite.Equal(1409625, attachment.File.FileSize) | ||||||
| 	suite.Equal(9446, attachment.Thumbnail.FileSize) | 	suite.Equal(14478, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LKF~w1RjRO.99DRORPaetkV?WCMw", attachment.Blurhash) | 	suite.Equal("LKF~w1RjRO.99DM_RPaetkV?WCMw", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -654,10 +654,10 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcess() { | ||||||
| 		Width: 186, Height: 187, Size: 34782, Aspect: 0.9946524064171123, | 		Width: 186, Height: 187, Size: 34782, Aspect: 0.9946524064171123, | ||||||
| 	}, attachment.FileMeta.Small) | 	}, attachment.FileMeta.Small) | ||||||
| 	suite.Equal("image/png", attachment.File.ContentType) | 	suite.Equal("image/png", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(17471, attachment.File.FileSize) | 	suite.Equal(17471, attachment.File.FileSize) | ||||||
| 	suite.Equal(2630, attachment.Thumbnail.FileSize) | 	suite.Equal(6446, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LBOW$@%i-=aj%go#RSRP_1av~Tt2", attachment.Blurhash) | 	suite.Equal("LDQcrD%i-?aj%ho#M~RP~nf3~nt2", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -666,7 +666,7 @@ func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcess() { | ||||||
| 
 | 
 | ||||||
| 	// ensure the files contain the expected data. | 	// ensure the files contain the expected data. | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-png-noalphachannel-processed.png") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-png-noalphachannel-processed.png") | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-png-noalphachannel-thumbnail.webp") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-png-noalphachannel-thumbnail.jpeg") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() { | func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() { | ||||||
|  | @ -712,8 +712,8 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() { | ||||||
| 	suite.Equal("image/png", attachment.File.ContentType) | 	suite.Equal("image/png", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(18832, attachment.File.FileSize) | 	suite.Equal(18832, attachment.File.FileSize) | ||||||
| 	suite.Equal(2630, attachment.Thumbnail.FileSize) | 	suite.Equal(3592, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LBOW$@%i-=aj%go#RSRP_1av~Tt2", attachment.Blurhash) | 	suite.Equal("LBOW$@%i-rak%go#RSRP_1av~Ts+", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -722,7 +722,7 @@ func (suite *ManagerTestSuite) TestPngAlphaChannelProcess() { | ||||||
| 
 | 
 | ||||||
| 	// ensure the files contain the expected data. | 	// ensure the files contain the expected data. | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-png-alphachannel-processed.png") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-png-alphachannel-processed.png") | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-png-alphachannel-thumbnail.webp") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-png-alphachannel-thumbnail.jpeg") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() { | func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() { | ||||||
|  | @ -766,10 +766,10 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() { | ||||||
| 		Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, | 		Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, | ||||||
| 	}, attachment.FileMeta.Small) | 	}, attachment.FileMeta.Small) | ||||||
| 	suite.Equal("image/jpeg", attachment.File.ContentType) | 	suite.Equal("image/jpeg", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(269739, attachment.File.FileSize) | 	suite.Equal(269739, attachment.File.FileSize) | ||||||
| 	suite.Equal(8536, attachment.Thumbnail.FileSize) | 	suite.Equal(22858, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LcBzLU#6RkRn~qvzRjWF?urqV@jc", attachment.Blurhash) | 	suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -778,7 +778,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessWithCallback() { | ||||||
| 
 | 
 | ||||||
| 	// ensure the files contain the expected data. | 	// ensure the files contain the expected data. | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg") | ||||||
| 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.webp") | 	equalFiles(suite.T(), suite.state.Storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.jpeg") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() { | func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() { | ||||||
|  | @ -844,10 +844,10 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() { | ||||||
| 		Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, | 		Width: 512, Height: 288, Size: 147456, Aspect: 1.7777777777777777, | ||||||
| 	}, attachment.FileMeta.Small) | 	}, attachment.FileMeta.Small) | ||||||
| 	suite.Equal("image/jpeg", attachment.File.ContentType) | 	suite.Equal("image/jpeg", attachment.File.ContentType) | ||||||
| 	suite.Equal("image/webp", attachment.Thumbnail.ContentType) | 	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) | ||||||
| 	suite.Equal(269739, attachment.File.FileSize) | 	suite.Equal(269739, attachment.File.FileSize) | ||||||
| 	suite.Equal(8536, attachment.Thumbnail.FileSize) | 	suite.Equal(22858, attachment.Thumbnail.FileSize) | ||||||
| 	suite.Equal("LcBzLU#6RkRn~qvzRjWF?urqV@jc", attachment.Blurhash) | 	suite.Equal("LjCGfG#6RkRn_NvzRjWF?urqV@a$", attachment.Blurhash) | ||||||
| 
 | 
 | ||||||
| 	// now make sure the attachment is in the database | 	// now make sure the attachment is in the database | ||||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachment.ID) | ||||||
|  | @ -856,7 +856,7 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessWithDiskStorage() { | ||||||
| 
 | 
 | ||||||
| 	// ensure the files contain the expected data. | 	// ensure the files contain the expected data. | ||||||
| 	equalFiles(suite.T(), storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg") | 	equalFiles(suite.T(), storage, dbAttachment.File.Path, "./test/test-jpeg-processed.jpg") | ||||||
| 	equalFiles(suite.T(), storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.webp") | 	equalFiles(suite.T(), storage, dbAttachment.Thumbnail.Path, "./test/test-jpeg-thumbnail.jpeg") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() { | func (suite *ManagerTestSuite) TestSmallSizedMediaTypeDetection_issue2263() { | ||||||
|  |  | ||||||
|  | @ -47,9 +47,14 @@ func clearMetadata(ctx context.Context, filepath string) error { | ||||||
| 		// cleaning exif data using a native Go library. | 		// cleaning exif data using a native Go library. | ||||||
| 		log.Debug(ctx, "cleaning with exif-terminator") | 		log.Debug(ctx, "cleaning with exif-terminator") | ||||||
| 		err := terminateExif(outpath, filepath, ext) | 		err := terminateExif(outpath, filepath, ext) | ||||||
| 		if err != nil { | 		if err == nil { | ||||||
| 			return err | 			// No problem. | ||||||
|  | 			break | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		log.Warnf(ctx, "error cleaning with exif-terminator, falling back to ffmpeg: %v", err) | ||||||
|  | 		fallthrough | ||||||
|  | 
 | ||||||
| 	default: | 	default: | ||||||
| 		// For all other types, best-effort clean with ffmpeg. | 		// For all other types, best-effort clean with ffmpeg. | ||||||
| 		log.Debug(ctx, "cleaning with ffmpeg -map_metadata -1") | 		log.Debug(ctx, "cleaning with ffmpeg -map_metadata -1") | ||||||
|  |  | ||||||
|  | @ -230,33 +230,28 @@ func (p *ProcessingMedia) store(ctx context.Context) error { | ||||||
| 		p.media.FileMeta.Small.Size = (thumbWidth * thumbHeight) | 		p.media.FileMeta.Small.Size = (thumbWidth * thumbHeight) | ||||||
| 		p.media.FileMeta.Small.Aspect = aspect | 		p.media.FileMeta.Small.Aspect = aspect | ||||||
| 
 | 
 | ||||||
| 		// Generate a thumbnail image from input image path. | 		// Determine if blurhash needs generating. | ||||||
| 		thumbpath, err = ffmpegGenerateThumb(ctx, temppath, | 		needBlurhash := (p.media.Blurhash == "") | ||||||
|  | 		var newBlurhash string | ||||||
|  | 
 | ||||||
|  | 		// Generate thumbnail, and new blurhash if need from media. | ||||||
|  | 		thumbpath, newBlurhash, err = generateThumb(ctx, temppath, | ||||||
| 			thumbWidth, | 			thumbWidth, | ||||||
| 			thumbHeight, | 			thumbHeight, | ||||||
|  | 			result.orientation, | ||||||
|  | 			result.PixFmt(), | ||||||
|  | 			needBlurhash, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return gtserror.Newf("error generating image thumb: %w", err) | 			return gtserror.Newf("error generating image thumb: %w", err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if p.media.Blurhash == "" { | 		if needBlurhash { | ||||||
| 			// Generate blurhash (if not already) from thumbnail. | 			// Set newly determined blurhash. | ||||||
| 			p.media.Blurhash, err = generateBlurhash(thumbpath) | 			p.media.Blurhash = newBlurhash | ||||||
| 			if err != nil { |  | ||||||
| 				return gtserror.Newf("error generating thumb blurhash: %w", err) |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		// Calculate final media attachment thumbnail path. |  | ||||||
| 		p.media.Thumbnail.Path = uris.StoragePathForAttachment( |  | ||||||
| 			p.media.AccountID, |  | ||||||
| 			string(TypeAttachment), |  | ||||||
| 			string(SizeSmall), |  | ||||||
| 			p.media.ID, |  | ||||||
| 			"webp", |  | ||||||
| 		) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Calculate final media attachment file path. | 	// Calculate final media attachment file path. | ||||||
| 	p.media.File.Path = uris.StoragePathForAttachment( | 	p.media.File.Path = uris.StoragePathForAttachment( | ||||||
| 		p.media.AccountID, | 		p.media.AccountID, | ||||||
|  | @ -279,6 +274,18 @@ func (p *ProcessingMedia) store(ctx context.Context) error { | ||||||
| 	p.media.File.FileSize = int(filesz) | 	p.media.File.FileSize = int(filesz) | ||||||
| 
 | 
 | ||||||
| 	if thumbpath != "" { | 	if thumbpath != "" { | ||||||
|  | 		// Determine final thumbnail ext. | ||||||
|  | 		thumbExt := getExtension(thumbpath) | ||||||
|  | 
 | ||||||
|  | 		// Calculate final media attachment thumbnail path. | ||||||
|  | 		p.media.Thumbnail.Path = uris.StoragePathForAttachment( | ||||||
|  | 			p.media.AccountID, | ||||||
|  | 			string(TypeAttachment), | ||||||
|  | 			string(SizeSmall), | ||||||
|  | 			p.media.ID, | ||||||
|  | 			thumbExt, | ||||||
|  | 		) | ||||||
|  | 
 | ||||||
| 		// Copy thumbnail file into storage at path. | 		// Copy thumbnail file into storage at path. | ||||||
| 		thumbsz, err := p.mgr.state.Storage.PutFile(ctx, | 		thumbsz, err := p.mgr.state.Storage.PutFile(ctx, | ||||||
| 			p.media.Thumbnail.Path, | 			p.media.Thumbnail.Path, | ||||||
|  | @ -290,6 +297,18 @@ func (p *ProcessingMedia) store(ctx context.Context) error { | ||||||
| 
 | 
 | ||||||
| 		// Set final determined thumbnail size. | 		// Set final determined thumbnail size. | ||||||
| 		p.media.Thumbnail.FileSize = int(thumbsz) | 		p.media.Thumbnail.FileSize = int(thumbsz) | ||||||
|  | 
 | ||||||
|  | 		// Determine thumbnail content-type from thumb ext. | ||||||
|  | 		p.media.Thumbnail.ContentType = getMimeType(thumbExt) | ||||||
|  | 
 | ||||||
|  | 		// Generate a media attachment thumbnail URL. | ||||||
|  | 		p.media.Thumbnail.URL = uris.URIForAttachment( | ||||||
|  | 			p.media.AccountID, | ||||||
|  | 			string(TypeAttachment), | ||||||
|  | 			string(SizeSmall), | ||||||
|  | 			p.media.ID, | ||||||
|  | 			thumbExt, | ||||||
|  | 		) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Generate a media attachment URL. | 	// Generate a media attachment URL. | ||||||
|  | @ -301,22 +320,10 @@ func (p *ProcessingMedia) store(ctx context.Context) error { | ||||||
| 		ext, | 		ext, | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	// Generate a media attachment thumbnail URL. |  | ||||||
| 	p.media.Thumbnail.URL = uris.URIForAttachment( |  | ||||||
| 		p.media.AccountID, |  | ||||||
| 		string(TypeAttachment), |  | ||||||
| 		string(SizeSmall), |  | ||||||
| 		p.media.ID, |  | ||||||
| 		"webp", |  | ||||||
| 	) |  | ||||||
| 
 |  | ||||||
| 	// Get mimetype for the file container | 	// Get mimetype for the file container | ||||||
| 	// type, falling back to generic data. | 	// type, falling back to generic data. | ||||||
| 	p.media.File.ContentType = getMimeType(ext) | 	p.media.File.ContentType = getMimeType(ext) | ||||||
| 
 | 
 | ||||||
| 	// Set the known thumbnail content type. |  | ||||||
| 	p.media.Thumbnail.ContentType = "image/webp" |  | ||||||
| 
 |  | ||||||
| 	// We can now consider this cached. | 	// We can now consider this cached. | ||||||
| 	p.media.Cached = util.Ptr(true) | 	p.media.Cached = util.Ptr(true) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 14 KiB | 
| Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-jpeg-thumbnail.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 22 KiB | 
| Before Width: | Height: | Size: 8.3 KiB | 
| Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 5.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-png-alphachannel-thumbnail.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.5 KiB | 
| Before Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-png-noalphachannel-thumbnail.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.3 KiB | 
| Before Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										380
									
								
								internal/media/thumbnail.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1,380 @@ | ||||||
|  | // 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" | ||||||
|  | 	"image" | ||||||
|  | 	"image/gif" | ||||||
|  | 	"image/jpeg" | ||||||
|  | 	"image/png" | ||||||
|  | 	"io" | ||||||
|  | 	"os" | ||||||
|  | 	"strings" | ||||||
|  | 
 | ||||||
|  | 	"github.com/buckket/go-blurhash" | ||||||
|  | 	"github.com/disintegration/imaging" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
|  | 	"golang.org/x/image/webp" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // generateThumb generates a thumbnail for the | ||||||
|  | // input file at path, resizing it to the given | ||||||
|  | // dimensions and generating a blurhash if needed. | ||||||
|  | // This wraps much of the complex thumbnailing | ||||||
|  | // logic in which where possible we use native | ||||||
|  | // Go libraries for generating thumbnails, else | ||||||
|  | // always falling back to slower but much more | ||||||
|  | // widely supportive ffmpeg. | ||||||
|  | func generateThumb( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	filepath string, | ||||||
|  | 	width, height int, | ||||||
|  | 	orientation int, | ||||||
|  | 	pixfmt string, | ||||||
|  | 	needBlurhash bool, | ||||||
|  | ) ( | ||||||
|  | 	outpath string, | ||||||
|  | 	blurhash string, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 	var ext string | ||||||
|  | 
 | ||||||
|  | 	// Generate thumb output path REPLACING extension. | ||||||
|  | 	if i := strings.IndexByte(filepath, '.'); i != -1 { | ||||||
|  | 		outpath = filepath[:i] + "_thumb.webp" | ||||||
|  | 		ext = filepath[i+1:] // old extension | ||||||
|  | 	} else { | ||||||
|  | 		return "", "", gtserror.New("input file missing extension") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check for the few media types we | ||||||
|  | 	// have native Go decoding that allow | ||||||
|  | 	// us to generate thumbs natively. | ||||||
|  | 	switch { | ||||||
|  | 
 | ||||||
|  | 	case ext == "jpeg": | ||||||
|  | 		// Replace the "webp" with "jpeg", as we'll | ||||||
|  | 		// use our native Go thumbnailing generation. | ||||||
|  | 		outpath = outpath[:len(outpath)-4] + "jpeg" | ||||||
|  | 
 | ||||||
|  | 		log.Debug(ctx, "generating thumb from jpeg") | ||||||
|  | 		blurhash, err := generateNativeThumb( | ||||||
|  | 			filepath, | ||||||
|  | 			outpath, | ||||||
|  | 			width, | ||||||
|  | 			height, | ||||||
|  | 			orientation, | ||||||
|  | 			jpeg.Decode, | ||||||
|  | 			needBlurhash, | ||||||
|  | 		) | ||||||
|  | 		return outpath, blurhash, err | ||||||
|  | 
 | ||||||
|  | 	// We specifically only allow generating native | ||||||
|  | 	// thumbnails from gif IF it doesn't contain an | ||||||
|  | 	// alpha channel. We'll ultimately be encoding to | ||||||
|  | 	// jpeg which doesn't support transparency layers. | ||||||
|  | 	case ext == "gif" && !containsAlpha(pixfmt): | ||||||
|  | 
 | ||||||
|  | 		// Replace the "webp" with "jpeg", as we'll | ||||||
|  | 		// use our native Go thumbnailing generation. | ||||||
|  | 		outpath = outpath[:len(outpath)-4] + "jpeg" | ||||||
|  | 
 | ||||||
|  | 		log.Debug(ctx, "generating thumb from gif") | ||||||
|  | 		blurhash, err := generateNativeThumb( | ||||||
|  | 			filepath, | ||||||
|  | 			outpath, | ||||||
|  | 			width, | ||||||
|  | 			height, | ||||||
|  | 			orientation, | ||||||
|  | 			gif.Decode, | ||||||
|  | 			needBlurhash, | ||||||
|  | 		) | ||||||
|  | 		return outpath, blurhash, err | ||||||
|  | 
 | ||||||
|  | 	// We specifically only allow generating native | ||||||
|  | 	// thumbnails from png IF it doesn't contain an | ||||||
|  | 	// alpha channel. We'll ultimately be encoding to | ||||||
|  | 	// jpeg which doesn't support transparency layers. | ||||||
|  | 	case ext == "png" && !containsAlpha(pixfmt): | ||||||
|  | 
 | ||||||
|  | 		// Replace the "webp" with "jpeg", as we'll | ||||||
|  | 		// use our native Go thumbnailing generation. | ||||||
|  | 		outpath = outpath[:len(outpath)-4] + "jpeg" | ||||||
|  | 
 | ||||||
|  | 		log.Debug(ctx, "generating thumb from png") | ||||||
|  | 		blurhash, err := generateNativeThumb( | ||||||
|  | 			filepath, | ||||||
|  | 			outpath, | ||||||
|  | 			width, | ||||||
|  | 			height, | ||||||
|  | 			orientation, | ||||||
|  | 			png.Decode, | ||||||
|  | 			needBlurhash, | ||||||
|  | 		) | ||||||
|  | 		return outpath, blurhash, err | ||||||
|  | 
 | ||||||
|  | 	// We specifically only allow generating native | ||||||
|  | 	// thumbnails from webp IF it doesn't contain an | ||||||
|  | 	// alpha channel. We'll ultimately be encoding to | ||||||
|  | 	// jpeg which doesn't support transparency layers. | ||||||
|  | 	case ext == "webp" && !containsAlpha(pixfmt): | ||||||
|  | 
 | ||||||
|  | 		// Replace the "webp" with "jpeg", as we'll | ||||||
|  | 		// use our native Go thumbnailing generation. | ||||||
|  | 		outpath = outpath[:len(outpath)-4] + "jpeg" | ||||||
|  | 
 | ||||||
|  | 		log.Debug(ctx, "generating thumb from webp") | ||||||
|  | 		blurhash, err := generateNativeThumb( | ||||||
|  | 			filepath, | ||||||
|  | 			outpath, | ||||||
|  | 			width, | ||||||
|  | 			height, | ||||||
|  | 			orientation, | ||||||
|  | 			webp.Decode, | ||||||
|  | 			needBlurhash, | ||||||
|  | 		) | ||||||
|  | 		return outpath, blurhash, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// The fallback for thumbnail generation, which | ||||||
|  | 	// encompasses most media types is with ffmpeg. | ||||||
|  | 	log.Debug(ctx, "generating thumb with ffmpeg") | ||||||
|  | 	if err := ffmpegGenerateWebpThumb(ctx, | ||||||
|  | 		filepath, | ||||||
|  | 		outpath, | ||||||
|  | 		width, | ||||||
|  | 		height, | ||||||
|  | 		pixfmt, | ||||||
|  | 	); err != nil { | ||||||
|  | 		return outpath, "", err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if needBlurhash { | ||||||
|  | 		// Generate new blurhash from webp output thumb. | ||||||
|  | 		blurhash, err = generateWebpBlurhash(outpath) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return outpath, "", gtserror.Newf("error generating blurhash: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return outpath, blurhash, err | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // generateNativeThumb generates a thumbnail | ||||||
|  | // using native Go code, using given decode | ||||||
|  | // function to get image, resize to given dimens, | ||||||
|  | // and write to output filepath as JPEG. If a | ||||||
|  | // blurhash is required it will also generate | ||||||
|  | // this from the image.Image while in-memory. | ||||||
|  | func generateNativeThumb( | ||||||
|  | 	inpath, outpath string, | ||||||
|  | 	width, height int, | ||||||
|  | 	orientation int, | ||||||
|  | 	decode func(io.Reader) (image.Image, error), | ||||||
|  | 	needBlurhash bool, | ||||||
|  | ) ( | ||||||
|  | 	string, // blurhash | ||||||
|  | 	error, | ||||||
|  | ) { | ||||||
|  | 	// Open input file at given path. | ||||||
|  | 	infile, err := os.Open(inpath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error opening input file %s: %w", inpath, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Decode image into memory. | ||||||
|  | 	img, err := decode(infile) | ||||||
|  | 
 | ||||||
|  | 	// Done with file. | ||||||
|  | 	_ = infile.Close() | ||||||
|  | 
 | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error decoding file %s: %w", inpath, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Apply orientation BEFORE any resize, | ||||||
|  | 	// as our image dimensions are calculated | ||||||
|  | 	// taking orientation into account. | ||||||
|  | 	switch orientation { | ||||||
|  | 	case orientationFlipH: | ||||||
|  | 		img = imaging.FlipH(img) | ||||||
|  | 	case orientationFlipV: | ||||||
|  | 		img = imaging.FlipV(img) | ||||||
|  | 	case orientationRotate90: | ||||||
|  | 		img = imaging.Rotate90(img) | ||||||
|  | 	case orientationRotate180: | ||||||
|  | 		img = imaging.Rotate180(img) | ||||||
|  | 	case orientationRotate270: | ||||||
|  | 		img = imaging.Rotate270(img) | ||||||
|  | 	case orientationTranspose: | ||||||
|  | 		img = imaging.Transpose(img) | ||||||
|  | 	case orientationTransverse: | ||||||
|  | 		img = imaging.Transverse(img) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Resize image to dimens. | ||||||
|  | 	img = imaging.Resize(img, | ||||||
|  | 		width, height, | ||||||
|  | 		imaging.Linear, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	// Open output file at given path. | ||||||
|  | 	outfile, err := os.Create(outpath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error opening output file %s: %w", outpath, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Encode in-memory image to output file. | ||||||
|  | 	// (nil uses defaults, i.e. quality=75). | ||||||
|  | 	err = jpeg.Encode(outfile, img, nil) | ||||||
|  | 
 | ||||||
|  | 	// Done with file. | ||||||
|  | 	_ = outfile.Close() | ||||||
|  | 
 | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error encoding image: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if needBlurhash { | ||||||
|  | 		// for generating blurhashes, it's more cost effective to | ||||||
|  | 		// lose detail since it's blurry, so make a tiny version. | ||||||
|  | 		tiny := imaging.Resize(img, 64, 64, imaging.NearestNeighbor) | ||||||
|  | 
 | ||||||
|  | 		// Drop the larger image | ||||||
|  | 		// ref as soon as possible | ||||||
|  | 		// to allow GC to claim. | ||||||
|  | 		img = nil //nolint | ||||||
|  | 
 | ||||||
|  | 		// Generate blurhash for the tiny thumbnail. | ||||||
|  | 		blurhash, err := blurhash.Encode(4, 3, tiny) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return "", gtserror.Newf("error generating blurhash: %w", err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		return blurhash, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return "", nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // generateWebpBlurhash generates a blurhash for Webp at filepath. | ||||||
|  | func generateWebpBlurhash(filepath string) (string, error) { | ||||||
|  | 	// Open the file at given path. | ||||||
|  | 	file, err := os.Open(filepath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error opening input file %s: %w", filepath, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Decode image from file. | ||||||
|  | 	img, err := webp.Decode(file) | ||||||
|  | 
 | ||||||
|  | 	// Done with file. | ||||||
|  | 	_ = file.Close() | ||||||
|  | 
 | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error decoding file %s: %w", filepath, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// for generating blurhashes, it's more cost effective to | ||||||
|  | 	// lose detail since it's blurry, so make a tiny version. | ||||||
|  | 	tiny := imaging.Resize(img, 64, 64, imaging.NearestNeighbor) | ||||||
|  | 
 | ||||||
|  | 	// Drop the larger image | ||||||
|  | 	// ref as soon as possible | ||||||
|  | 	// to allow GC to claim. | ||||||
|  | 	img = nil //nolint | ||||||
|  | 
 | ||||||
|  | 	// Generate blurhash for the tiny thumbnail. | ||||||
|  | 	blurhash, err := blurhash.Encode(4, 3, tiny) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", gtserror.Newf("error generating blurhash: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return blurhash, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // List of pixel formats that have an alpha layer. | ||||||
|  | // Derived from the following very messy command: | ||||||
|  | // | ||||||
|  | //	for res in $(ffprobe -show_entries pixel_format=name:flags=alpha | grep -B1 alpha=1 | grep name); do echo $res | sed 's/name=//g' | sed 's/^/"/g' | sed 's/$/",/g'; done | ||||||
|  | var alphaPixelFormats = []string{ | ||||||
|  | 	"pal8", | ||||||
|  | 	"argb", | ||||||
|  | 	"rgba", | ||||||
|  | 	"abgr", | ||||||
|  | 	"bgra", | ||||||
|  | 	"yuva420p", | ||||||
|  | 	"ya8", | ||||||
|  | 	"yuva422p", | ||||||
|  | 	"yuva444p", | ||||||
|  | 	"yuva420p9be", | ||||||
|  | 	"yuva420p9le", | ||||||
|  | 	"yuva422p9be", | ||||||
|  | 	"yuva422p9le", | ||||||
|  | 	"yuva444p9be", | ||||||
|  | 	"yuva444p9le", | ||||||
|  | 	"yuva420p10be", | ||||||
|  | 	"yuva420p10le", | ||||||
|  | 	"yuva422p10be", | ||||||
|  | 	"yuva422p10le", | ||||||
|  | 	"yuva444p10be", | ||||||
|  | 	"yuva444p10le", | ||||||
|  | 	"yuva420p16be", | ||||||
|  | 	"yuva420p16le", | ||||||
|  | 	"yuva422p16be", | ||||||
|  | 	"yuva422p16le", | ||||||
|  | 	"yuva444p16be", | ||||||
|  | 	"yuva444p16le", | ||||||
|  | 	"rgba64be", | ||||||
|  | 	"rgba64le", | ||||||
|  | 	"bgra64be", | ||||||
|  | 	"bgra64le", | ||||||
|  | 	"ya16be", | ||||||
|  | 	"ya16le", | ||||||
|  | 	"gbrap", | ||||||
|  | 	"gbrap16be", | ||||||
|  | 	"gbrap16le", | ||||||
|  | 	"ayuv64le", | ||||||
|  | 	"ayuv64be", | ||||||
|  | 	"gbrap12be", | ||||||
|  | 	"gbrap12le", | ||||||
|  | 	"gbrap10be", | ||||||
|  | 	"gbrap10le", | ||||||
|  | 	"gbrapf32be", | ||||||
|  | 	"gbrapf32le", | ||||||
|  | 	"yuva422p12be", | ||||||
|  | 	"yuva422p12le", | ||||||
|  | 	"yuva444p12be", | ||||||
|  | 	"yuva444p12le", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // containsAlpha returns whether given pixfmt | ||||||
|  | // (i.e. colorspace) contains an alpha channel. | ||||||
|  | func containsAlpha(pixfmt string) bool { | ||||||
|  | 	if pixfmt == "" { | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | 	for _, checkfmt := range alphaPixelFormats { | ||||||
|  | 		if pixfmt == checkfmt { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  | @ -21,20 +21,24 @@ import ( | ||||||
| 	"cmp" | 	"cmp" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"image" |  | ||||||
| 	"io" | 	"io" | ||||||
| 	"os" | 	"os" | ||||||
| 
 | 
 | ||||||
| 	"golang.org/x/image/webp" |  | ||||||
| 
 |  | ||||||
| 	"codeberg.org/gruf/go-bytesize" | 	"codeberg.org/gruf/go-bytesize" | ||||||
| 	"codeberg.org/gruf/go-iotools" | 	"codeberg.org/gruf/go-iotools" | ||||||
| 	"codeberg.org/gruf/go-mimetypes" | 	"codeberg.org/gruf/go-mimetypes" | ||||||
| 
 |  | ||||||
| 	"github.com/buckket/go-blurhash" |  | ||||||
| 	"github.com/disintegration/imaging" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // getExtension splits file extension from path. | ||||||
|  | func getExtension(path string) string { | ||||||
|  | 	for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- { | ||||||
|  | 		if path[i] == '.' { | ||||||
|  | 			return path[i+1:] | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return "" | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // thumbSize returns the dimensions to use for an input | // thumbSize returns the dimensions to use for an input | ||||||
| // image of given width / height, for its outgoing thumbnail. | // image of given width / height, for its outgoing thumbnail. | ||||||
| // This attempts to maintains the original image aspect ratio. | // This attempts to maintains the original image aspect ratio. | ||||||
|  | @ -68,44 +72,6 @@ func thumbSize(width, height int, aspect float32) (int, int) { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // webpDecode decodes the WebP at filepath into parsed image.Image. |  | ||||||
| func webpDecode(filepath string) (image.Image, error) { |  | ||||||
| 	// Open the file at given path. |  | ||||||
| 	file, err := os.Open(filepath) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Decode image from file. |  | ||||||
| 	img, err := webp.Decode(file) |  | ||||||
| 
 |  | ||||||
| 	// Done with file. |  | ||||||
| 	_ = file.Close() |  | ||||||
| 
 |  | ||||||
| 	return img, err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // generateBlurhash generates a blurhash for JPEG at filepath. |  | ||||||
| func generateBlurhash(filepath string) (string, error) { |  | ||||||
| 	// Decode JPEG file at given path. |  | ||||||
| 	img, err := webpDecode(filepath) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return "", err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// for generating blurhashes, it's more cost effective to |  | ||||||
| 	// lose detail since it's blurry, so make a tiny version. |  | ||||||
| 	tiny := imaging.Resize(img, 64, 64, imaging.NearestNeighbor) |  | ||||||
| 
 |  | ||||||
| 	// Drop the larger image |  | ||||||
| 	// ref as soon as possible |  | ||||||
| 	// to allow GC to claim. |  | ||||||
| 	img = nil //nolint |  | ||||||
| 
 |  | ||||||
| 	// Generate blurhash for thumbnail. |  | ||||||
| 	return blurhash.Encode(4, 3, tiny) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // getMimeType returns a suitable mimetype for file extension. | // getMimeType returns a suitable mimetype for file extension. | ||||||
| func getMimeType(ext string) string { | func getMimeType(ext string) string { | ||||||
| 	const defaultType = "application/octet-stream" | 	const defaultType = "application/octet-stream" | ||||||
|  |  | ||||||
|  | @ -197,7 +197,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileThumbnailUncached() { | ||||||
| 	suite.NoError(content.Content.Close()) | 	suite.NoError(content.Content.Close()) | ||||||
| 
 | 
 | ||||||
| 	suite.Equal(thumbnailBytes, b) | 	suite.Equal(thumbnailBytes, b) | ||||||
| 	suite.Equal("image/webp", content.ContentType) | 	suite.Equal("image/jpeg", content.ContentType) | ||||||
| 	suite.EqualValues(testAttachment.Thumbnail.FileSize, content.ContentLength) | 	suite.EqualValues(testAttachment.Thumbnail.FileSize, content.ContentLength) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/ohyou-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
| Before Width: | Height: | Size: 4.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/sloth-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 55 KiB | 
| Before Width: | Height: | Size: 41 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/team-fortress-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 26 KiB | 
| Before Width: | Height: | Size: 10 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/thoughtsofdog-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 20 KiB | 
| Before Width: | Height: | Size: 8.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/trent-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 6.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/welcome-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
| Before Width: | Height: | Size: 5.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								testrig/media/zork-small.jpeg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 49 KiB | 
| Before Width: | Height: | Size: 35 KiB | 
|  | @ -736,14 +736,14 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Blurhash:          "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", | 			Blurhash:          "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", | ||||||
| 			Processing:        2, | 			Processing:        2, | ||||||
| 			File: gtsmodel.File{ | 			File: gtsmodel.File{ | ||||||
| 				Path:        "01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", | 				Path:        "01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", | ||||||
| 				ContentType: "image/jpeg", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    62529, | 				FileSize:    62529, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp", | 				Path:        "01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/webp", | ||||||
| 				FileSize:    5376, | 				FileSize:    17605, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -788,9 +788,9 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 				FileSize:    1109138, | 				FileSize:    1109138, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.webp", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    6336, | 				FileSize:    10270, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH7TDVANYKWVE8VVKFPJTJ.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -840,7 +840,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.webp", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.webp", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/webp", | ||||||
| 				FileSize:    5446, | 				FileSize:    11570, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01CDR64G398ADCHXK08WWTHEZ5.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -885,9 +885,9 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 				FileSize:    27759, | 				FileSize:    27759, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.webp", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    4930, | 				FileSize:    14665, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -927,14 +927,14 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Blurhash:          "LHI:dk=G|rj]H[J-5roJvnr@Opag", | 			Blurhash:          "LHI:dk=G|rj]H[J-5roJvnr@Opag", | ||||||
| 			Processing:        2, | 			Processing:        2, | ||||||
| 			File: gtsmodel.File{ | 			File: gtsmodel.File{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg", | ||||||
| 				ContentType: "image/jpeg", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    457680, | 				FileSize:    457680, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    36188, | 				FileSize:    50381, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -974,14 +974,14 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Blurhash:          "L17KPDs:$ykDJroJ-RoJ0fR+xVjY", | 			Blurhash:          "L17KPDs:$ykDJroJ-RoJ0fR+xVjY", | ||||||
| 			Processing:        2, | 			Processing:        2, | ||||||
| 			File: gtsmodel.File{ | 			File: gtsmodel.File{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", | ||||||
| 				ContentType: "image/jpeg", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    517226, | 				FileSize:    517226, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    10200, | 				FileSize:    26794, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -1031,7 +1031,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01J2M20K6K9XQC4WSB961YJHV6.webp", | 				Path:        "01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01J2M20K6K9XQC4WSB961YJHV6.webp", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/webp", | ||||||
| 				FileSize:    4652, | 				FileSize:    11624, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01J2M20K6K9XQC4WSB961YJHV6.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01J2M20K6K9XQC4WSB961YJHV6.webp", | ||||||
| 				RemoteURL:   "", | 				RemoteURL:   "", | ||||||
| 			}, | 			}, | ||||||
|  | @ -1071,14 +1071,14 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Blurhash:          "L3Q9_@4n9E?axW4mD$Mx~q00Di%L", | 			Blurhash:          "L3Q9_@4n9E?axW4mD$Mx~q00Di%L", | ||||||
| 			Processing:        2, | 			Processing:        2, | ||||||
| 			File: gtsmodel.File{ | 			File: gtsmodel.File{ | ||||||
| 				Path:        "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", | 				Path:        "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", | ||||||
| 				ContentType: "image/jpeg", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    19310, | 				FileSize:    19310, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.webp", | 				Path:        "01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    9128, | 				FileSize:    20394, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.webp", | 				URL:         "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.webp", | ||||||
| 			}, | 			}, | ||||||
| 			Avatar: util.Ptr(false), | 			Avatar: util.Ptr(false), | ||||||
|  | @ -1117,14 +1117,14 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Blurhash:          "L3Q9_@4n9E?axW4mD$Mx~q00Di%L", | 			Blurhash:          "L3Q9_@4n9E?axW4mD$Mx~q00Di%L", | ||||||
| 			Processing:        2, | 			Processing:        2, | ||||||
| 			File: gtsmodel.File{ | 			File: gtsmodel.File{ | ||||||
| 				Path:        "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", | 				Path:        "062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg", | ||||||
| 				ContentType: "image/jpeg", | 				ContentType: "image/jpeg", | ||||||
| 				FileSize:    19310, | 				FileSize:    19310, | ||||||
| 			}, | 			}, | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.webp", | 				Path:        "062G5WYKY35KKD12EMSM3F8PJ8/attachment/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpeg", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/webp", | ||||||
| 				FileSize:    9128, | 				FileSize:    20394, | ||||||
| 				URL:         "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.webp", | 				URL:         "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.webp", | ||||||
| 			}, | 			}, | ||||||
| 			Avatar: util.Ptr(false), | 			Avatar: util.Ptr(false), | ||||||
|  | @ -1169,7 +1169,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Thumbnail: gtsmodel.Thumbnail{ | 			Thumbnail: gtsmodel.Thumbnail{ | ||||||
| 				Path:        "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.webp", | 				Path:        "01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.webp", | ||||||
| 				ContentType: "image/webp", | 				ContentType: "image/webp", | ||||||
| 				FileSize:    42208, | 				FileSize:    55966, | ||||||
| 				URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.webp", | 				URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.webp", | ||||||
| 			}, | 			}, | ||||||
| 			Avatar: util.Ptr(false), | 			Avatar: util.Ptr(false), | ||||||
|  | @ -1355,11 +1355,11 @@ func newTestStoredAttachments() map[string]filenames { | ||||||
| 	return map[string]filenames{ | 	return map[string]filenames{ | ||||||
| 		"admin_account_status_1_attachment_1": { | 		"admin_account_status_1_attachment_1": { | ||||||
| 			Original: "welcome-original.jpg", | 			Original: "welcome-original.jpg", | ||||||
| 			Small:    "welcome-small.webp", | 			Small:    "welcome-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_1_status_4_attachment_1": { | 		"local_account_1_status_4_attachment_1": { | ||||||
| 			Original: "trent-original.gif", | 			Original: "trent-original.gif", | ||||||
| 			Small:    "trent-small.webp", | 			Small:    "trent-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_1_status_4_attachment_2": { | 		"local_account_1_status_4_attachment_2": { | ||||||
| 			Original: "cowlick-original.mp4", | 			Original: "cowlick-original.mp4", | ||||||
|  | @ -1367,15 +1367,15 @@ func newTestStoredAttachments() map[string]filenames { | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_1_unattached_1": { | 		"local_account_1_unattached_1": { | ||||||
| 			Original: "ohyou-original.jpg", | 			Original: "ohyou-original.jpg", | ||||||
| 			Small:    "ohyou-small.webp", | 			Small:    "ohyou-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_1_avatar": { | 		"local_account_1_avatar": { | ||||||
| 			Original: "zork-original.jpg", | 			Original: "zork-original.jpg", | ||||||
| 			Small:    "zork-small.webp", | 			Small:    "zork-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_1_header": { | 		"local_account_1_header": { | ||||||
| 			Original: "team-fortress-original.jpg", | 			Original: "team-fortress-original.jpg", | ||||||
| 			Small:    "team-fortress-small.webp", | 			Small:    "team-fortress-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 		"local_account_1_status_8_attachment_1": { | 		"local_account_1_status_8_attachment_1": { | ||||||
| 			Original: "ghosts-original.mp3", | 			Original: "ghosts-original.mp3", | ||||||
|  | @ -1383,11 +1383,11 @@ func newTestStoredAttachments() map[string]filenames { | ||||||
| 		}, | 		}, | ||||||
| 		"remote_account_1_status_1_attachment_1": { | 		"remote_account_1_status_1_attachment_1": { | ||||||
| 			Original: "thoughtsofdog-original.jpg", | 			Original: "thoughtsofdog-original.jpg", | ||||||
| 			Small:    "thoughtsofdog-small.webp", | 			Small:    "thoughtsofdog-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 		"remote_account_2_status_1_attachment_1": { | 		"remote_account_2_status_1_attachment_1": { | ||||||
| 			Original: "sloth-original.jpg", | 			Original: "sloth-original.jpg", | ||||||
| 			Small:    "sloth-small.webp", | 			Small:    "sloth-small.jpeg", | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||