Skip to content

Instantly share code, notes, and snippets.

@tillsanders
Last active August 20, 2023 17:14
Show Gist options
  • Save tillsanders/a926d9cf5ec44b76934ff22a68479f39 to your computer and use it in GitHub Desktop.
Save tillsanders/a926d9cf5ec44b76934ff22a68479f39 to your computer and use it in GitHub Desktop.
Scriptable: FrameYourPhotos.js

FrameYourPhotos

This is a Scriptable script.

Generates frames for your photos to avoid cropping the carefully chosen composition of your photos. This is especially useful for Instagram where square posts still look best on your profile.

Simply select any number of photos from your library and use the Share Sheet to pass them to the FrameYourPhotos script. You predefine any settings in the script itself, or the script will ask for your input. You can adjust height, width and resolution of the post, margin and background color to your liking. You can ask the script to either contain or crop your images.

When passing multiple images to the script, you can choose to process them in batch or combine them to a collage where the images appear either side by side or above and below on the same post.

After processing, the images will be presented to you and you can use the Share Sheet to save to a location of your choosing. This will greatly speed up posting your photos on social media.

// 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 <mail@till-sanders.de>
* @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)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment