import Cocoa import ZIPFoundation import AVFoundation @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations { var exporter: AVAssetExportSession? @IBAction func showInfoAction(_ sender: Any) { NSApplication.shared.keyWindow?.contentViewController?.performSegue(withIdentifier: "showInfo", sender: self) } @IBAction func showTimelapseAction(_ sender: Any) { NSApplication.shared.keyWindow?.contentViewController?.performSegue(withIdentifier: "showTimelapse", sender: self) } func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { // Show timelapse and show info buttons if(item.tag == 67 || item.tag == 68 || item.tag == 100 || item.tag == 101 || item.tag == 102) { return NSApplication.shared.keyWindow != nil } return true } func addAccessoryView(_ savePanel : NSSavePanel, fileTypes : Array) { let accessoryView = (ExportAccessoryView.fromNib()! as ExportAccessoryView) accessoryView.setSavePanel(savePanel) accessoryView.setPossibleOptions(fileTypes) savePanel.accessoryView = accessoryView as NSView } func getBitmapType(_ ext : String) -> NSBitmapImageRep.FileType { switch(ext) { case "tiff": return .tiff case "bmp": return .bmp case "jpeg": return .jpeg case "png": return .png default: return .png } } func getPSDBlendMode(_ layer : SilicaLayer) -> PSDBlendModes { switch(layer.data.blendMode) { case .Normal: return kPSDBlendModeNormal case .Multiply: return kPSDBlendModeMultiply case .Screen: return kPSDBlendModeScreen case .Add: return kPSDBlendModeAdd case .Lighten: return kPSDBlendModeLighten case .Exclusion: return kPSDBlendModeExclusion case .Difference: return kPSDBlendModeDifference case .Subtract: return kPSDBlendModeSubtract case .LinearBurn: return kPSDBlendModeLinearBurn case .ColorDodge: return kPSDBlendModeColorDodge case .ColorBurn: return kPSDBlendModeColorBurn case .Overlay: return kPSDBlendModeOverlay case .HardLight: return kPSDBlendModeHardLight case .Color: return kPSDBlendModeColor case .Luminosity: return kPSDBlendModeLuminosity case .Hue: return kPSDBlendModeHue case .Saturation: return kPSDBlendModeSaturation case .SoftLight: return kPSDBlendSoftLight case .Darken: return kPSDBlendModeDarken case .HardMix: return kPSDBlendModeHardMix case .VividLight: return kPSDBlendModeVividLight case .LinearLight: return kPSdBlendModeLinearLight case .PinLight: return kPSDBlendModePinLight case .LighterColor: return kPSDBlendModeLighterColor case .DarkerColor: return kPSDBlendModeDarkerColor case .Divide: return kPSDBlendModeDivide } } @IBAction func exportAction(_ sender: Any) { let document = NSApplication.shared.keyWindow?.windowController?.document as? Document; let savePanel = NSSavePanel() savePanel.title = "Save" savePanel.allowedFileTypes = ["tiff"] savePanel.nameFieldStringValue = (document?.getIdealFilename())! addAccessoryView(savePanel, fileTypes: ["png", "tiff", "jpeg", "psd"]) savePanel.begin { (result) in if result.rawValue == NSApplication.ModalResponse.OK.rawValue { if savePanel.allowedFileTypes![0] != "psd" { let canvas = document?.makeComposite() let canvasTiff = canvas?.tiffRepresentation let bitmapImage = NSBitmapImageRep(data: canvasTiff!) let canvasPng = bitmapImage!.representation(using: self.getBitmapType(savePanel.allowedFileTypes![0]), properties: [:]) try? canvasPng?.write(to: savePanel.url!) } else { var degreesToRotate = 0.0 if document!.info.orientation == 3 { degreesToRotate = 90 } else if document!.info.orientation == 4 { degreesToRotate = -90 } var flipHoriz = false var flipVert = false if document!.info.flippedHorizontally && (document!.info.orientation == 1 || document!.info.orientation == 2) { flipHoriz = true } else if document!.info.flippedHorizontally && (document!.info.orientation == 3 || document!.info.orientation == 4) { flipVert = true } else if document!.info.flippedVertically && (document!.info.orientation == 1 || document!.info.orientation == 2) { flipVert = true } else if !document!.info.flippedVertically && (document!.info.orientation == 3 || document!.info.orientation == 4) { flipHoriz = true } var rect = document!.info.cgRect rect.size.width = document!.info.cgSize.height rect.size.height = document!.info.cgSize.width let writer = PSDWriter(documentSize: rect.size) writer?.shouldUnpremultiplyLayerData = true let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big) let ccgContext = CGContext(data: nil, width: Int(rect.size.width), height: Int(rect.size.height), bitsPerComponent: 8, bytesPerRow: Int(rect.size.width) * 4, space: document!.info.colorSpace, bitmapInfo: bitmapInfo.rawValue) ccgContext?.setFillColor(document!.info.backgroundColor) ccgContext?.fill(rect) writer?.addLayer(with: ccgContext?.makeImage(), andName: "Background", andOpacity: 1.0, andOffset: .zero) for layer in document!.info.layers.reversed() { let finalCgImage = document!.simpleDrawLayer(layer.data)!.rotate(angle: self.degreesToRadians(degreesToRotate), flipHorizontally: flipHoriz, flipVertically: flipVert) if(layer.mask != nil) { let mask = document!.simpleDrawLayer(layer.mask!)!.rotate(angle: self.degreesToRadians(degreesToRotate), flipHorizontally: flipHoriz, flipVertically: flipVert) writer?.addLayer(with: mask, andName: layer.name + " (Mask)", andOpacity: 1.0, andOffset: .zero) } writer?.addLayer(with: finalCgImage, andName: layer.name, andOpacity: Float(layer.data.opacity), andOffset: .zero, andBlendMode: Int(self.getPSDBlendMode(layer).rawValue), andIsNonBaseLayer: layer.clipped) } let data = writer?.createPSDData() try? data?.write(to: savePanel.url!) } } } } func degreesToRadians(_ value: Double) -> Double { return value * .pi / 180 } @IBAction func exportThumbnailAction(_ sender: Any) { let document = NSApplication.shared.keyWindow?.windowController?.document as? Document; let savePanel = NSSavePanel() savePanel.title = "Save Thumbnail" savePanel.allowedFileTypes = ["png"] savePanel.nameFieldStringValue = (document?.getIdealFilename())! addAccessoryView(savePanel, fileTypes: ["png", "tiff", "jpeg"]) savePanel.begin { (result) in if result.rawValue == NSApplication.ModalResponse.OK.rawValue { let canvas = document?.makeThumbnail() let canvasTiff = canvas?.tiffRepresentation let bitmapImage = NSBitmapImageRep(data: canvasTiff!) let canvasPng = bitmapImage!.representation(using: self.getBitmapType(savePanel.allowedFileTypes![0]), properties: [:]) try? canvasPng?.write(to: savePanel.url!) } } } @IBAction func exportTimelapseAction(_ sender: Any) { guard let originalWindow = NSApplication.shared.keyWindow else { return } let document = originalWindow.windowController?.document as? Document; let savePanel = NSSavePanel() savePanel.title = "Save Timelapse" savePanel.allowedFileTypes = ["public.mpeg-4"] savePanel.nameFieldStringValue = (document?.getIdealFilename())! addAccessoryView(savePanel, fileTypes: ["mp4", "mov", "heic"]) savePanel.begin { (result) in if result.rawValue == NSApplication.ModalResponse.OK.rawValue { guard let archive = Archive(data: (document?.data)!, accessMode: Archive.AccessMode.read) else { return } let directory = NSTemporaryDirectory() let mixComposition = AVMutableComposition() var instructions: [AVMutableVideoCompositionLayerInstruction] = [] var duration = CMTime.zero for entry in archive.makeIterator() { if entry.path.contains(VideoPath) { let fileName = NSUUID().uuidString + ".mp4" // This returns a URL? even though it is an NSURL class method let fullURL = NSURL.fileURL(withPathComponents: [directory, fileName])! let _ = try? archive.extract(entry, to: fullURL) let asset = AVAsset(url: fullURL) guard let track = mixComposition.addMutableTrack(withMediaType: .video, preferredTrackID: Int32(kCMPersistentTrackID_Invalid)) else { return } try? track.insertTimeRange(CMTimeRangeMake(start: .zero, duration: asset.duration), of: asset.tracks(withMediaType: .video)[0], at: duration) duration = CMTimeAdd(duration, asset.duration) let opacityInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: track) opacityInstruction.setOpacity(0.0, at: duration + asset.duration) instructions.append(opacityInstruction) } } let mainInstruction = AVMutableVideoCompositionInstruction() mainInstruction.timeRange = CMTimeRangeMake(start: .zero, duration: duration) mainInstruction.layerInstructions = instructions let mainComposition = AVMutableVideoComposition() mainComposition.instructions = [mainInstruction] mainComposition.frameDuration = CMTimeMake(value: 1, timescale: 30) mainComposition.renderSize = CGSize(width: document!.info.videoFrame.0, height: document!.info.videoFrame.1) self.exporter = AVAssetExportSession(asset: mixComposition, presetName: AVAssetExportPresetHighestQuality) self.exporter?.outputURL = savePanel.url! self.exporter?.videoComposition = mainComposition switch(savePanel.allowedFileTypes![0]) { case "mp4": self.exporter?.outputFileType = .mp4; break; case "mov": self.exporter?.outputFileType = .mov; break; case "heic": self.exporter?.outputFileType = .heic; break; default: break; } let alert = NSAlert() alert.messageText = "Exporting timelapse..." alert.addButton(withTitle: "Cancel") alert.beginSheetModal(for: originalWindow) { (resonse) in self.exporter?.cancelExport() alert.window.close() } self.exporter?.exportAsynchronously { if self.exporter?.status != .cancelled { DispatchQueue.main.sync { alert.window.close() } } } } } } } extension CGImage { // we really only handle 90 degree turns, which is totally fine func rotate(angle : CGFloat, flipHorizontally : Bool, flipVertically : Bool) -> CGImage? { let newWidth: CGFloat = CGFloat(height) let newHeight: CGFloat = CGFloat(width) let ctx = CGContext(data: nil, width: Int(newWidth), height: Int(newHeight), bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace!, bitmapInfo: bitmapInfo.rawValue)! ctx.translateBy(x: newWidth / 2, y: newHeight / 2) ctx.rotate(by: -angle) if flipVertically { ctx.scaleBy(x: 1.0, y: -1.0) } if flipHorizontally { ctx.scaleBy(x: -1.0, y: 1.0) } let dstRect = CGRect(x: -width / 2, y: -height / 2, width: width, height: height) ctx.draw(self, in: dstRect) return ctx.makeImage() } }