├── example.png ├── Sources ├── Resources │ ├── Apple iPhone SE Black.png │ ├── Apple iPhone 14 Pro Black.png │ ├── Apple iPhone 14 Pro Max Black.png │ ├── Apple iPhone 8 Plus Space Gray.png │ ├── Apple iPad Pro (11-inch) Space Gray.png │ └── Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png └── EasyFrameCommand │ ├── Extension │ └── NSImage+PixelSize.swift │ ├── Model │ ├── Layout.swift │ ├── EasyFrameConfig.swift │ └── SupportedDevice.swift │ ├── View │ ├── FramedScreenshotView.swift │ ├── CustomBackgroundView.swift │ └── ScreenshotDesignView.swift │ ├── FileHelper.swift │ └── EasyFrameCommand.swift ├── .gitignore ├── Package.resolved ├── Package.swift ├── LICENSE ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── easy-frame.xcscheme └── README.md /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/example.png -------------------------------------------------------------------------------- /Sources/Resources/Apple iPhone SE Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/Sources/Resources/Apple iPhone SE Black.png -------------------------------------------------------------------------------- /Sources/Resources/Apple iPhone 14 Pro Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/Sources/Resources/Apple iPhone 14 Pro Black.png -------------------------------------------------------------------------------- /Sources/Resources/Apple iPhone 14 Pro Max Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/Sources/Resources/Apple iPhone 14 Pro Max Black.png -------------------------------------------------------------------------------- /Sources/Resources/Apple iPhone 8 Plus Space Gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/Sources/Resources/Apple iPhone 8 Plus Space Gray.png -------------------------------------------------------------------------------- /Sources/Resources/Apple iPad Pro (11-inch) Space Gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/Sources/Resources/Apple iPad Pro (11-inch) Space Gray.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Sources/Resources/Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alschmut/EasyFrameCommand/HEAD/Sources/Resources/Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/Extension/NSImage+PixelSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+PixelSize.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 30.01.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension NSImage { 11 | 12 | var pixelSize: CGSize { 13 | let representation = representations[0] 14 | return CGSize(width: representation.pixelsWide, height: representation.pixelsHigh) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ee29e6db266acb1b98ecddab812d754c576f4903e80ae71d02005e35eb4901b5", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser", 8 | "state" : { 9 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 10 | "version" : "1.5.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/Model/Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Layout.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 27.01.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Layout { 11 | let deviceImageName: String 12 | let deviceScreenSize: CGSize 13 | var additionalScreenSizes: [CGSize] = [] 14 | let devicePositioningOffset: CGSize 15 | let clipCornerRadius: CGFloat 16 | 17 | func relative(_ value: CGFloat) -> CGFloat { 18 | deviceScreenSize.height / 2796 * value 19 | } 20 | 21 | var allSupportedScreenSizes: [CGSize] { 22 | [deviceScreenSize] + additionalScreenSizes 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/Model/EasyFrameConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreensConfiguration.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 27.01.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EasyFrameConfig: Decodable { 11 | let pages: [PageConfig] 12 | } 13 | 14 | struct PageConfig: Decodable { 15 | let languagesConfig: [LanguageConfig] 16 | let screenshotSuffix: String 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case languagesConfig = "languages" 20 | case screenshotSuffix = "screenshot" 21 | } 22 | } 23 | 24 | struct LanguageConfig: Decodable { 25 | let locale: String 26 | let title: String 27 | let description: String 28 | } 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "easy-frame", 8 | platforms: [.macOS(.v15)], 9 | dependencies: [ 10 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), 11 | ], 12 | targets: [ 13 | .executableTarget( 14 | name: "easy-frame", 15 | dependencies: [ 16 | .product(name: "ArgumentParser", package: "swift-argument-parser") 17 | ], 18 | resources: [.process("Resources")] 19 | ) 20 | ], 21 | swiftLanguageModes: [.v5] 22 | ) 23 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/View/FramedScreenshotView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FramedScreenshotView.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 27.01.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FramedScreenshotView: View { 11 | let screenshotNSImage: NSImage 12 | let deviceNSImage: NSImage 13 | let deviceScreenSize: CGSize 14 | let clipCornerRadius: CGFloat 15 | let devicePositioningOffset: CGSize 16 | 17 | var body: some View { 18 | ZStack { 19 | Image(nsImage: screenshotNSImage) 20 | .resizable() 21 | .frame(width: deviceScreenSize.width, height: deviceScreenSize.height) 22 | .clipShape(RoundedRectangle(cornerRadius: clipCornerRadius)) 23 | 24 | Image(nsImage: deviceNSImage) 25 | .resizable() 26 | .frame(width: deviceNSImage.pixelSize.width, height: deviceNSImage.pixelSize.height) 27 | .offset(devicePositioningOffset) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alexander Schmutz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/View/CustomBackgroundView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomBackgroundView.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 02.02.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomBackgroundView: View { 11 | let pageIndex: Int 12 | 13 | var body: some View { 14 | MeshGradient(width: 3, height: 3, points: [ 15 | .init(0, 0), .init(0.5, 0), .init(1, 0), 16 | .init(0, 0.5), .init(0.5, 0.5), .init(1, 0.5), 17 | .init(0, 1), .init(0.5, 1), .init(1, 1) 18 | ], colors: colors) 19 | } 20 | 21 | let orange = Color(red: 251 / 255, green: 133 / 255, blue: 0 / 255) 22 | let blue = Color(red: 30 / 255, green: 85 / 255, blue: 125 / 255) 23 | let purple = Color.purple 24 | 25 | var colors: [Color] { 26 | if pageIndex % 3 == 0 { 27 | return [ 28 | purple, purple, blue, 29 | orange, orange, purple, 30 | orange, orange, orange 31 | ] 32 | } else if pageIndex % 3 == 1 { 33 | return [ 34 | blue, purple, purple, 35 | purple, orange, blue, 36 | orange, orange, orange 37 | ] 38 | } else { 39 | return [ 40 | purple, purple, purple, 41 | blue, orange, orange, 42 | orange, orange, orange 43 | ] 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | CustomBackgroundView(pageIndex: 0) 50 | } 51 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/Model/SupportedDevice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupportedDevice.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 27.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupportedDevice: CaseIterable { 11 | case iPhone14ProMax 12 | case iPadPro 13 | case iPhoneSE3rdGen 14 | 15 | var layout: Layout { 16 | switch self { 17 | case .iPhone14ProMax: .iPhone14ProMax 18 | case .iPadPro: .iPadPro 19 | case .iPhoneSE3rdGen: .iPhoneSE3rdGen 20 | } 21 | } 22 | 23 | static func getFirstMatchingLayout(byPixelSize pixelSize: CGSize) -> Layout? { 24 | SupportedDevice.allCases.first(where: { device in 25 | device.layout.allSupportedScreenSizes.contains(pixelSize) 26 | })?.layout 27 | } 28 | } 29 | 30 | private extension Layout { 31 | 32 | /// The iPhone 14 Pro Max frame is also used for screenshots made with an iPhone 15, 16 and 17 Pro Max 33 | /// The `FramedScreenshotView` will show a slightly downscaled version of the screenshot 34 | static let iPhone14ProMax = Self( 35 | deviceImageName: "Apple iPhone 14 Pro Max Black.png", 36 | deviceScreenSize: .iPhone14ProMax, 37 | additionalScreenSizes: [ 38 | .iPhone15ProMax, 39 | .iPhone16ProMax, 40 | .iPhone17ProMax 41 | ], 42 | devicePositioningOffset: .init(width: 0, height: 2), 43 | clipCornerRadius: 35 44 | ) 45 | 46 | static let iPadPro = Self( 47 | deviceImageName: "Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png", 48 | deviceScreenSize: .iPadPro6thGen13Inch, 49 | devicePositioningOffset: .init(width: 2, height: 0), 50 | clipCornerRadius: 35 51 | ) 52 | 53 | static let iPhoneSE3rdGen = Self( 54 | deviceImageName: "Apple iPhone SE Black.png", 55 | deviceScreenSize: .iPhoneSE3rdGen, 56 | devicePositioningOffset: .init(width: 0, height: 2), 57 | clipCornerRadius: 0 58 | ) 59 | } 60 | 61 | private extension CGSize { 62 | static let iPhone17ProMax = CGSize(width: 1320, height: 2868) 63 | static let iPhone16ProMax = CGSize(width: 1320, height: 2868) 64 | static let iPhone15ProMax = CGSize(width: 1290, height: 2796) 65 | static let iPhone14ProMax = CGSize(width: 1290, height: 2796) 66 | static let iPadPro6thGen13Inch = CGSize(width: 2048, height: 2732) 67 | static let iPhoneSE3rdGen = CGSize(width: 750, height: 1334) 68 | } 69 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/FileHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 11.09.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FileHelper { 11 | 12 | static func getFileContent(from url: URL) throws -> T { 13 | let data = try Data(contentsOf: url) 14 | return try JSONDecoder().decode(T.self, from: data) 15 | } 16 | 17 | static func saveFile(nsImage: NSImage, outputPath: String) throws { 18 | guard let jpegData = jpegDataFrom(image: nsImage) else { 19 | throw EasyFrameError.imageOperationFailure("Error: can't generate image from view") 20 | } 21 | 22 | let result = FileManager.default.createFile(atPath: outputPath, contents: jpegData) 23 | guard result else { 24 | throw EasyFrameError.fileSavingFailure("Error: can't save generated image at \(outputPath)") 25 | } 26 | } 27 | 28 | private static func jpegDataFrom(image: NSImage) -> Data? { 29 | let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)! 30 | let bitmapRep = NSBitmapImageRep(cgImage: cgImage) 31 | return bitmapRep.representation(using: .jpeg, properties: [:]) 32 | } 33 | 34 | static func getNSImage(fromDiskPath path: String) throws -> NSImage { 35 | let absolutePath = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath).path 36 | guard let deviceFrameImage = NSImage(contentsOfFile: absolutePath) else { 37 | throw EasyFrameError.fileNotFound("device frame was not found at \(path)") 38 | } 39 | return deviceFrameImage 40 | } 41 | 42 | @MainActor 43 | static func getNSImage(fromView view: Content, size: CGSize) throws -> NSImage { 44 | let renderer = ImageRenderer(content: view) 45 | renderer.proposedSize = .init(size) 46 | renderer.scale = 1.0 47 | guard let nsImage = renderer.nsImage else { 48 | throw EasyFrameError.imageOperationFailure("Error: can't generate image from view") 49 | } 50 | return nsImage 51 | } 52 | 53 | static func getBundledNSImage(fromFileName fileName: String) throws -> NSImage { 54 | let url = Bundle.module.url(forResource: fileName, withExtension: nil)! 55 | return NSImage(contentsOf: url)! 56 | } 57 | 58 | enum EasyFrameError: Error { 59 | case fileNotFound(String) 60 | case imageOperationFailure(String) 61 | case fileSavingFailure(String) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/View/ScreenshotDesignView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenshotDesignView.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 27.01.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ScreenshotDesignView: View { 11 | let layout: Layout 12 | let locale: String 13 | let pageIndex: Int 14 | let title: String 15 | let description: String 16 | let framedScreenshotNSImage: NSImage 17 | 18 | var body: some View { 19 | ZStack { 20 | CustomBackgroundView(pageIndex: pageIndex) 21 | 22 | VStack(spacing: 0) { 23 | VStack(spacing: layout.relative(37)) { 24 | let titleFontSize: CGFloat = 110 25 | Text(title) 26 | .font(.system(size: layout.relative(titleFontSize))) 27 | .lineSpacing(layout.relative(titleFontSize) / 7) 28 | .kerning(layout.relative(titleFontSize) / 30) 29 | .fontWeight(.bold) 30 | 31 | if !description.isEmpty { 32 | let descriptionFontSize: CGFloat = 75 33 | Text(description) 34 | .font(.system(size: layout.relative(descriptionFontSize))) 35 | .lineSpacing(layout.relative(descriptionFontSize) / 7) 36 | .kerning(layout.relative(descriptionFontSize) / 30) 37 | .fontWeight(.light) 38 | .padding(.horizontal, layout.relative(35)) 39 | } 40 | } 41 | .foregroundStyle(.white) 42 | .multilineTextAlignment(.center) 43 | .padding(.horizontal, layout.relative(10)) 44 | .frame(height: titleFrameHeight) 45 | 46 | Image(nsImage: framedScreenshotNSImage) 47 | .resizable() 48 | .scaledToFit() 49 | .shadow(radius: layout.relative(20)) 50 | .padding(.bottom, layout.relative(80)) 51 | .frame(height: screenshotFrameHeight) 52 | } 53 | } 54 | .environment(\.locale, Locale(identifier: locale)) 55 | } 56 | 57 | private var titleFrameHeight: CGFloat { 58 | layout.deviceScreenSize.height * 0.2 59 | } 60 | 61 | private var screenshotFrameHeight: CGFloat { 62 | layout.deviceScreenSize.height - titleFrameHeight 63 | } 64 | } 65 | 66 | #Preview { 67 | ScreenshotDesignView( 68 | layout: SupportedDevice.iPhone14ProMax.layout, 69 | locale: "en-GB", 70 | pageIndex: 0, 71 | title: "My title", 72 | description: "My description", 73 | framedScreenshotNSImage: NSImage() 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/easy-frame.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/EasyFrameCommand/EasyFrameCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EasyFrameCommand.swift 3 | // easy-frame 4 | // 5 | // Created by Alexander Schmutz on 27.01.25. 6 | // 7 | 8 | import SwiftUI 9 | import ArgumentParser 10 | 11 | @main 12 | struct EasyFrameCommand: AsyncParsableCommand { 13 | static var configuration: CommandConfiguration { 14 | CommandConfiguration(commandName: "easy-frame") 15 | } 16 | 17 | @Argument( 18 | help: "An absolute or relative path to the parent folder, which contains the EasyFrame.json file and a raw-screeshots folder with individual locale folders", 19 | completion: .file() 20 | ) 21 | var rootFolder: String 22 | 23 | @MainActor 24 | mutating func run() async throws { 25 | let rootFolderURL = URL(fileURLWithPath: rootFolder) 26 | let rawScreenshotsFolderURL = rootFolderURL.appendingPathComponent("raw-screenshots") 27 | let outputFolderURL = rootFolderURL.appendingPathComponent("screenshots") 28 | let easyFrameJsonFileURL = rawScreenshotsFolderURL.appendingPathComponent("EasyFrame.json") 29 | let easyFrameConfig: EasyFrameConfig = try FileHelper.getFileContent(from: easyFrameJsonFileURL) 30 | 31 | for (pageIndex, page) in easyFrameConfig.pages.enumerated() { 32 | for languagesConfig in page.languagesConfig { 33 | let screenshotsLocaleFolderURL = rawScreenshotsFolderURL.appendingPathComponent(languagesConfig.locale) 34 | let outputFolderURL = outputFolderURL.appendingPathComponent(languagesConfig.locale) 35 | 36 | let matchingScreenshotURLs = try FileManager.default 37 | .contentsOfDirectory(at: screenshotsLocaleFolderURL, includingPropertiesForKeys: nil, options: []) 38 | .filter { $0.lastPathComponent.contains(page.screenshotSuffix) } 39 | 40 | for screenshotURL in matchingScreenshotURLs { 41 | try createAndSaveScreenshotDesignView( 42 | pageIndex: pageIndex, 43 | languageConfig: languagesConfig, 44 | outputFolderURL: outputFolderURL, 45 | screenshotURL: screenshotURL 46 | ) 47 | } 48 | } 49 | } 50 | } 51 | 52 | @MainActor 53 | private func createAndSaveScreenshotDesignView( 54 | pageIndex: Int, 55 | languageConfig: LanguageConfig, 56 | outputFolderURL: URL, 57 | screenshotURL: URL 58 | ) throws { 59 | let screenshotNSImage = try FileHelper.getNSImage(fromDiskPath: screenshotURL.relativePath) 60 | let layout = try getDeviceLayout(pixelSize: screenshotNSImage.pixelSize) 61 | let deviceNSImage = try FileHelper.getBundledNSImage(fromFileName: layout.deviceImageName) 62 | 63 | let framedScreenshotView = FramedScreenshotView( 64 | screenshotNSImage: screenshotNSImage, 65 | deviceNSImage: deviceNSImage, 66 | deviceScreenSize: layout.deviceScreenSize, 67 | clipCornerRadius: layout.clipCornerRadius, 68 | devicePositioningOffset: layout.devicePositioningOffset 69 | ) 70 | let framedScreenshotNSImage = try FileHelper.getNSImage( 71 | fromView: framedScreenshotView, 72 | size: deviceNSImage.size 73 | ) 74 | 75 | let screenshotDesignView = ScreenshotDesignView( 76 | layout: layout, 77 | locale: languageConfig.locale, 78 | pageIndex: pageIndex, 79 | title: languageConfig.title, 80 | description: languageConfig.description, 81 | framedScreenshotNSImage: framedScreenshotNSImage 82 | ) 83 | let screenshotDesignViewNSImage = try FileHelper.getNSImage( 84 | fromView: screenshotDesignView, 85 | size: layout.deviceScreenSize 86 | ) 87 | 88 | try FileManager.default.createDirectory(at: outputFolderURL, withIntermediateDirectories: true) 89 | let outputFileName = screenshotURL 90 | .deletingPathExtension() 91 | .appendingPathExtension("jpg") 92 | .lastPathComponent 93 | let outputFileURL = outputFolderURL.appendingPathComponent(outputFileName) 94 | try FileHelper.saveFile( 95 | nsImage: screenshotDesignViewNSImage, 96 | outputPath: outputFileURL.relativePath 97 | ) 98 | } 99 | 100 | private func getDeviceLayout(pixelSize: CGSize) throws -> Layout { 101 | guard let layout = SupportedDevice.getFirstMatchingLayout(byPixelSize: pixelSize) else { 102 | throw EasyFrameError.deviceFrameNotSupported("No matching device frame found for pixelSize \(pixelSize)") 103 | } 104 | return layout 105 | } 106 | 107 | enum EasyFrameError: Error { 108 | case deviceFrameNotSupported(String) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EasyFrameCommand 2 | `easy-frame` is a lightweight Swift command-line tool to create fully customizable framed App Store screenshots using SwiftUI. Built for automation, this open-source Swift package efficiently frames screenshots for all localizations and devices. While it follows a workflow similar to fastlane’s frameit, you are in full controll. Simply fork the reporsitory with just about 400 lines of swift code and get as creative as you desire. 3 | 4 | ![Framed Example Screenshots](example.png) 5 | 6 | ## Who is this tool for? 7 | This Swift package is ideal for indie Swift developers who value: 8 | - **Automation** over manual screenshot creation 9 | - **Full design control** over relying on premade templates with limited customization options 10 | - **A free and open-source solution** over paid alternatives 11 | 12 | ## Getting started 13 | To generate screenshots like in the example above - using the implementation as-is, just follow the below steps: 14 | 1. Set up the directory structure as shown in the example below. Place your unframed raw screenshots in the `raw-screenshots` directory, organized by locale. 15 | ``` 16 | parent-folder/ 17 | raw-screenshots/ 18 | EasyFrame.json 19 | de-DE/ 20 | iPhone 15 Pro Max-1-calendar.png 21 | iPhone 15 Pro Max-2-list.png 22 | iPhone 15 Pro Max-3-add-entry.png 23 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png 24 | iPad Pro (12.9-inch) (6th generation)-2-list.png 25 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png 26 | en-GB/ 27 | iPhone 15 Pro Max-1-calendar.png 28 | iPhone 15 Pro Max-2-list.png 29 | iPhone 15 Pro Max-3-add-entry.png 30 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png 31 | iPad Pro (12.9-inch) (6th generation)-2-list.png 32 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png 33 | ``` 34 | - If you use Fastlane to generate unframed screenshots, its output directory structure already meets `easy-frame`'s requirements. Just ensure that the `output_directory` is set to `raw-screenshots`. 35 | ```ruby 36 | capture_ios_screenshots( 37 | devices: ["iPhone 15 Pro Max", "iPad Pro (12.9-inch) (6th generation)"], 38 | languages: ["de-DE", "en-GB"], 39 | scheme: ENV["XCODE_SCHEME_UI_TEST"], 40 | output_directory: "./fastlane/raw-screenshots" 41 | ) 42 | ``` 43 | 1. Create an `EasyFrame.json` configuration file based on the below [Example EasyFrame.json](#example-easyframejson). The config defines each App Store page with: 44 | - localized texts, which will be displayed on the framed screenshot 45 | - a screenshot name, which must partially match the raw screenshot filenames 46 | 1. Run the local `easy-frame` swift package command to generate framed screenshots into the `parent-folder/screenshots/` directory: 47 | ```sh 48 | cd path/to/EasyFrameCommandProject 49 | swift run easy-frame path/to/parent-folder 50 | ``` 51 | 52 | ## Which devices are supported? 53 | See [Sources/EasyFrameCommand/Model/SupportedDevice.swift](Sources/EasyFrameCommand/Model/SupportedDevice.swift). The device frames have been taken from https://github.com/fastlane/frameit-frames. 54 | 55 | ## Where to adjust the SwiftUI layout? 56 | See [Sources/EasyFrameCommand/View/ScreenshotDesignView.swift](Sources/EasyFrameCommand/View/ScreenshotDesignView.swift) 57 | 58 | ## Example directory structure 59 | ``` 60 | parent-folder/ 61 | raw-screenshots/ (the locale folders contains the unframed raw screenshots) 62 | EasyFrame.json 63 | de-DE/ 64 | iPhone 15 Pro Max-1-calendar.png 65 | iPhone 15 Pro Max-2-list.png 66 | iPhone 15 Pro Max-3-add-entry.png 67 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png 68 | iPad Pro (12.9-inch) (6th generation)-2-list.png 69 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png 70 | en-GB/ 71 | iPhone 15 Pro Max-1-calendar.png 72 | iPhone 15 Pro Max-2-list.png 73 | iPhone 15 Pro Max-3-add-entry.png 74 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png 75 | iPad Pro (12.9-inch) (6th generation)-2-list.png 76 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png 77 | screenshots/ (this folder will be generated by easy-frame with the framed screenshots) 78 | de-DE/ 79 | iPhone 15 Pro Max-1-calendar.jpg 80 | iPhone 15 Pro Max-2-list.jpg 81 | iPhone 15 Pro Max-3-add-entry.jpg 82 | iPad Pro (12.9-inch) (6th generation)-1-calendar.jpg 83 | iPad Pro (12.9-inch) (6th generation)-2-list.jpg 84 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.jpg 85 | en-GB/ 86 | iPhone 15 Pro Max-1-calendar.jpg 87 | iPhone 15 Pro Max-2-list.jpg 88 | iPhone 15 Pro Max-3-add-entry.jpg 89 | iPad Pro (12.9-inch) (6th generation)-1-calendar.jpg 90 | iPad Pro (12.9-inch) (6th generation)-2-list.jpg 91 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.jpg 92 | ``` 93 | 94 | ## Example EasyFrame.json 95 | ```json 96 | { 97 | "pages": [ 98 | { 99 | "languages": [ 100 | { "locale": "en-GB", "title": "Title 1 EN", "description": "Description" }, 101 | { "locale": "de-DE", "title": "Title 1 DE", "description": "Description" } 102 | ], 103 | "screenshot": "1-calendar" 104 | }, 105 | { 106 | "languages": [ 107 | { "locale": "en-GB", "title": "Title 2 EN", "description": "Description" }, 108 | { "locale": "de-DE", "title": "Title 2 DE", "description": "Description" } 109 | ], 110 | "screenshot": "2-list" 111 | }, 112 | { 113 | "languages": [ 114 | { "locale": "en-GB", "title": "Title 3 EN", "description": "Description" }, 115 | { "locale": "de-DE", "title": "Title 3 DE", "description": "Description" } 116 | ], 117 | "screenshot": "3-add-entry" 118 | } 119 | ] 120 | } 121 | ``` 122 | 123 | ## Acknowledgments 124 | Thanks to [FrameKit](https://github.com/ainame/FrameKit) and fastlane [frameit](https://docs.fastlane.tools/actions/frameit/), which both inspired me and made this swift package possible. 125 | 126 | ## Roadmap 127 | There is no planned roadmap or a guarantee of ongoing maintenance. This Swift package is intended to be forked and serves as a solid starting point for further customization 😉 128 | --------------------------------------------------------------------------------