diff --git a/StudioClient/Photo.cs b/StudioClient/Photo.cs index 1823e25..3a99cae 100644 --- a/StudioClient/Photo.cs +++ b/StudioClient/Photo.cs @@ -1,358 +1,358 @@ -using System.Drawing.Imaging; -using System.Security.Cryptography; -using NetVips; -using Newtonsoft.Json.Linq; -using RestSharp; - - -namespace SkylabStudio -{ - public partial class StudioClient - { - /// - /// Array of valid file extensions for photos. - /// - 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. - /// - /// The photo payload to be sent in the request. - /// A dynamic object representing the created photo. - public async Task CreatePhoto(object payload) - { - return await Request("photos", Method.Post, payload); - } - - /// - /// Retrieves information about a specific photo based on its ID. - /// - /// The ID of the photo to retrieve. - /// A dynamic object representing the requested photo. - public async Task GetPhoto(long photoId) - { - return await Request($"photos/{photoId}", Method.Get); - } - - /// - /// Deletes a specific photo based on its ID. - /// - /// The ID of the photo to delete. - /// A dynamic object representing the result of the deletion. - public async Task DeletePhoto(long photoId) - { - return await Request($"photos/{photoId}", Method.Delete); - } - - /// - /// Retrieves a presigned URL for uploading a photo. - /// - /// The ID of the photo for which to get the upload URL. - /// The MD5 hash of the photo data. - /// Flag indicating whether to use cache upload. - /// A dynamic object representing the presigned upload URL. - public async Task GetUploadUrl(long photoId, string md5 = "", bool useCacheUpload = false) - { - string queryParams = $"use_cache_upload={useCacheUpload.ToString().ToLower()}&photo_id={photoId}&content_md5={md5}"; - - return await Request($"photos/upload_url?{queryParams}", Method.Get); - } - - /// - /// Uploads a photo associated with a job. - /// - /// 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, byte[]? photoBuffer = null) - { - 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; - } - - /// - /// Uploads a photo associated with a profile (for profiles with replace bg enabled) - /// - /// 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, byte[] photoBuffer = null) - { - 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; - } - - /// - /// Uploads a photo associated with a job or profile. - /// - /// 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, byte[]? photoBuffer) - { - string[] availableModels = { "job", "profile" }; - if (!availableModels.Contains(modelName)) throw new Exception("Invalid model name. Must be 'job' or 'profile'"); - - var photoBasename = Path.GetFileName(photoPath); - string fileExtension = Path.GetExtension(photoBasename).ToLower(); - if (!VALID_EXTENSIONS.Contains(fileExtension)) - { - 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 }, - { "resized", image.Height != modifiedImage.Height } - }; - if (modelName == "job") photoObject["job_id"] = modelId; else photoObject["profile_id"] = modelId; - - dynamic photo = await CreatePhoto(photoObject); - - 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(fileBytes); - string md5Base64 = Convert.ToBase64String(md5Hash); - - dynamic uploadObj = await GetUploadUrl(photo.id.Value, System.Net.WebUtility.UrlEncode(md5Base64)); - string presignedUrl = uploadObj.url.Value; - - using (RestClient httpClient = new RestClient()) - { - RestRequest request = new RestRequest(presignedUrl, Method.Put); - - request.AddParameter("application/octet-stream", fileBytes, ParameterType.RequestBody); - request.AddHeader("Content-MD5", Convert.ToBase64String(md5.ComputeHash(fileBytes))); - - if (modelName == "job") request.AddHeader("X-Amz-Tagging", "job=photo&api=true"); - - // Upload image via PUT request to presigned url - RestResponse response = await httpClient.ExecuteAsync(request); - - if (!response.IsSuccessStatusCode) - { - await DeletePhoto(photo.id.Value); - throw new Exception("Could not upload photo."); - } - - return photo; - } - - 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), "At least one of width or height must be provided."); - } - - var normalSize = GetNormalSize(width ?? 0, height ?? 0, orientation); - - int originalWidth = normalSize.Width; - int originalHeight = normalSize.Height; - - double ratio = (double)originalWidth / originalHeight; - - // If both dimensions are <= 6400, no resizing needed - if (originalWidth <= 6400 && originalHeight <= 6400) - { - return new Dimensions { Width = originalWidth, Height = originalHeight }; - } - - double widthScale = originalWidth > 6400 ? (double)6400 / originalWidth : 1.0; - double heightScale = originalHeight > 6400 ? (double)6400 / originalHeight : 1.0; - double scale = Math.Min(widthScale, heightScale); // pick the stricter scale - - int finalWidth = (int)Math.Floor(originalWidth * scale); - int finalHeight = (int)Math.Floor(originalHeight * 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; - } - } +using System.Drawing.Imaging; +using System.Security.Cryptography; +using NetVips; +using Newtonsoft.Json.Linq; +using RestSharp; + + +namespace SkylabStudio +{ + public partial class StudioClient + { + /// + /// Array of valid file extensions for photos. + /// + 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. + /// + /// The photo payload to be sent in the request. + /// A dynamic object representing the created photo. + public async Task CreatePhoto(object payload) + { + return await Request("photos", Method.Post, payload); + } + + /// + /// Retrieves information about a specific photo based on its ID. + /// + /// The ID of the photo to retrieve. + /// A dynamic object representing the requested photo. + public async Task GetPhoto(long photoId) + { + return await Request($"photos/{photoId}", Method.Get); + } + + /// + /// Deletes a specific photo based on its ID. + /// + /// The ID of the photo to delete. + /// A dynamic object representing the result of the deletion. + public async Task DeletePhoto(long photoId) + { + return await Request($"photos/{photoId}", Method.Delete); + } + + /// + /// Retrieves a presigned URL for uploading a photo. + /// + /// The ID of the photo for which to get the upload URL. + /// The MD5 hash of the photo data. + /// Flag indicating whether to use cache upload. + /// A dynamic object representing the presigned upload URL. + public async Task GetUploadUrl(long photoId, string md5 = "", bool useCacheUpload = false) + { + string queryParams = $"use_cache_upload={useCacheUpload.ToString().ToLower()}&photo_id={photoId}&content_md5={md5}"; + + return await Request($"photos/upload_url?{queryParams}", Method.Get); + } + + /// + /// Uploads a photo associated with a job. + /// + /// 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, byte[]? photoBuffer = null) + { + 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; + } + + /// + /// Uploads a photo associated with a profile (for profiles with replace bg enabled) + /// + /// 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, byte[] photoBuffer = null) + { + 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; + } + + /// + /// Uploads a photo associated with a job or profile. + /// + /// 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, byte[]? photoBuffer) + { + string[] availableModels = { "job", "profile" }; + if (!availableModels.Contains(modelName)) throw new Exception("Invalid model name. Must be 'job' or 'profile'"); + + var photoBasename = Path.GetFileName(photoPath); + string fileExtension = Path.GetExtension(photoBasename).ToLower(); + if (!VALID_EXTENSIONS.Contains(fileExtension)) + { + 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 > 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 }, + { "resized", image.Height != modifiedImage.Height } + }; + if (modelName == "job") photoObject["job_id"] = modelId; else photoObject["profile_id"] = modelId; + + dynamic photo = await CreatePhoto(photoObject); + + 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(fileBytes); + string md5Base64 = Convert.ToBase64String(md5Hash); + + dynamic uploadObj = await GetUploadUrl(photo.id.Value, System.Net.WebUtility.UrlEncode(md5Base64)); + string presignedUrl = uploadObj.url.Value; + + using (RestClient httpClient = new RestClient()) + { + RestRequest request = new RestRequest(presignedUrl, Method.Put); + + request.AddParameter("application/octet-stream", fileBytes, ParameterType.RequestBody); + request.AddHeader("Content-MD5", Convert.ToBase64String(md5.ComputeHash(fileBytes))); + + if (modelName == "job") request.AddHeader("X-Amz-Tagging", "job=photo&api=true"); + + // Upload image via PUT request to presigned url + RestResponse response = await httpClient.ExecuteAsync(request); + + if (!response.IsSuccessStatusCode) + { + await DeletePhoto(photo.id.Value); + throw new Exception("Could not upload photo."); + } + + return photo; + } + + 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), "At least one of width or height must be provided."); + } + + var normalSize = GetNormalSize(width ?? 0, height ?? 0, orientation); + + int originalWidth = normalSize.Width; + int originalHeight = normalSize.Height; + + double ratio = (double)originalWidth / originalHeight; + + // If both dimensions are <= 6400, no resizing needed + if (originalWidth <= 6400 && originalHeight <= 6400) + { + return new Dimensions { Width = originalWidth, Height = originalHeight }; + } + + double widthScale = originalWidth > 6400 ? (double)6400 / originalWidth : 1.0; + double heightScale = originalHeight > 6400 ? (double)6400 / originalHeight : 1.0; + double scale = Math.Min(widthScale, heightScale); // pick the stricter scale + + int finalWidth = (int)Math.Floor(originalWidth * scale); + int finalHeight = (int)Math.Floor(originalHeight * 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