[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,15 +248,35 @@ 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 {
|
||||||
format string
|
format string
|
||||||
audio []audioStream
|
audio []audioStream
|
||||||
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
|
|
||||||
// already been specified.
|
|
||||||
if r.rotation != 0 {
|
|
||||||
return nil, errors.New("multiple sets of rotation data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
r.rotation = (rot % 360)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure orientation not
|
||||||
|
// already been specified.
|
||||||
|
if r.orientation != 0 {
|
||||||
|
return nil, errors.New("multiple sets of orientation data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim any space from orientation value.
|
||||||
|
str := strings.TrimSpace(pf.Tags.Orientation)
|
||||||
|
|
||||||
|
// Parse as integer value.
|
||||||
|
i, _ := strconv.Atoi(str)
|
||||||
|
if i <= 0 || i >= 9 {
|
||||||
|
return nil, errors.New("invalid orientation data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set orientation.
|
||||||
|
r.orientation = i
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
@ -539,17 +553,18 @@ 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,31 +230,26 @@ 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.
|
||||||
|
@ -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 |
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 |
After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 2.6 KiB |
|
@ -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 |
After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 41 KiB |
After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 8.9 KiB |
After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 5.3 KiB |
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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|