|
// Variables used by Scriptable. |
|
// These must be at the very top of the file. Do not edit. |
|
// icon-color: yellow; icon-glyph: magic; share-sheet-inputs: image; |
|
/** |
|
* @name FrameYourPhotos |
|
* @description Make artsy posts from your photos for social media. |
|
* @author Till Sanders <[email protected]> |
|
* @version 1.0.0 (20.08.2023) |
|
*/ |
|
|
|
/** |
|
* CUSTOMIZE HERE! |
|
* -> In this section, you can pre-define variables. If you do so, the script |
|
* will stop asking you about them, so this is a great way to speed up your |
|
* process. To change the variables, simply uncomment the example lines |
|
* below (remove the slashes '//') and set the desired values. Note that |
|
* some values are numbers (no quotes) and some are strings (with quotes). |
|
*/ |
|
|
|
// -> Dimensions [px] |
|
// Set both width and height to a number in pixel |
|
let width = undefined |
|
let height = undefined |
|
// width = 1080 // default, Instagram Post Format |
|
// height = 1080 // default, Instagram Post Format |
|
// width = 1080 // Instagram Story Format |
|
// height = 1920 // Instagram Story Format |
|
|
|
|
|
// -> Margin [px] |
|
// The space around your photo on the X (horizontal) and Y (vertical) |
|
// achsis |
|
let marginX = undefined |
|
let marginY = undefined |
|
// marginX = 40 // default, suited for Instagram Post Format |
|
// marginY = 40 // default, suited for Instagram Post Format |
|
// marginX = 40 // suited for Instagram Story Format |
|
// marginY = 250 // suited for Instagram Story Format |
|
|
|
// -> Resolution |
|
// It is a good idea to double all pixel values above so you get a higher |
|
// resolution image. This is just a factor, so 2 would mean two times the |
|
// size. |
|
let resolution = undefined |
|
// resolution = 2 // default |
|
|
|
// -> Background Color |
|
// Set to any RGB color (hex notation) |
|
let background = undefined |
|
// background = new Color('FFFFFF') // default, white |
|
// background = new Color('000000') // black |
|
// background = new Color('222222') // dark gray |
|
|
|
// -> Crop |
|
// Your images can be cropped (to center) or just scaled with their |
|
// aspect-ratio preserved. Note that for multiple images in a collage it |
|
// is expected that they all have the same aspect-ratio if they are not |
|
// cropped. |
|
let croppingAllowed = undefined |
|
// cropingAllowed = false // default, no cropping, aspect-ratio is preserved |
|
// cropingAllowed = true // will crop the shit out of it |
|
|
|
// -> Collage |
|
// You can process multiple images at once simply by selecting and passing |
|
// them to this script via the share sheet. The can then be either batch |
|
// processed or they can be added to the same image as a collage. |
|
let preferCollage = undefined |
|
// let preferCollage = true // will create a collage up until 5 pictures |
|
// let preferCollage = false // will always process as a batch instead |
|
|
|
// -> Orientation |
|
// In a collage, your photos will be rendered side by side or above one |
|
// another. You can select what you like better. |
|
let orientation = undefined |
|
// orientation = 'x' // default, x-achsis / side by side |
|
// orientation = 'y' // y-achsis / above one another |
|
|
|
// -> Gap [px] |
|
// The space between photos in a collage. |
|
let gap = undefined |
|
// gap = 40 // default |
|
|
|
/** |
|
* ============================================================================ |
|
* ============================================================================ |
|
* ============================================================================ |
|
* Here be dragons! |
|
* |
|
* -> This is where the code starts. Only change things here if you know what |
|
* you're doing ;) |
|
* ============================================================================ |
|
*/ |
|
|
|
// Get arguments / images |
|
if ( |
|
args.images === undefined || |
|
!Array.isArray(args.images) || |
|
args.images.length < 1 |
|
) { |
|
const alert = new Alert() |
|
alert.message = `Please trigger this script from an image's share sheet! You |
|
can select a single or multiple images.` |
|
alert.present() |
|
return |
|
} |
|
|
|
/** |
|
* ============================================================================ |
|
* Utilities |
|
*/ |
|
|
|
/** |
|
* Utility: Crop |
|
* Creates a new drawing context with the given dimensions where the image is |
|
* being cropped to cover the entire area. |
|
*/ |
|
function crop (image, toWidth, toHeight, background) { |
|
let context = new DrawContext() |
|
context.size = new Size(toWidth, toHeight) |
|
context.opaque = false |
|
context.setFillColor(background) |
|
context.fillRect(new Rect(0, 0, toWidth, toHeight)) |
|
|
|
const originalHeight = image.size.height |
|
const originalWidth = image.size.width |
|
let scale, fittedHeight, fittedWidth |
|
if (originalWidth / originalHeight < toWidth / toHeight) { |
|
// Portrait |
|
scale = originalWidth / toWidth |
|
fittedHeight = originalHeight / scale |
|
fittedWidth = toWidth |
|
} else { |
|
// Landscape or Square |
|
scale = originalHeight / toHeight |
|
fittedHeight = toHeight |
|
fittedWidth = originalWidth / scale |
|
} |
|
|
|
context.drawImageInRect(image, |
|
new Rect( |
|
(toWidth - originalWidth / scale) / 2, |
|
(toHeight - originalHeight / scale) / 2, |
|
fittedWidth, |
|
fittedHeight, |
|
) |
|
) |
|
|
|
// Result |
|
return context.getImage() |
|
} |
|
|
|
/** |
|
* Utility: Contain |
|
* Creates a new drawing context with the given dimensions where the image is |
|
* contained without being cropped. |
|
*/ |
|
|
|
function contain (image, toWidth, toHeight, background) { |
|
let context = new DrawContext() |
|
context.size = new Size(toWidth, toHeight) |
|
context.opaque = false |
|
context.setFillColor(background) |
|
context.fillRect(new Rect(0, 0, toWidth, toHeight)) |
|
|
|
// Scale Image |
|
const originalHeight = image.size.height |
|
const originalWidth = image.size.width |
|
let fittedHeight, fittedWidth, scale |
|
|
|
// Fit width |
|
fittedWidth = toWidth |
|
scale = originalWidth / fittedWidth |
|
fittedHeight = originalHeight / scale |
|
|
|
// Fit height |
|
if (fittedHeight > toHeight) { |
|
fittedHeight = toHeight |
|
scale = originalHeight / fittedHeight |
|
fittedWidth = originalWidth / scale |
|
} |
|
|
|
// Position Image |
|
const offsetLeft = (toWidth - fittedWidth) / 2 |
|
const offsetTop = (toHeight - fittedHeight) / 2 |
|
context.drawImageInRect(image, new Rect(offsetLeft, offsetTop, fittedWidth, fittedHeight)) |
|
|
|
// Result |
|
return context.getImage() |
|
} |
|
|
|
/** |
|
* Utility: Error Notification |
|
*/ |
|
function abort (message) { |
|
const alert = new Alert() |
|
alert.message = message |
|
alert.present() |
|
} |
|
|
|
/** |
|
* ============================================================================ |
|
* Parameters |
|
*/ |
|
|
|
// Get width |
|
if (width !== undefined && (typeof width !== 'number' || width <= 0)) { |
|
abort('Error: If you pre-define the width parameter, make sure to provide a positive number and not a string.') |
|
} |
|
if (width === undefined) { |
|
width = 1080 |
|
const getWidthAlert = new Alert() |
|
getWidthAlert.message = "Please provide the desired width in pixel. Default: 1080px" |
|
const widthField = getWidthAlert.addTextField('Width in px', width.toString()) |
|
getWidthAlert.addAction('Okay') // index: 0 |
|
getWidthAlert.addCancelAction('Cancel') |
|
const getWidth = await getWidthAlert.presentAlert() |
|
if (getWidth !== 0) { |
|
return |
|
} |
|
width = parseInt(getWidthAlert.textFieldValue(widthField), 10) |
|
} |
|
|
|
// Get height |
|
if (height !== undefined && (typeof height !== 'number' || height <= 0)) { |
|
abort('Error: If you pre-define the height parameter, make sure to provide a positive number and not a string.') |
|
} |
|
if (height === undefined) { |
|
height = 1080 |
|
const getHeightAlert = new Alert() |
|
getHeightAlert.message = "Please provide the desired height in pixel. Default: 1080px" |
|
const heightField = getHeightAlert.addTextField('Height in px', height.toString()) |
|
getHeightAlert.addAction('Okay') // index: 0 |
|
getHeightAlert.addCancelAction('Cancel') |
|
const getHeight = await getHeightAlert.presentAlert() |
|
if (getHeight !== 0) { |
|
return |
|
} |
|
height = parseInt(getHeightAlert.textFieldValue(heightField), 10) |
|
} |
|
|
|
// Get marginX |
|
if (marginX !== undefined && (typeof marginX !== 'number' || marginX <= 0)) { |
|
abort('Error: If you pre-define the marginX parameter, make sure to provide a positive number and not a string.') |
|
} |
|
if (marginX === undefined) { |
|
marginX = 40 |
|
const getMarginXAlert = new Alert() |
|
getMarginXAlert.message = "Please provide the desired margin (x-achsis) in pixel. Default: 40px" |
|
const marginXField = getMarginXAlert.addTextField('Margin in px', marginX.toString()) |
|
getMarginXAlert.addAction('Okay') // index: 0 |
|
getMarginXAlert.addCancelAction('Cancel') |
|
const getMarginX = await getMarginXAlert.presentAlert() |
|
if (getMarginX !== 0) { |
|
return |
|
} |
|
marginX = parseInt(getMarginXAlert.textFieldValue(marginXField), 10) |
|
} |
|
|
|
// Get marginY |
|
if (marginY !== undefined && (typeof marginY !== 'number' || marginY <= 0)) { |
|
abort('Error: If you pre-define the marginY parameter, make sure to provide a positive number and not a string.') |
|
} |
|
if (marginY === undefined) { |
|
marginY = 40 |
|
const getMarginYAlert = new Alert() |
|
getMarginYAlert.message = "Please provide the desired margin (y-achsis) in pixel. Default: 40px" |
|
const marginYField = getMarginYAlert.addTextField('Margin in px', marginY.toString()) |
|
getMarginYAlert.addAction('Okay') // index: 0 |
|
getMarginYAlert.addCancelAction('Cancel') |
|
const getMarginY = await getMarginYAlert.presentAlert() |
|
if (getMarginY !== 0) { |
|
return |
|
} |
|
marginY = parseInt(getMarginYAlert.textFieldValue(marginYField), 10) |
|
} |
|
|
|
// Get resolution |
|
if (resolution !== undefined && (typeof resolution !== 'number' || resolution <= 0)) { |
|
abort('Error: If you pre-define the resolution parameter, make sure to provide a positive number and not a string.') |
|
} |
|
if (resolution === undefined) { |
|
resolution = 2 |
|
const getResolutionAlert = new Alert() |
|
getResolutionAlert.message = "Please provide the desired resolution factor. Default: 2" |
|
const resolutionField = getResolutionAlert.addTextField('Resolution', resolution.toString()) |
|
getResolutionAlert.addAction('Okay') // index: 0 |
|
getResolutionAlert.addCancelAction('Cancel') |
|
const getResolution = await getResolutionAlert.presentAlert() |
|
if (getResolution !== 0) { |
|
return |
|
} |
|
resolution = parseInt(getResolutionAlert.textFieldValue(resolutionField), 10) |
|
} |
|
|
|
// Get background |
|
if (background === undefined) { |
|
background = Color.white() |
|
const getBackgroundAlert = new Alert() |
|
getBackgroundAlert.message = "Please choose a background color or provide your own:" |
|
const backgroundField = getBackgroundAlert.addTextField('#FFFFFF', background.hex) |
|
getBackgroundAlert.addAction('Custom') // index: 0 |
|
getBackgroundAlert.addAction('White') // index: 1 |
|
getBackgroundAlert.addAction('Gray') // index: 2 |
|
getBackgroundAlert.addAction('Black') // index: 3 |
|
getBackgroundAlert.addCancelAction('Cancel') |
|
const getBackground = await getBackgroundAlert.presentAlert() |
|
switch (getBackground) { |
|
case 0: |
|
background = new Color(getBackgroundAlert.textFieldValue(0), 1) |
|
break |
|
case 1: |
|
break |
|
case 2: |
|
background = new Color('222222', 1) |
|
break |
|
case 3: |
|
background = Color.black() |
|
break |
|
case 4: |
|
default: |
|
return |
|
} |
|
} |
|
|
|
// Get croppingAllowed |
|
if (croppingAllowed !== undefined && typeof croppingAllowed !== 'boolean') { |
|
abort('Error: If you pre-define the croppingAllowed parameter, make sure to set it to either true or false, no quotation marks.') |
|
} |
|
if (croppingAllowed === undefined) { |
|
croppingAllowed = false |
|
const getCroppingAlert = new Alert() |
|
getCroppingAlert.message = "Do you want allow cropping?" |
|
getCroppingAlert.addAction('No') // index: 0 |
|
getCroppingAlert.addAction('Yes') // index: 1 |
|
getCroppingAlert.addCancelAction('Cancel') |
|
const getCropping = await getCroppingAlert.presentAlert() |
|
switch (getCropping) { |
|
case 0: |
|
croppingAllowed = false |
|
break |
|
case 1: |
|
croppingAllowed = true |
|
break |
|
default: |
|
return |
|
} |
|
} |
|
|
|
// Get preferCollage |
|
if (preferCollage !== undefined && typeof preferCollage !== 'boolean') { |
|
abort('Error: If you pre-define the preferCollage parameter, make sure to set it to either true or false, no quotation marks.') |
|
} |
|
if (args.images.length > 1 && preferCollage === undefined) { |
|
preferCollage = false |
|
const getCollageAlert = new Alert() |
|
getCollageAlert.message = "Do you want to combine the selected photos into a collage or process them as a batch?" |
|
getCollageAlert.addAction('Combine into collage') // index: 0 |
|
getCollageAlert.addAction('Process as batch') // index: 1 |
|
getCollageAlert.addCancelAction('Cancel') |
|
const getCollage = await getCollageAlert.presentAlert() |
|
switch (getCollage) { |
|
case 0: |
|
preferCollage = true |
|
break |
|
case 1: |
|
preferCollage = false |
|
break |
|
default: |
|
return |
|
} |
|
} |
|
|
|
// Get orientation |
|
if (orientation !== undefined && (orientation !== 'x' || orientation !== 'y')) { |
|
abort('Error: If you pre-define the orientation parameter, make sure to set it to either "x" or "y".') |
|
} |
|
if (args.images.length > 1 && preferCollage && orientation === undefined) { |
|
orientation = 'x' |
|
const getOrientationAlert = new Alert() |
|
getOrientationAlert.message = "Do you want to arrange the photos side by side or below one another?" |
|
getOrientationAlert.addAction('Side by side') // index: 0 |
|
getOrientationAlert.addAction('Above and below') // index: 1 |
|
getOrientationAlert.addCancelAction('Cancel') |
|
const getCollage = await getOrientationAlert.presentAlert() |
|
switch (getCollage) { |
|
case 0: |
|
orientation = 'x' |
|
break |
|
case 1: |
|
orientation = 'y' |
|
break |
|
default: |
|
return |
|
} |
|
} |
|
|
|
// Get gap |
|
if (gap !== undefined && (typeof gap !== 'number' || gap <= 0)) { |
|
abort('Error: If you pre-define the gap parameter, make sure to provide a positive number and not a string.') |
|
} |
|
if (args.images.length > 1 && preferCollage && gap === undefined) { |
|
gap = 40 |
|
const getGapAlert = new Alert() |
|
getGapAlert.message = "Please provide the desired gap between photos in a collage in pixel. Default: 40px" |
|
const gapField = getGapAlert.addTextField('Gap in px', gap.toString()) |
|
getGapAlert.addAction('Okay') // index: 0 |
|
getGapAlert.addCancelAction('Cancel') |
|
const getGap = await getGapAlert.presentAlert() |
|
if (getGap !== 0) { |
|
return |
|
} |
|
gap = parseInt(getGapAlert.textFieldValue(gapField), 10) |
|
} |
|
|
|
/** |
|
* ============================================================================ |
|
* Output |
|
*/ |
|
|
|
// Apply resolution |
|
height = height * resolution |
|
width = width * resolution |
|
marginX = marginX * resolution |
|
marginY = marginY * resolution |
|
gap = gap * resolution |
|
|
|
function generateSingle (image, width, height, marginX, marginY, background, croppingAllowed) { |
|
// Create background |
|
let context = new DrawContext() |
|
context.size = new Size(width, height) |
|
context.opaque = false |
|
context.setFillColor(background) |
|
context.fillRect(new Rect(0, 0, width, height)) |
|
|
|
// Scale Image |
|
let scaledImage |
|
if (croppingAllowed) { |
|
scaledImage = crop(image, width - marginX * 2, height - marginY * 2, background) |
|
} else { |
|
scaledImage = contain(image, width - marginX * 2, height - marginY * 2, background) |
|
} |
|
|
|
// Position Image |
|
const offsetLeft = (width - scaledImage.size.width) / 2 |
|
const offsetTop = (height - scaledImage.size.height) / 2 |
|
context.drawImageInRect(scaledImage, new Rect(offsetLeft, offsetTop, scaledImage.size.width, scaledImage.size.height)) |
|
|
|
// Result |
|
return context.getImage() |
|
} |
|
|
|
function generateCollageX (images, width, height, marginX, marginY, background, croppingAllowed) { |
|
// Create background |
|
let context = new DrawContext() |
|
context.size = new Size(width, height) |
|
context.opaque = false |
|
context.setFillColor(background) |
|
context.fillRect(new Rect(0, 0, width, height)) |
|
|
|
const count = images.length |
|
const imageWidth = (width - (marginX * 2) - ((count - 1) * gap)) / count |
|
const imageHeight = height - marginY * 2 |
|
images.forEach((image, index) => { |
|
|
|
// Scale Image |
|
let scaledImage |
|
if (croppingAllowed) { |
|
scaledImage = crop(image, imageWidth, imageHeight, background) |
|
} else { |
|
scaledImage = contain(image, imageWidth, imageHeight, background) |
|
} |
|
|
|
// Position Image |
|
const offsetLeft = marginX + (imageWidth + gap) * index |
|
const offsetTop = (height - scaledImage.size.height) / 2 |
|
context.drawImageInRect( |
|
scaledImage, |
|
new Rect( |
|
offsetLeft, |
|
offsetTop, |
|
scaledImage.size.width, |
|
scaledImage.size.height, |
|
) |
|
) |
|
}) |
|
|
|
// Result |
|
return context.getImage() |
|
} |
|
|
|
function generateCollageY (images, width, height, marginX, marginY, background, croppingAllowed) { |
|
// Create background |
|
let context = new DrawContext() |
|
context.size = new Size(width, height) |
|
context.opaque = false |
|
context.setFillColor(background) |
|
context.fillRect(new Rect(0, 0, width, height)) |
|
|
|
const count = images.length |
|
const imageWidth = width - marginX * 2 |
|
const imageHeight = (height - (marginY * 2) - ((count - 1) * gap)) / count |
|
images.forEach((image, index) => { |
|
|
|
// Scale Image |
|
let scaledImage |
|
if (croppingAllowed) { |
|
scaledImage = crop(image, imageWidth, imageHeight, background) |
|
} else { |
|
scaledImage = contain(image, imageWidth, imageHeight, background) |
|
} |
|
|
|
// Position Image |
|
const offsetLeft = (width - scaledImage.size.width) / 2 |
|
const offsetTop = marginY + (imageHeight + gap) * index |
|
context.drawImageInRect( |
|
scaledImage, |
|
new Rect( |
|
offsetLeft, |
|
offsetTop, |
|
scaledImage.size.width, |
|
scaledImage.size.height, |
|
) |
|
) |
|
}) |
|
|
|
// Result |
|
return context.getImage() |
|
} |
|
|
|
// Generate and Share |
|
if (preferCollage === true && orientation === 'x' && args.images.length > 1) { |
|
// Quick feedback |
|
const notification = new Notification() |
|
notification.title = "Generating collage (x-achsis)" |
|
notification.subtitle = "This should only take a moment..." |
|
await notification.schedule(Date.now()) |
|
|
|
// Generate x-collage and share |
|
const result = generateCollageX(args.images, width, height, marginX, marginY, background, croppingAllowed) |
|
notification.remove() |
|
QuickLook.present(result, true) |
|
} else if (preferCollage === true && orientation === 'y' && args.images.length > 1) { |
|
// Quick feedback |
|
const notification = new Notification() |
|
notification.title = "Generating collage (y-achsis)" |
|
notification.subtitle = "This should only take a moment..." |
|
await notification.schedule(Date.now()) |
|
|
|
// Generate y-collage and share |
|
const result = generateCollageY(args.images, width, height, marginX, marginY, background, croppingAllowed) |
|
notification.remove() |
|
QuickLook.present(result, true) |
|
} else { |
|
// Quick feedback |
|
const notification = new Notification() |
|
notification.title = "Generating " + args.images.length + (args.images.length > 1 ? " images" : " image") |
|
notification.subtitle = "This should only take a moment..." |
|
await notification.schedule(Date.now()) |
|
|
|
// Generate and share |
|
const results = args.images.map((image) => generateSingle(image, width, height, marginX, marginY, background, croppingAllowed)) |
|
|
|
notification.remove() |
|
|
|
if (results.length > 1) { |
|
ShareSheet.present(results) |
|
return |
|
} |
|
|
|
QuickLook.present(results[0], true) |
|
} |