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 @@
+