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