diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/instance/instancepatch_test.go | 10 | ||||
-rw-r--r-- | internal/media/image.go | 85 | ||||
-rw-r--r-- | internal/media/manager_test.go | 72 | ||||
-rw-r--r-- | internal/media/processingmedia.go | 68 | ||||
-rw-r--r-- | internal/media/test/test-mp4-original.mp4 | bin | 0 -> 312413 bytes | |||
-rw-r--r-- | internal/media/test/test-mp4-processed.mp4 | bin | 0 -> 312413 bytes | |||
-rw-r--r-- | internal/media/test/test-mp4-thumbnail.jpg | bin | 0 -> 1912 bytes | |||
-rw-r--r-- | internal/media/types.go | 13 | ||||
-rw-r--r-- | internal/media/util.go | 15 | ||||
-rw-r--r-- | internal/media/video.go | 140 |
10 files changed, 319 insertions, 84 deletions
diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go index edf724d4a..353ae9c16 100644 --- a/internal/api/client/instance/instancepatch_test.go +++ b/internal/api/client/instance/instancepatch_test.go @@ -65,7 +65,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Example Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"someone@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Example Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"someone@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp","video/mp4"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch2() { @@ -95,7 +95,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Geoff's Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Geoff's Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp","video/mp4"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch3() { @@ -125,7 +125,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp","video/mp4"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch4() { @@ -216,7 +216,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp","video/mp4"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch7() { @@ -279,7 +279,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() { } suite.NotEmpty(instanceAccount.AvatarMediaAttachmentID) - expectedInstanceResponse := fmt.Sprintf(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/fileserver/%s/attachment/original/%s.gif","thumbnail_type":"image/gif","thumbnail_description":"A bouncing little green peglin.","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, instanceAccount.ID, instanceAccount.AvatarMediaAttachmentID) + expectedInstanceResponse := fmt.Sprintf(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png","image/webp","video/mp4"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/fileserver/%s/attachment/original/%s.gif","thumbnail_type":"image/gif","thumbnail_description":"A bouncing little green peglin.","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"max_toot_chars":5000}`, instanceAccount.ID, instanceAccount.AvatarMediaAttachmentID) suite.Equal(expectedInstanceResponse, string(b)) } diff --git a/internal/media/image.go b/internal/media/image.go index b095a6c49..aedac5707 100644 --- a/internal/media/image.go +++ b/internal/media/image.go @@ -38,16 +38,7 @@ const ( thumbnailMaxHeight = 512 ) -type imageMeta struct { - width int - height int - size int - aspect float64 - blurhash string // defined only for calls to deriveThumbnail if createBlurhash is true - small []byte // defined only for calls to deriveStaticEmoji or deriveThumbnail -} - -func decodeGif(r io.Reader) (*imageMeta, error) { +func decodeGif(r io.Reader) (*mediaMeta, error) { gif, err := gif.DecodeAll(r) if err != nil { return nil, err @@ -59,7 +50,7 @@ func decodeGif(r io.Reader) (*imageMeta, error) { size := width * height aspect := float64(width) / float64(height) - return &imageMeta{ + return &mediaMeta{ width: width, height: height, size: size, @@ -67,7 +58,7 @@ func decodeGif(r io.Reader) (*imageMeta, error) { }, nil } -func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { +func decodeImage(r io.Reader, contentType string) (*mediaMeta, error) { var i image.Image var err error @@ -96,7 +87,7 @@ func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { size := width * height aspect := float64(width) / float64(height) - return &imageMeta{ + return &mediaMeta{ width: width, height: height, size: size, @@ -104,8 +95,37 @@ func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { }, nil } -// deriveThumbnail returns a byte slice and metadata for a thumbnail -// of a given jpeg, png, gif or webp, or an error if something goes wrong. +// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. +func deriveStaticEmoji(r io.Reader, contentType string) (*mediaMeta, error) { + var i image.Image + var err error + + switch contentType { + case mimeImagePng: + i, err = StrippedPngDecode(r) + if err != nil { + return nil, err + } + case mimeImageGif: + i, err = gif.Decode(r) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) + } + + out := &bytes.Buffer{} + if err := png.Encode(out, i); err != nil { + return nil, err + } + return &mediaMeta{ + small: out.Bytes(), + }, nil +} + +// deriveThumbnailFromImage returns a byte slice and metadata for a thumbnail +// of a given piece of media, or an error if something goes wrong. // // If createBlurhash is true, then a blurhash will also be generated from a tiny // version of the image. This costs precious CPU cycles, so only use it if you @@ -113,7 +133,7 @@ func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { // // If createBlurhash is false, then the blurhash field on the returned ImageAndMeta // will be an empty string. -func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*imageMeta, error) { +func deriveThumbnailFromImage(r io.Reader, contentType string, createBlurhash bool) (*mediaMeta, error) { var i image.Image var err error @@ -126,7 +146,7 @@ func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*ima }) i, err = imaging.Decode(strippedPngReader, imaging.AutoOrientation(true)) default: - err = fmt.Errorf("content type %s can't be thumbnailed", contentType) + err = fmt.Errorf("content type %s can't be thumbnailed as an image", contentType) } if err != nil { @@ -149,7 +169,7 @@ func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*ima size := thumbX * thumbY aspect := float64(thumbX) / float64(thumbY) - im := &imageMeta{ + im := &mediaMeta{ width: thumbX, height: thumbY, size: size, @@ -178,32 +198,3 @@ func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*ima return im, nil } - -// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. -func deriveStaticEmoji(r io.Reader, contentType string) (*imageMeta, error) { - var i image.Image - var err error - - switch contentType { - case mimeImagePng: - i, err = StrippedPngDecode(r) - if err != nil { - return nil, err - } - case mimeImageGif: - i, err = gif.Decode(r) - if err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) - } - - out := &bytes.Buffer{} - if err := png.Encode(out, i); err != nil { - return nil, err - } - return &imageMeta{ - small: out.Bytes(), - }, nil -} diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go index 659740af6..a8912bde0 100644 --- a/internal/media/manager_test.go +++ b/internal/media/manager_test.go @@ -376,6 +376,78 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) } +func (suite *ManagerTestSuite) TestSlothVineProcessBlocking() { + ctx := context.Background() + + data := func(_ context.Context) (io.ReadCloser, int64, error) { + // load bytes from a test video + b, err := os.ReadFile("./test/test-mp4-original.mp4") + if err != nil { + panic(err) + } + return io.NopCloser(bytes.NewBuffer(b)), int64(len(b)), nil + } + + accountID := "01FS1X72SK9ZPW0J1QQ68BD264" + + // process the media with no additional info provided + processingMedia, err := suite.manager.ProcessMedia(ctx, data, nil, accountID, nil) + suite.NoError(err) + // fetch the attachment id from the processing media + attachmentID := processingMedia.AttachmentID() + + // do a blocking call to fetch the attachment + attachment, err := processingMedia.LoadAttachment(ctx) + suite.NoError(err) + suite.NotNil(attachment) + + // make sure it's got the stuff set on it that we expect + // the attachment ID and accountID we expect + suite.Equal(attachmentID, attachment.ID) + suite.Equal(accountID, attachment.AccountID) + + // file meta should be correctly derived from the video + suite.EqualValues(gtsmodel.Original{ + Width: 338, Height: 240, Size: 81120, Aspect: 1.4083333333333334, + }, attachment.FileMeta.Original) + suite.EqualValues(gtsmodel.Small{ + Width: 338, Height: 240, Size: 81120, Aspect: 1.4083333333333334, + }, attachment.FileMeta.Small) + suite.Equal("video/mp4", attachment.File.ContentType) + suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) + suite.Equal(312413, attachment.File.FileSize) + suite.Equal("", attachment.Blurhash) + + // now make sure the attachment is in the database + dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) + suite.NoError(err) + suite.NotNil(dbAttachment) + + // make sure the processed file is in storage + processedFullBytes, err := suite.storage.Get(ctx, attachment.File.Path) + suite.NoError(err) + suite.NotEmpty(processedFullBytes) + + // load the processed bytes from our test folder, to compare + processedFullBytesExpected, err := os.ReadFile("./test/test-mp4-processed.mp4") + suite.NoError(err) + suite.NotEmpty(processedFullBytesExpected) + + // the bytes in storage should be what we expected + suite.Equal(processedFullBytesExpected, processedFullBytes) + + // now do the same for the thumbnail and make sure it's what we expected + processedThumbnailBytes, err := suite.storage.Get(ctx, attachment.Thumbnail.Path) + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytes) + + processedThumbnailBytesExpected, err := os.ReadFile("./test/test-mp4-thumbnail.jpg") + suite.NoError(err) + suite.NotEmpty(processedThumbnailBytesExpected) + + suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) +} + func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingNoContentLengthGiven() { ctx := context.Background() diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go index 94c8f9a7a..a7ea4dbab 100644 --- a/internal/media/processingmedia.go +++ b/internal/media/processingmedia.go @@ -88,11 +88,11 @@ func (p *ProcessingMedia) LoadAttachment(ctx context.Context) (*gtsmodel.MediaAt return nil, err } - if err := p.loadThumb(ctx); err != nil { + if err := p.loadFullSize(ctx); err != nil { return nil, err } - if err := p.loadFullSize(ctx); err != nil { + if err := p.loadThumb(ctx); err != nil { return nil, err } @@ -128,7 +128,6 @@ func (p *ProcessingMedia) loadThumb(ctx context.Context) error { switch processState(thumbState) { case received: // we haven't processed a thumbnail for this media yet so do it now - // check if we need to create a blurhash or if there's already one set var createBlurhash bool if p.attachment.Blurhash == "" { @@ -136,28 +135,47 @@ func (p *ProcessingMedia) loadThumb(ctx context.Context) error { createBlurhash = true } - // stream the original file out of storage - stored, err := p.storage.GetStream(ctx, p.attachment.File.Path) - if err != nil { - p.err = fmt.Errorf("loadThumb: error fetching file from storage: %s", err) - atomic.StoreInt32(&p.thumbState, int32(errored)) - return p.err - } - defer stored.Close() + var ( + thumb *mediaMeta + err error + ) + switch ct := p.attachment.File.ContentType; ct { + case mimeImageJpeg, mimeImagePng, mimeImageWebp, mimeImageGif: + // thumbnail the image from the original stored full size version + stored, err := p.storage.GetStream(ctx, p.attachment.File.Path) + if err != nil { + p.err = fmt.Errorf("loadThumb: error fetching file from storage: %s", err) + atomic.StoreInt32(&p.thumbState, int32(errored)) + return p.err + } - // stream the file from storage straight into the derive thumbnail function - thumb, err := deriveThumbnail(stored, p.attachment.File.ContentType, createBlurhash) - if err != nil { - p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err) + thumb, err = deriveThumbnailFromImage(stored, ct, createBlurhash) + + // try to close the stored stream we had open, no matter what + if closeErr := stored.Close(); closeErr != nil { + log.Errorf("error closing stream: %s", closeErr) + } + + // now check if we managed to get a thumbnail + if err != nil { + p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err) + atomic.StoreInt32(&p.thumbState, int32(errored)) + return p.err + } + case mimeVideoMp4: + // create a generic thumbnail based on video height + width + thumb, err = deriveThumbnailFromVideo(p.attachment.FileMeta.Original.Height, p.attachment.FileMeta.Original.Width) + if err != nil { + p.err = fmt.Errorf("loadThumb: error deriving thumbnail: %s", err) + atomic.StoreInt32(&p.thumbState, int32(errored)) + return p.err + } + default: + p.err = fmt.Errorf("loadThumb: content type %s not a processible image type", ct) atomic.StoreInt32(&p.thumbState, int32(errored)) return p.err } - // Close stored media now we're done - if err := stored.Close(); err != nil { - log.Errorf("loadThumb: error closing stored full size: %s", err) - } - // put the thumbnail in storage if err := p.storage.Put(ctx, p.attachment.Thumbnail.Path, thumb.small); err != nil && err != storage.ErrAlreadyExists { p.err = fmt.Errorf("loadThumb: error storing thumbnail: %s", err) @@ -195,7 +213,7 @@ func (p *ProcessingMedia) loadFullSize(ctx context.Context) error { switch processState(fullSizeState) { case received: var err error - var decoded *imageMeta + var decoded *mediaMeta // stream the original file out of storage... stored, err := p.storage.GetStream(ctx, p.attachment.File.Path) @@ -218,6 +236,8 @@ func (p *ProcessingMedia) loadFullSize(ctx context.Context) error { decoded, err = decodeImage(stored, ct) case mimeImageGif: decoded, err = decodeGif(stored) + case mimeVideoMp4: + decoded, err = decodeVideo(stored, ct) default: err = fmt.Errorf("loadFullSize: content type %s not a processible image type", ct) } @@ -295,7 +315,7 @@ func (p *ProcessingMedia) store(ctx context.Context) error { } // bail if this is a type we can't process - if !supportedImage(contentType) { + if !supportedAttachment(contentType) { return fmt.Errorf("store: media type %s not (yet) supported", contentType) } @@ -338,6 +358,10 @@ func (p *ProcessingMedia) store(ctx context.Context) error { // can't terminate if we don't know the file size, so just store the multiReader readerToStore = multiReader } + case mimeMp4: + p.attachment.Type = gtsmodel.FileTypeVideo + // nothing to terminate, we can just store the multireader + readerToStore = multiReader default: return fmt.Errorf("store: couldn't process %s", extension) } diff --git a/internal/media/test/test-mp4-original.mp4 b/internal/media/test/test-mp4-original.mp4 Binary files differnew file mode 100644 index 000000000..f78f51de6 --- /dev/null +++ b/internal/media/test/test-mp4-original.mp4 diff --git a/internal/media/test/test-mp4-processed.mp4 b/internal/media/test/test-mp4-processed.mp4 Binary files differnew file mode 100644 index 000000000..f78f51de6 --- /dev/null +++ b/internal/media/test/test-mp4-processed.mp4 diff --git a/internal/media/test/test-mp4-thumbnail.jpg b/internal/media/test/test-mp4-thumbnail.jpg Binary files differnew file mode 100644 index 000000000..8bfdf1540 --- /dev/null +++ b/internal/media/test/test-mp4-thumbnail.jpg diff --git a/internal/media/types.go b/internal/media/types.go index b855d72b5..e7edfe643 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -34,6 +34,7 @@ const maxFileHeaderBytes = 261 // mime consts const ( mimeImage = "image" + mimeVideo = "video" mimeJpeg = "jpeg" mimeImageJpeg = mimeImage + "/" + mimeJpeg @@ -46,6 +47,9 @@ const ( mimeWebp = "webp" mimeImageWebp = mimeImage + "/" + mimeWebp + + mimeMp4 = "mp4" + mimeVideoMp4 = mimeVideo + "/" + mimeMp4 ) type processState int32 @@ -128,3 +132,12 @@ type DataFunc func(ctx context.Context) (reader io.ReadCloser, fileSize int64, e // // This can be set to nil, and will then not be executed. type PostDataCallbackFunc func(ctx context.Context) error + +type mediaMeta struct { + width int + height int + size int + aspect float64 + blurhash string + small []byte +} diff --git a/internal/media/util.go b/internal/media/util.go index 60661cbc0..387f5d65a 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -37,6 +37,7 @@ func AllSupportedMIMETypes() []string { mimeImageGif, mimeImagePng, mimeImageWebp, + mimeVideoMp4, } } @@ -61,16 +62,10 @@ func parseContentType(fileHeader []byte) (string, error) { return kind.MIME.Value, nil } -// supportedImage checks mime type of an image against a slice of accepted types, -// and returns True if the mime type is accepted. -func supportedImage(mimeType string) bool { - acceptedImageTypes := []string{ - mimeImageJpeg, - mimeImageGif, - mimeImagePng, - mimeImageWebp, - } - for _, accepted := range acceptedImageTypes { +// supportedAttachment checks mime type of an attachment against a +// slice of accepted types, and returns True if the mime type is accepted. +func supportedAttachment(mimeType string) bool { + for _, accepted := range AllSupportedMIMETypes() { if mimeType == accepted { return true } diff --git a/internal/media/video.go b/internal/media/video.go new file mode 100644 index 000000000..ef486d63d --- /dev/null +++ b/internal/media/video.go @@ -0,0 +1,140 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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 ( + "bytes" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "io" + "os" + + "github.com/abema/go-mp4" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +var thumbFill = color.RGBA{42, 43, 47, 0} // the color to fill video thumbnails with + +func decodeVideo(r io.Reader, contentType string) (*mediaMeta, error) { + // We'll need a readseeker to decode the video. We can get a readseeker + // without burning too much mem by first copying the reader into a temp file. + // First create the file in the temporary directory... + tempFile, err := os.CreateTemp(os.TempDir(), "gotosocial-") + if err != nil { + return nil, fmt.Errorf("could not create temporary file while decoding video: %w", err) + } + tempFileName := tempFile.Name() + + // Make sure to clean up the temporary file when we're done with it + defer func() { + if err := tempFile.Close(); err != nil { + log.Errorf("could not close file %s: %s", tempFileName, err) + } + if err := os.Remove(tempFileName); err != nil { + log.Errorf("could not remove file %s: %s", tempFileName, err) + } + }() + + // Now copy the entire reader we've been provided into the + // temporary file; we won't use the reader again after this. + if _, err := io.Copy(tempFile, r); err != nil { + return nil, fmt.Errorf("could not copy video reader into temporary file %s: %w", tempFileName, err) + } + + // define some vars we need to pull the width/height out of the video + var ( + height int + width int + readHandler = getReadHandler(&height, &width) + ) + + // do the actual decoding here, providing the temporary file we created as readseeker + if _, err := mp4.ReadBoxStructure(tempFile, readHandler); err != nil { + return nil, fmt.Errorf("parsing video data: %w", err) + } + + // width + height should now be updated by the readHandler + return &mediaMeta{ + width: width, + height: height, + size: height * width, + aspect: float64(width) / float64(height), + }, nil +} + +// getReadHandler returns a handler function that updates the underling +// values of the given height and width int pointers to the hightest and +// widest points of the video. +func getReadHandler(height *int, width *int) func(h *mp4.ReadHandle) (interface{}, error) { + return func(rh *mp4.ReadHandle) (interface{}, error) { + if rh.BoxInfo.Type == mp4.BoxTypeTkhd() { + box, _, err := rh.ReadPayload() + if err != nil { + return nil, fmt.Errorf("could not read mp4 payload: %w", err) + } + + tkhd, ok := box.(*mp4.Tkhd) + if !ok { + return nil, errors.New("box was not of type *mp4.Tkhd") + } + + // if height + width of this box are greater than what + // we have stored, then update our stored values + if h := int(tkhd.GetHeight()); h > *height { + *height = h + } + + if w := int(tkhd.GetWidth()); w > *width { + *width = w + } + } + + if rh.BoxInfo.IsSupportedType() { + return rh.Expand() + } + + return nil, nil + } +} + +func deriveThumbnailFromVideo(height int, width int) (*mediaMeta, error) { + // create a rectangle with the same dimensions as the video + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // fill the rectangle with our desired fill color + draw.Draw(img, img.Bounds(), &image.Uniform{thumbFill}, image.Point{}, draw.Src) + + // we can get away with using extremely poor quality for this monocolor thumbnail + out := &bytes.Buffer{} + if err := jpeg.Encode(out, img, &jpeg.Options{Quality: 1}); err != nil { + return nil, fmt.Errorf("error encoding video thumbnail: %w", err) + } + + return &mediaMeta{ + width: width, + height: height, + size: width * height, + aspect: float64(width) / float64(height), + small: out.Bytes(), + }, nil +} |