Skip to content

Commit

Permalink
Merge pull request #13 from skylab-tech/SCD-6-resizing
Browse files Browse the repository at this point in the history
Add oversized image resizing
  • Loading branch information
kev-le authored Oct 7, 2024
2 parents a27481c + 5b5ed34 commit 7c995ee
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 23 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```

Expand Down
4 changes: 3 additions & 1 deletion StudioClient.Example/StudioClient.Example.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>

</Project>
29 changes: 23 additions & 6 deletions StudioClient.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
221 changes: 208 additions & 13 deletions StudioClient/Photo.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Drawing.Imaging;
using System.Security.Cryptography;
using NetVips;
using Newtonsoft.Json.Linq;
using RestSharp;

Expand All @@ -12,11 +14,40 @@ public partial class StudioClient
/// </summary>
public static readonly string[] VALID_EXTENSIONS = { ".png", ".jpg", ".jpeg", ".webp" };

/// <summary>
/// Maximum allowed pixels for a photo.
/// </summary>
public const int MAX_PHOTO_PIXELS = 27_000_000;

/// <summary>
/// Maximum allowed size for a photo in bytes.
/// </summary>
public const int MAX_PHOTO_SIZE = 27 * 1024 * 1024;

/// <summary>
/// Maximum allowed pixel length for height/width.
/// </summary>
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() { }
}

/// <summary>
/// Creates a new photo with the specified payload.
/// </summary>
Expand Down Expand Up @@ -66,21 +97,75 @@ public async Task<dynamic> GetUploadUrl(long photoId, string md5 = "", bool useC
/// </summary>
/// <param name="photoPath">The path to the photo file.</param>
/// <param name="jobId">The ID of the job associated with the photo.</param>
/// <param name="photoBuffer">The photo buffer byte array (reads photo from byte array instead of photo path)</param>
/// <returns>A dynamic object representing the uploaded photo.</returns>
public async Task<dynamic> UploadJobPhoto(string photoPath, long jobId)
public async Task<dynamic?> 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;
}

/// <summary>
/// Uploads a photo associated with a profile (for profiles with replace bg enabled)
/// </summary>
/// <param name="photoPath">The path to the photo file.</param>
/// <param name="profileId">The ID of the profile associated with the photo.</param>
/// <param name="photoBuffer">The photo buffer byte array (reads photo from byte array instead of photo path)</param>
/// <returns>A dynamic object representing the uploaded photo.</returns>
public async Task<dynamic> UploadProfilePhoto(string photoPath, long profileId)
public async Task<dynamic?> UploadProfilePhoto(string photoPath, long profileId, byte[] photoBuffer = null)

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / test

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / test

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.

Check warning on line 140 in StudioClient/Photo.cs

View workflow job for this annotation

GitHub Actions / release

Cannot convert null literal to non-nullable reference type.
{
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;
}

/// <summary>
Expand All @@ -89,8 +174,9 @@ public async Task<dynamic> UploadProfilePhoto(string photoPath, long profileId)
/// <param name="photoPath">The path to the photo file.</param>
/// <param name="modelName">The name of the model (job or profile).</param>
/// <param name="modelId">The ID of the model associated with the photo.</param>
/// <param name="photoBuffer">The photo buffer byte array (reads photo from byte array instead of photo path)</param>
/// <returns>A dynamic object representing the uploaded photo.</returns>
private async Task<dynamic> UploadPhoto(string photoPath, string modelName, long modelId)
private async Task<dynamic> 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'");
Expand All @@ -102,31 +188,40 @@ private async Task<dynamic> 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));
string presignedUrl = uploadObj.url.Value;

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);
Expand All @@ -148,5 +243,105 @@ private async Task<dynamic> 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;
}
}
}
Loading

0 comments on commit 7c995ee

Please sign in to comment.