diff --git a/README.md b/README.md index baa0258..afd7f3e 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ using SkylabStudio; var apiClient = new StudioClient("YOUR_SKYLAB_API_TOKEN"); -// option to configure max concurrent downloads (for when using DownloadAllPhotos method) -// defaults to 5 concurrent downloads at a time -var studioOptions = new StudioOptions { MaxConcurrentDownloads = 5 }; +// optional: to configure max concurrent downloads (for when using DownloadAllPhotos method) +// - defaults to 5 concurrent downloads at a time +// optional: to resize oversized images to be below Skylab dimension limit +// - defaults to false +var studioOptions = new StudioOptions { MaxConcurrentDownloads = 5, ResizeImageIfOversized = true }; var apiClient = new StudioClient(Environment.GetEnvironmentVariable("SKYLAB_API_TOKEN"), studioOptions); ``` diff --git a/StudioClient.Example/StudioClient.Example.csproj b/StudioClient.Example/StudioClient.Example.csproj index 4c4a7bd..a377786 100644 --- a/StudioClient.Example/StudioClient.Example.csproj +++ b/StudioClient.Example/StudioClient.Example.csproj @@ -6,9 +6,11 @@ Exe - net7.0 + net48 enable enable + latest + AnyCPU;x64 diff --git a/StudioClient.sln b/StudioClient.sln index 290b88b..e6ffc49 100644 --- a/StudioClient.sln +++ b/StudioClient.sln @@ -3,32 +3,49 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StudioClient", "StudioClient\StudioClient.csproj", "{0F41D41D-9264-4E44-8313-4F959BC82C31}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StudioClient", "StudioClient\StudioClient.csproj", "{0F41D41D-9264-4E44-8313-4F959BC82C31}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StudioClient.Example", "StudioClient.Example\StudioClient.Example.csproj", "{1D0ACA01-210A-42F3-BC79-B772BF55D9C0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StudioClient.Example", "StudioClient.Example\StudioClient.Example.csproj", "{1D0ACA01-210A-42F3-BC79-B772BF55D9C0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StudioClient.Tests", "StudioClient.Tests\StudioClient.Tests.csproj", "{5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StudioClient.Tests", "StudioClient.Tests\StudioClient.Tests.csproj", "{5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {0F41D41D-9264-4E44-8313-4F959BC82C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0F41D41D-9264-4E44-8313-4F959BC82C31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F41D41D-9264-4E44-8313-4F959BC82C31}.Debug|x64.ActiveCfg = Debug|Any CPU + {0F41D41D-9264-4E44-8313-4F959BC82C31}.Debug|x64.Build.0 = Debug|Any CPU {0F41D41D-9264-4E44-8313-4F959BC82C31}.Release|Any CPU.ActiveCfg = Release|Any CPU {0F41D41D-9264-4E44-8313-4F959BC82C31}.Release|Any CPU.Build.0 = Release|Any CPU + {0F41D41D-9264-4E44-8313-4F959BC82C31}.Release|x64.ActiveCfg = Release|Any CPU + {0F41D41D-9264-4E44-8313-4F959BC82C31}.Release|x64.Build.0 = Release|Any CPU {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Debug|x64.ActiveCfg = Debug|x64 + {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Debug|x64.Build.0 = Debug|x64 {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Release|Any CPU.ActiveCfg = Release|Any CPU {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Release|Any CPU.Build.0 = Release|Any CPU + {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Release|x64.ActiveCfg = Release|x64 + {1D0ACA01-210A-42F3-BC79-B772BF55D9C0}.Release|x64.Build.0 = Release|x64 {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Debug|x64.ActiveCfg = Debug|Any CPU + {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Debug|x64.Build.0 = Debug|Any CPU {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Release|Any CPU.ActiveCfg = Release|Any CPU {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Release|Any CPU.Build.0 = Release|Any CPU + {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Release|x64.ActiveCfg = Release|Any CPU + {5AB68DEF-291E-4A80-9B64-69DAFC3EA10B}.Release|x64.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CE6262B9-9C94-4708-9C5F-2044818CEED3} EndGlobalSection EndGlobal diff --git a/StudioClient/Photo.cs b/StudioClient/Photo.cs index 28fab1f..bb430e2 100644 --- a/StudioClient/Photo.cs +++ b/StudioClient/Photo.cs @@ -1,4 +1,6 @@ +using System.Drawing.Imaging; using System.Security.Cryptography; +using NetVips; using Newtonsoft.Json.Linq; using RestSharp; @@ -12,11 +14,40 @@ public partial class StudioClient /// public static readonly string[] VALID_EXTENSIONS = { ".png", ".jpg", ".jpeg", ".webp" }; + /// + /// Maximum allowed pixels for a photo. + /// + public const int MAX_PHOTO_PIXELS = 27_000_000; + /// /// Maximum allowed size for a photo in bytes. /// public const int MAX_PHOTO_SIZE = 27 * 1024 * 1024; + /// + /// Maximum allowed pixel length for height/width. + /// + public const int MAX_DIMENSION = 6400; + + public class Dimensions + { + public int Width { get; set; } + public int Height { get; set; } + } + + public class PhotoMetadata + { + public ImageFormat? Format { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + public int? Orientation { get; set; } + + public long? Bytes { get; set; } + + + public PhotoMetadata() { } + } + /// /// Creates a new photo with the specified payload. /// @@ -66,10 +97,37 @@ public async Task GetUploadUrl(long photoId, string md5 = "", bool useC /// /// The path to the photo file. /// The ID of the job associated with the photo. + /// The photo buffer byte array (reads photo from byte array instead of photo path) /// A dynamic object representing the uploaded photo. - public async Task UploadJobPhoto(string photoPath, long jobId) + public async Task UploadJobPhoto(string photoPath, long jobId, byte[]? photoBuffer = null) { - return await UploadPhoto(photoPath, "job", jobId); + const int maxRetries = 3; + int attempt = 0; + + while (attempt < maxRetries) + { + try + { + return await UploadPhoto(photoPath, "job", jobId, photoBuffer); + } + catch (Exception ex) + { + attempt++; + + // If we've reached the max number of retries, rethrow the exception + if (attempt == maxRetries) + { + throw new Exception($"Failed to upload photo after {maxRetries} attempts", ex); + } + + // Wait for 2 seconds before retrying + await Task.Delay(2000); + } + } + + // This will never be reached due to the return in the loop, + // but we include it to satisfy the method's return type. + return null; } /// @@ -77,10 +135,37 @@ public async Task UploadJobPhoto(string photoPath, long jobId) /// /// The path to the photo file. /// The ID of the profile associated with the photo. + /// The photo buffer byte array (reads photo from byte array instead of photo path) /// A dynamic object representing the uploaded photo. - public async Task UploadProfilePhoto(string photoPath, long profileId) + public async Task UploadProfilePhoto(string photoPath, long profileId, byte[] photoBuffer = null) { - return await UploadPhoto(photoPath, "profile", profileId); + const int maxRetries = 3; + int attempt = 0; + + while (attempt < maxRetries) + { + try + { + return await UploadPhoto(photoPath, "profile", profileId, photoBuffer); + } + catch (Exception ex) + { + attempt++; + + // If we've reached the max number of retries, rethrow the exception + if (attempt == maxRetries) + { + throw new Exception($"Failed to upload photo after {maxRetries} attempts", ex); + } + + // Wait for 2 seconds before retrying + await Task.Delay(2000); + } + } + + // This will never be reached due to the return in the loop, + // but we include it to satisfy the method's return type. + return null; } /// @@ -89,8 +174,9 @@ public async Task UploadProfilePhoto(string photoPath, long profileId) /// The path to the photo file. /// The name of the model (job or profile). /// The ID of the model associated with the photo. + /// The photo buffer byte array (reads photo from byte array instead of photo path) /// A dynamic object representing the uploaded photo. - private async Task UploadPhoto(string photoPath, string modelName, long modelId) + private async Task UploadPhoto(string photoPath, string modelName, long modelId, byte[]? photoBuffer) { string[] availableModels = { "job", "profile" }; if (!availableModels.Contains(modelName)) throw new Exception("Invalid model name. Must be 'job' or 'profile'"); @@ -102,23 +188,33 @@ private async Task UploadPhoto(string photoPath, string modelName, long throw new Exception("Photo has an invalid extension. Supported extensions (.jpg, .jpeg, .png, .webp)"); } + byte[] photoData = photoBuffer != null ? photoBuffer : File.ReadAllBytes(photoPath); + if (photoData.Length > 0 && ((photoData.Length / 1024 / 1024) > MAX_PHOTO_SIZE)) + { + throw new Exception($"{photoPath} exceeds 27MB"); + } + + PhotoMetadata photoMetadata = GetImageMetadata(photoData); + Image image = Image.NewFromBuffer(photoData); + Image modifiedImage = ResizeImage(image, photoMetadata); + var photoObject = new JObject { { "name", $"{Guid.NewGuid()}{fileExtension}" }, - { "path", photoPath } + { "path", photoPath }, + { "resized", image.Height != modifiedImage.Height } }; if (modelName == "job") photoObject["job_id"] = modelId; else photoObject["profile_id"] = modelId; dynamic photo = await CreatePhoto(photoObject); - byte[] photoData = File.ReadAllBytes(photoPath); - if (photoData.Length > 0 && ((photoData.Length / 1024 / 1024) > MAX_PHOTO_SIZE)) - { - throw new Exception($"{photoPath} exceeds 27MB"); + string format = ".jpg"; + if (photoMetadata.Format != null && photoMetadata.Format.Equals(ImageFormat.Png)) { + format = ".png"; } - + byte[] fileBytes = modifiedImage.WriteToBuffer($".{format}[Q=98]"); var md5 = MD5.Create(); - byte[] md5Hash = md5.ComputeHash(photoData); + byte[] md5Hash = md5.ComputeHash(fileBytes); string md5Base64 = Convert.ToBase64String(md5Hash); dynamic uploadObj = await GetUploadUrl(photo.id.Value, System.Net.WebUtility.UrlEncode(md5Base64)); @@ -126,7 +222,6 @@ private async Task UploadPhoto(string photoPath, string modelName, long using (RestClient httpClient = new RestClient()) { - byte[] fileBytes = File.ReadAllBytes(photoPath); RestRequest request = new RestRequest(presignedUrl, Method.Put); request.AddParameter("application/octet-stream", fileBytes, ParameterType.RequestBody); @@ -148,5 +243,105 @@ private async Task UploadPhoto(string photoPath, string modelName, long throw new Exception("An error has occurred uploading the photo."); } + + private PhotoMetadata GetImageMetadata(byte[] imageData) + { + PhotoMetadata photoMetadata = new PhotoMetadata(); + + // Load the image from the byte array + using (MemoryStream ms = new MemoryStream(imageData)) + { + photoMetadata.Bytes = ms.Length; + using (System.Drawing.Image image = System.Drawing.Image.FromStream(ms)) + { + photoMetadata.Width = image.Width; photoMetadata.Height = image.Height; + + photoMetadata.Orientation = GetImageOrientation(image); + + ImageFormat format = image.RawFormat; + photoMetadata.Format = format; + } + } + + return photoMetadata; + } + + private int GetImageOrientation(System.Drawing.Image image) + { + // The EXIF orientation tag number is 0x0112 (274 in decimal) + const int orientationId = 0x0112; + + // Check if the image has property items (EXIF data) + if (image.PropertyIdList.Contains(orientationId)) + { + // Get the orientation property + PropertyItem propItem = image.GetPropertyItem(orientationId); + + // The orientation value is stored as a short (16-bit integer) + return BitConverter.ToUInt16(propItem.Value, 0); + } + else + { + // If no orientation property exists, assume "normal" orientation (1) + return 1; + } + } + + private bool IsSizeInvalid(long? sizeInBytes, int? width, int? height) + { + return ( + sizeInBytes > 27 * 1024 * 1024 || // MB + width > 6400 || + height > 6400 || + width * height > 27000000 // >27MP + ); + } + + private Dimensions GetNormalSize(int width, int height, int? orientation) + { + return (orientation ?? 0) >= 5 + ? new Dimensions { Width = height, Height = width } + : new Dimensions { Width = width, Height = height }; + } + + private Dimensions CalculateFinalSize(int? width, int? height, int? orientation) + { + if (width == null && height == null) + { + throw new ArgumentNullException(nameof(width)); + } + + var normalSize = GetNormalSize(width ?? 0, height ?? 0, orientation); + + double ratio = (double)normalSize.Width / normalSize.Height; + int pixels = normalSize.Width * normalSize.Height; + double scale = Math.Sqrt((double)pixels / MAX_PHOTO_PIXELS); + + int finalHeight = (int)Math.Floor(normalSize.Height / scale); + int finalWidth = (int)Math.Floor((ratio * normalSize.Height) / scale); + + return new Dimensions { Width = finalWidth, Height = finalHeight }; + } + + + private Image ResizeImage(Image image, PhotoMetadata metadata) + { + if (_resizeImageIfOversized != true || metadata == null || !metadata.Bytes.HasValue || !metadata.Width.HasValue || !metadata.Height.HasValue) + { + return image; + } + + if (IsSizeInvalid(metadata.Bytes, metadata.Width, metadata.Height)) + { + // resize image to calculated final size + Dimensions finalDimensions = CalculateFinalSize(metadata.Width, metadata.Height, metadata.Orientation); + + Image finalImage = image.ThumbnailImage(finalDimensions.Width, finalDimensions.Height, noRotate: false, crop: Enums.Interesting.Centre, size: Enums.Size.Both); + + return finalImage; + } + + return image; + } } } \ No newline at end of file diff --git a/StudioClient/StudioClient.cs b/StudioClient/StudioClient.cs index 93c9f5e..c42c4ab 100644 --- a/StudioClient/StudioClient.cs +++ b/StudioClient/StudioClient.cs @@ -8,6 +8,7 @@ namespace SkylabStudio { public class StudioOptions { public int? MaxConcurrentDownloads { get; set; } + public bool? ResizeImageIfOversized { get; set; } } public partial class StudioClient @@ -15,6 +16,7 @@ public partial class StudioClient private readonly RestClient _httpClient; private readonly string _apiKey; private readonly int _maxConcurrentDownloads = 5; + private readonly bool _resizeImageIfOversized = false; /// /// Initializes a new instance of the class with the specified API key and options. @@ -29,6 +31,7 @@ public StudioClient(string? apiKey = null, StudioOptions? options = null) _httpClient = new RestClient(baseUrl); _apiKey = apiKey; _maxConcurrentDownloads = options?.MaxConcurrentDownloads ?? 5; + _resizeImageIfOversized = options?.ResizeImageIfOversized ?? false; } /// diff --git a/StudioClient/StudioClient.csproj b/StudioClient/StudioClient.csproj index f76e5e0..4d81b59 100644 --- a/StudioClient/StudioClient.csproj +++ b/StudioClient/StudioClient.csproj @@ -25,6 +25,7 @@ +