diff --git a/WatchFaceDumper.xcodeproj/project.pbxproj b/WatchFaceDumper.xcodeproj/project.pbxproj index bf04392..0b83e14 100644 --- a/WatchFaceDumper.xcodeproj/project.pbxproj +++ b/WatchFaceDumper.xcodeproj/project.pbxproj @@ -42,6 +42,8 @@ EA9D41352520BFD300838E68 /* Face.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9D41342520BFD300838E68 /* Face.swift */; }; EA9D41382520C00800838E68 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9D41372520C00800838E68 /* Resources.swift */; }; EA9D41442520C11A00838E68 /* Watchface+FileWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9D41432520C11A00838E68 /* Watchface+FileWrapper.swift */; }; + EA9D8B7527E36AB5002917A9 /* NewDocumentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9D8B7427E36AB5002917A9 /* NewDocumentViewController.swift */; }; + EAA4F2912744F57900AE226B /* PortraitWatchface.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA4F2902744F57900AE226B /* PortraitWatchface.swift */; }; EAD954F1256E7D5D004EBB02 /* EditableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD954F0256E7D5D004EBB02 /* EditableImageView.swift */; }; /* End PBXBuildFile section */ @@ -71,6 +73,8 @@ EA9D41342520BFD300838E68 /* Face.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Face.swift; sourceTree = ""; }; EA9D41372520C00800838E68 /* Resources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resources.swift; sourceTree = ""; }; EA9D41432520C11A00838E68 /* Watchface+FileWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Watchface+FileWrapper.swift"; sourceTree = ""; }; + EA9D8B7427E36AB5002917A9 /* NewDocumentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDocumentViewController.swift; sourceTree = ""; }; + EAA4F2902744F57900AE226B /* PortraitWatchface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitWatchface.swift; sourceTree = ""; }; EAD954F0256E7D5D004EBB02 /* EditableImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableImageView.swift; sourceTree = ""; }; FE8E86D8C8D1F1C494C8E5E4 /* Pods-WatchFaceDumper.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchFaceDumper.release.xcconfig"; path = "Target Support Files/Pods-WatchFaceDumper/Pods-WatchFaceDumper.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -127,6 +131,7 @@ isa = PBXGroup; children = ( EA4D31B924FFE37100104A33 /* AppDelegate.swift */, + EA9D8B7427E36AB5002917A9 /* NewDocumentViewController.swift */, EA4E96FF251267B5008D822B /* WindowController.swift */, EA4D31BB24FFE37100104A33 /* ViewController.swift */, EA4E96F82512642D008D822B /* ImageItemRowView.swift */, @@ -147,6 +152,7 @@ isa = PBXGroup; children = ( EA52949D2520DAA400CE938E /* PhotosWatchface.swift */, + EAA4F2902744F57900AE226B /* PortraitWatchface.swift */, ); path = SpecificWatchfaces; sourceTree = ""; @@ -317,6 +323,7 @@ EA9D41252520BE8900838E68 /* ComplicationTemplate.swift in Sources */, EA4E96F92512642D008D822B /* ImageItemRowView.swift in Sources */, EA9D41442520C11A00838E68 /* Watchface+FileWrapper.swift in Sources */, + EA9D8B7527E36AB5002917A9 /* NewDocumentViewController.swift in Sources */, EA4E96FC25126454008D822B /* EditableAVPlayerView.swift in Sources */, EA9D41382520C00800838E68 /* Resources.swift in Sources */, EA4E9700251267B5008D822B /* WindowController.swift in Sources */, @@ -329,6 +336,7 @@ EAD954F1256E7D5D004EBB02 /* EditableImageView.swift in Sources */, EA9D412C2520BF5100838E68 /* ImageProvider.swift in Sources */, EA76F8D8251B94E700B395EC /* MetadataViewModel.swift in Sources */, + EAA4F2912744F57900AE226B /* PortraitWatchface.swift in Sources */, EA4D31BE24FFE37100104A33 /* Document.swift in Sources */, EA76F8D3251B906800B395EC /* ImageListOutlineViewModel.swift in Sources */, ); diff --git a/WatchFaceDumper/AppDelegate.swift b/WatchFaceDumper/AppDelegate.swift index ab9699f..c327dde 100644 --- a/WatchFaceDumper/AppDelegate.swift +++ b/WatchFaceDumper/AppDelegate.swift @@ -2,4 +2,10 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { + @IBAction func newDocument(_ sender: Any?) { + let wc = NSWindowController(window: .init(contentViewController: NewDocumentViewController())) + wc.window?.title = "New watch face" + wc.window?.styleMask = [.titled] + wc.showWindow(nil) + } } diff --git a/WatchFaceDumper/Document.swift b/WatchFaceDumper/Document.swift index 0a706fc..f02ec68 100644 --- a/WatchFaceDumper/Document.swift +++ b/WatchFaceDumper/Document.swift @@ -3,9 +3,7 @@ import ZIPFoundation import Ikemen class Document: NSDocument { - var watchface: Watchface = .init( - photosWatchface: PhotosWatchface( - device_size: 2, position: .top, snapshot: Data(), no_borders_snapshot: Data(), topComplication: nil, bottomComplication: nil, resources: .init(images: .init(imageList: []), files: [:]))) + var watchface: Watchface private var isLossyReading = false private var allowLossyAutosaving = false @@ -18,6 +16,20 @@ class Document: NSDocument { } } + convenience override init() { + self.init(photos: ()) + } + + init(photos: Void) { + watchface = .init(photosWatchface: PhotosWatchface(device_size: 2, position: .top, snapshot: Data(), no_borders_snapshot: Data(), topComplication: nil, bottomComplication: nil, resources: .init(images: .photos(.init(imageList: [])), files: [:]))) + super.init() + } + + init(portrait: Void) { + watchface = .init(portraitWatchface: PortraitWatchface(device_size: 2, style: .style3, snapshot: Data(), no_borders_snapshot: Data(), dateComplication: nil, bottomComplication: nil, resources: .init(images: .init(imageList: []), files: [:]))) + super.init() + } + override func makeWindowControllers() { addWindowController(WindowController(document: self)) } diff --git a/WatchFaceDumper/ImageItemRowView.swift b/WatchFaceDumper/ImageItemRowView.swift index 8e0904e..5d9899b 100644 --- a/WatchFaceDumper/ImageItemRowView.swift +++ b/WatchFaceDumper/ImageItemRowView.swift @@ -1,5 +1,6 @@ import AppKit import Ikemen +import Combine final class ImageItemRowView: NSTableRowView { private let titleLabel = NSTextField(labelWithString: "") @@ -20,7 +21,7 @@ final class ImageItemRowView: NSTableRowView { && lhs.movie?.data == rhs.movie?.data && lhs.movie?.duration == rhs.movie?.duration } - + var image: NSImage? var movie: (data: Data, duration: Double?)? } @@ -79,3 +80,64 @@ final class ImageItemRowView: NSTableRowView { movieView.controlsStyle = item.movie != nil ? .minimal : .none } } + +final class UltraCubeImageItemRowView: NSTableRowView { + private let titleLabel = NSTextField(labelWithString: "") + private let baseImageView = EditableImageView() + private let backImageView = EditableImageView() + private let maskImageView = EditableImageView() + + struct ImageItem: Equatable { + var baseImage: NSImage? + var backImage: NSImage? + var maskImage: NSImage? + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.baseImage?.tiffRepresentation == rhs.baseImage?.tiffRepresentation + && lhs.backImage?.tiffRepresentation == rhs.backImage?.tiffRepresentation + && lhs.maskImage?.tiffRepresentation == rhs.maskImage?.tiffRepresentation + } + } + + @Published var item: ImageItem { + didSet { + reloadItem() + } + } + + init(item: ImageItem) { + self.item = item + super.init(frame: .zero) + + let inpaintButton = NSButton(title: "Inpaint...", target: self, action: nil) + + let autolayout = northLayoutFormat([:], [ + "title": titleLabel, + "base": baseImageView ※ {$0.imageDidChange = {[weak self] in self?.item.baseImage = $0}}, + "back": backImageView ※ {$0.imageDidChange = {[weak self] in self?.item.backImage = $0}}, + "mask": maskImageView ※ {$0.imageDidChange = {[weak self] in self?.item.maskImage = $0}}, + "inpaint": inpaintButton]) + autolayout("H:|-[title]-|") + autolayout("H:|-[base]-[back(base)]-[mask(base)]-|") + autolayout("V:|-[title]-[base(240)]-|") + autolayout("V:|-[title]-[back(base)]-|") + autolayout("V:|-[title]-[mask(base)]-|") + autolayout("V:[inpaint]-|") + inpaintButton.centerXAnchor.constraint(equalTo: backImageView.centerXAnchor).isActive = true + addSubview(inpaintButton, positioned: .above, relativeTo: nil) + + reloadItem() + } + + required init?(coder: NSCoder) {fatalError("init(coder:) has not been implemented")} + + private func reloadItem() { + titleLabel.stringValue = [ + item.baseImage.map {"\(Int($0.size.width))×\(Int($0.size.height))"} ?? "no image (Portrait)", + (item.backImage != nil && item.maskImage != nil) ? "Portrait Photo" : "(Missing Portrait Support)" + ].joined(separator: ", ") + baseImageView.image = item.baseImage + backImageView.image = item.backImage + maskImageView.image = item.maskImage + } +} diff --git a/WatchFaceDumper/ImageListOutlineViewModel.swift b/WatchFaceDumper/ImageListOutlineViewModel.swift index 600fc9e..4bff7c7 100644 --- a/WatchFaceDumper/ImageListOutlineViewModel.swift +++ b/WatchFaceDumper/ImageListOutlineViewModel.swift @@ -11,8 +11,13 @@ final class ImageListOutlineViewModel: NSObject, NSOutlineViewDelegate, NSOutlin } func setWatchface(_ watchface: Watchface) { - imageListPropertyList = (try? PropertyListEncoder().encode(watchface.resources?.images.imageList)) - .flatMap {try? PropertyListSerialization.propertyList(from: $0, options: [], format: nil)} as? [Any] + let imageList: Data? + switch watchface.resources?.images { + case .photos(let v)?: imageList = try? PropertyListEncoder().encode(v.imageList) + case .ultraCube(let v)?: imageList = try? PropertyListEncoder().encode(v.imageList) + case nil: imageList = nil + } + imageListPropertyList = imageList.flatMap {try? PropertyListSerialization.propertyList(from: $0, options: [], format: nil)} as? [Any] } func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { diff --git a/WatchFaceDumper/NewDocumentViewController.swift b/WatchFaceDumper/NewDocumentViewController.swift new file mode 100644 index 0000000..f7fe4cf --- /dev/null +++ b/WatchFaceDumper/NewDocumentViewController.swift @@ -0,0 +1,53 @@ +import NorthLayout +import Ikemen + +class NewDocumentViewController: NSViewController { + private lazy var photosRadioButton: NSButton = NSButton(radioButtonWithTitle: "Photos", target: self, action: #selector(faceTypeChanged(_:))) ※ { + $0.state = .on + } + private lazy var portraitRadioButton: NSButton = NSButton(radioButtonWithTitle: "Portrait", target: self, action: #selector(faceTypeChanged(_:))) + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + + let autolayout = view.northLayoutFormat(["p": 20], [ + "photos": photosRadioButton, + "portrait": portraitRadioButton, + "cancel": NSButton(title: "Cancel", target: self, action: #selector(cancel(_:))) ※ { + $0.keyEquivalent = "\u{1b}" // esc + }, + "new": NSButton(title: "New", target: self, action: #selector(new(_:))) ※ { + $0.keyEquivalent = "\r" + },]) + autolayout("H:|-p-[photos]-p-|") + autolayout("H:|-p-[portrait]-p-|") + autolayout("H:|-(>=p)-[cancel(new)]-p-[new]-p-|") + autolayout("V:|-p-[photos]-[portrait]-(>=p)-[cancel]-p-|") + autolayout("V:[new]-p-|") + } + + @IBAction func faceTypeChanged(_ sender: Any?) { + } + + @IBAction func cancel(_ sender: Any?) { + view.window?.close() + } + + @IBAction func new(_ sender: Any?) { + if photosRadioButton.state == .on { + openNewDocument(.init(photos: ())) + } else if portraitRadioButton.state == .on { + openNewDocument(.init(portrait: ())) + } + view.window?.close() + } + + func openNewDocument(_ document: Document) { + document.makeWindowControllers() + document.showWindows() + } +} diff --git a/WatchFaceDumper/ViewController.swift b/WatchFaceDumper/ViewController.swift index 0903901..9332105 100644 --- a/WatchFaceDumper/ViewController.swift +++ b/WatchFaceDumper/ViewController.swift @@ -2,6 +2,8 @@ import Cocoa import NorthLayout import Ikemen import AVKit +import Combine +import CoreGraphics final class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource, NSSplitViewDelegate { var document: Document { @@ -93,6 +95,8 @@ final class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDa $0.setContentCompressionResistancePriority(.init(rawValue: 9), for: .horizontal) } + private var cancellables: Set = [] + init(document: Document) { self.document = document super.init(nibName: nil, bundle: nil) @@ -162,8 +166,13 @@ final class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDa let watchface = document.watchface // NSLog("%@", "\(watchface)") - let faceType = watchface.face.face_type.rawValue - faceTypeLabel.stringValue = faceType.first!.uppercased() + faceType.dropFirst() + " watch face" + let faceTypeName: String = { + switch watchface.face.face_type { + case .bundle where watchface.face.bundle_id == .comAppleNTKUltraCubeFaceBundle: return "Portrait" + default: return watchface.face.face_type.rawValue + } + }() + faceTypeLabel.stringValue = faceTypeName.first!.uppercased() + faceTypeName.dropFirst() + " watch face" snapshot.image = NSImage(data: watchface.snapshot) snapshot.imageFrameStyle = snapshot.image.map {_ in .none} ?? .grayBezel @@ -172,11 +181,22 @@ final class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDa metadataViewModel.setWatchface(watchface) - imageItems = watchface.resources.map { resources in - resources.images.imageList - .map {(resources.files[$0.imageURL], resources.files[$0.irisVideoURL])} - .map {ImageItem(image: $0.0.flatMap {NSImage(data: $0)}, movie: $0.1.map {($0, nil)})} - } ?? [] + resourceItems = watchface.resources.map { resources in + switch resources.images { + case .photos(let v): return .photos( + v.imageList + .map {(resources.files[$0.imageURL], resources.files[$0.irisVideoURL])} + .map {.init(image: $0.0.flatMap {NSImage(data: $0)}, movie: $0.1.map {($0, nil)})}) + case .ultraCube(let v): return .ultraCube( + v.imageList + .map {(resources.files[$0.baseImageURL], + $0.backgroundImageURL.flatMap {resources.files[$0]}, + $0.maskImageURL.flatMap {resources.files[$0]})} + .map {.init(baseImage: $0.0.flatMap {NSImage(data: $0)}, + backImage: $0.1.flatMap {NSImage(data: $0)}, + maskImage: $0.2.flatMap {NSImage(data: $0)})}) + } + } ?? .photos([]) imageListOutlineViewModel.setWatchface(watchface) @@ -184,51 +204,133 @@ final class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDa complicationsBottomLabel.stringValue = "complications.bottom: " + (watchface.metadata.complications_names.bottom ?? "" as String) + " " + (watchface.metadata.complication_sample_templates.bottom?.sampleText.map {"(\($0))"} ?? "") } - typealias ImageItem = ImageItemRowView.ImageItem - var imageItems: [ImageItem] = [] { + var resourceItems: ResourceItems = .photos([]) { didSet { - guard imageItems != oldValue else { return } + guard resourceItems != oldValue else { return } imageListTableView.reloadData() } } + enum ResourceItems: Equatable { + case photos([ImageItemRowView.ImageItem]) + case ultraCube([UltraCubeImageItemRowView.ImageItem]) - func numberOfRows(in tableView: NSTableView) -> Int {imageItems.count} + var count: Int { + switch self { + case .photos(let items): return items.count + case .ultraCube(let items): return items.count + } + } + } + + func numberOfRows(in tableView: NSTableView) -> Int {resourceItems.count} func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {nil} func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { - ImageItemRowView(item: imageItems[row]) ※ { - $0.imageDidChange = { [weak self] image in - guard let self = self else { return } - self.document.watchface = self.document.watchface ※ { watchface in - guard let imageURL = watchface.resources?.images.imageList[row].imageURL else { return } - let jpeg = image?.tiffRepresentation.flatMap {NSBitmapImageRep(data: $0)}?.representation(using: .jpeg, properties: [.compressionFactor: 0.95]) - watchface.resources?.files[imageURL] = jpeg - // TODO: resize - watchface.resources?.images.imageList[row].cropX = 0 - watchface.resources?.images.imageList[row].cropY = 0 - watchface.resources?.images.imageList[row].cropW = Double(image?.size.width ?? 0) - watchface.resources?.images.imageList[row].cropH = Double(image?.size.height ?? 0) - watchface.resources?.images.imageList[row].originalCropX = 0 - watchface.resources?.images.imageList[row].originalCropY = 0 - watchface.resources?.images.imageList[row].originalCropW = Double(image?.size.width ?? 0) - watchface.resources?.images.imageList[row].originalCropH = Double(image?.size.height ?? 0) + switch resourceItems { + case .photos(let items): + return ImageItemRowView(item: items[row]) ※ { + $0.imageDidChange = { [weak self] image in + guard let self = self else { return } + self.document.watchface = self.document.watchface ※ { watchface in + guard let resources = watchface.resources else { return } + let jpeg = image?.tiffRepresentation.flatMap {NSBitmapImageRep(data: $0)}?.representation(using: .jpeg, properties: [.compressionFactor: 0.95]) + watchface.resources = resources ※ { resources in + switch resources.images { + case .photos(let v): + resources.files[v.imageList[row].imageURL] = jpeg + resources.images = .photos(v ※ { + // TODO: resize + $0.imageList[row].cropX = 0 + $0.imageList[row].cropY = 0 + $0.imageList[row].cropW = Double(image?.size.width ?? 0) + $0.imageList[row].cropH = Double(image?.size.height ?? 0) + $0.imageList[row].originalCropX = 0 + $0.imageList[row].originalCropY = 0 + $0.imageList[row].originalCropW = Double(image?.size.width ?? 0) + $0.imageList[row].originalCropH = Double(image?.size.height ?? 0) + }) + case .ultraCube: + break + } + } + } + self.reloadDocument() } - self.reloadDocument() - } - $0.movieDidChange = { [weak self] in - guard let self = self else { return } - let (movie, duration) = ($0?.data, $0?.duration.flatMap {$0 > 0 ? $0 : nil}) - self.document.watchface = self.document.watchface ※ { watchface in - guard let irisVideoURL = watchface.resources?.images.imageList[row].irisVideoURL else { return } - watchface.resources?.files[irisVideoURL] = movie - watchface.resources?.images.imageList[row].isIris = movie != nil - watchface.resources?.images.imageList[row].irisDuration = duration ?? 3.0 - watchface.resources?.images.imageList[row].irisStillDisplayTime = (duration ?? 3.0) - 0.1 - // NOTE: 3 secs in 30fps is best for watchface resources that is cropped & created as watchface by iOS - // TODO: re-compress: should be less than 3 secs? - // TODO: update duration metadata - + $0.movieDidChange = { [weak self] in + guard let self = self else { return } + let (movie, duration) = ($0?.data, $0?.duration.flatMap {$0 > 0 ? $0 : nil}) + self.document.watchface = self.document.watchface ※ { watchface in + switch watchface.resources?.images { + case .photos(let v): + watchface.resources?.files[v.imageList[row].irisVideoURL] = movie + watchface.resources?.images = .photos(v ※ { + $0.imageList[row].isIris = movie != nil + $0.imageList[row].irisDuration = duration ?? 3.0 + $0.imageList[row].irisStillDisplayTime = (duration ?? 3.0) - 0.1 + // NOTE: 3 secs in 30fps is best for watchface resources that is cropped & created as watchface by iOS + // TODO: re-compress: should be less than 3 secs? + // TODO: update duration metadata + }) + case .ultraCube?, nil: + break + } + } + self.reloadDocument() } - self.reloadDocument() + } + case .ultraCube(let items): + return UltraCubeImageItemRowView(item: items[row]) ※ { + $0.$item.scan((UltraCubeImageItemRowView.ImageItem?, UltraCubeImageItemRowView.ImageItem)?.none) {($0?.1, $1)}.compactMap {$0}.sink { old, new in + let base = new.baseImage?.tiffRepresentation.flatMap {NSBitmapImageRep(data: $0)}?.representation(using: .jpeg, properties: [.compressionFactor: 0.95]) + let back = new.backImage?.tiffRepresentation.flatMap {NSBitmapImageRep(data: $0)}?.representation(using: .jpeg, properties: [.compressionFactor: 0.95]) + let maskPng = new.maskImage.flatMap { image -> Data? in + let width = Int(image.size.width) + let height = Int(image.size.height) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, // mask png should be 8-bit grayscale + samplesPerPixel: 1, + hasAlpha: false, // mask png should not have alpha channel + isPlanar: false, // suitable to be NSGraphicsContext.current + colorSpaceName: .calibratedWhite, + bytesPerRow: width, + bitsPerPixel: 8) else { return nil } + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + NSGraphicsContext.current = .init(bitmapImageRep: rep) + image.draw(in: NSRect(origin: .zero, size: image.size)) + return rep.representation(using: .png, properties: [:]) + } + self.document.watchface = self.document.watchface ※ { watchface in + switch watchface.resources?.images { + case .ultraCube(let v): + let baseImageURL = base.map {_ in "base_" + UUID().uuidString + ".jpeg"} ?? v.imageList[row].baseImageURL // TODO?: heic + let backgroundImageURL: String? = back.map {_ in "back_" + UUID().uuidString + ".jpeg"} // TODO?: heic + let maskImageURL: String? = maskPng.map {_ in "mask_" + UUID().uuidString + ".png"} + watchface.resources?.files[baseImageURL] = base + _ = backgroundImageURL.map {watchface.resources?.files[$0] = back} + _ = maskImageURL.map {watchface.resources?.files[$0] = maskPng} + watchface.resources?.images = .ultraCube(v ※ { + // TODO: resize + $0.imageList[row].cropX = 0 + $0.imageList[row].cropY = 0 + $0.imageList[row].cropW = Double(new.baseImage?.size.width ?? 0) + $0.imageList[row].cropH = Double(new.baseImage?.size.height ?? 0) + $0.imageList[row].originalCropX = 0 + $0.imageList[row].originalCropY = 0 + $0.imageList[row].originalCropW = Double(new.baseImage?.size.width ?? 0) + $0.imageList[row].originalCropH = Double(new.baseImage?.size.height ?? 0) + $0.imageList[row].baseImageURL = baseImageURL + $0.imageList[row].backgroundImageURL = backgroundImageURL + $0.imageList[row].maskImageURL = maskImageURL + }) + case .photos?, nil: + break + } + } + // TODO: apply editing + }.store(in: &cancellables) } } } @@ -247,40 +349,85 @@ final class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDa let imageData: Data? = Data() let movieData: Data? = nil - let filenameBase = UUID().uuidString - - let item = Watchface.Resources.Metadata.Item( - topAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), - leftAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), - bottomAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), - rightAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), - imageURL: "\(filenameBase).jpg", - irisDuration: 0, - irisStillDisplayTime: 0, - irisVideoURL: "\(filenameBase).mov", - isIris: movieData != nil, - localIdentifier: "", - modificationDate: Date(), - cropH: 0, - cropW: 0, - cropX: 0, - cropY: 0, - originalCropH: 0, - originalCropW: 0, - originalCropX: 0, - originalCropY: 0) - watchface.resources?.images.imageList.append(item) - watchface.resources?.files[item.imageURL] = imageData - watchface.resources?.files[item.irisVideoURL] = movieData + switch watchface.resources?.images { + case .photos(let v)?: + let filenameBase = UUID().uuidString + let item = Watchface.Resources.PhotosV1.Item( + topAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), + leftAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), + bottomAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), + rightAnalysis: .init(bgBrightness: 0, bgHue: 0, bgSaturation: 0, coloredText: false, complexBackground: false, shadowBrightness: 0, shadowHue: 0, shadowSaturation: 0, textBrightness: 0, textHue: 0, textSaturation: 0), + imageURL: "\(filenameBase).jpg", + irisDuration: 0, + irisStillDisplayTime: 0, + irisVideoURL: "\(filenameBase).mov", + isIris: movieData != nil, + localIdentifier: "", + modificationDate: Date(), + cropH: 0, + cropW: 0, + cropX: 0, + cropY: 0, + originalCropH: 0, + originalCropW: 0, + originalCropX: 0, + originalCropY: 0) + watchface.resources?.images = .photos(v ※ {$0.imageList.append(item)}) + watchface.resources?.files[item.imageURL] = imageData + watchface.resources?.files[item.irisVideoURL] = movieData + case .ultraCube(let v)?: + let filenameBase = UUID().uuidString + let item = Watchface.Resources.UltraCubeV2.Item( + baseImageURL: "\(filenameBase).jpg", + maskImageURL: nil, + backgroundImageURL: nil, + localIdentifier: "", + modificationDate: Date(), + cropH: 0, + cropW: 0, + cropX: 0, + cropY: 0, + originalCropH: 0, + originalCropW: 0, + originalCropX: 0, + originalCropY: 0, + baseImageZorder: 0, + maskedImageZorder: 1, + timeElementZorder: 2, + timeElementUnitBaseline: 0.8035714285714286, + timeElementUnitHeight: 0.2411167512690355, + imageAOTBrightness: 0.5, + parallaxFlat: false, + parallaxScale: 1.075, + userAdjusted: false) + watchface.resources?.images = .ultraCube(v ※ {$0.imageList.append(item)}) + watchface.resources?.files[item.baseImageURL] = imageData + case nil: + break + } } reloadDocument() } @IBAction func removeImage(_ sender: Any?) { - guard case 0.. public var complications_names: ComplicationPositionDictionary @@ -23,6 +23,7 @@ extension Watchface { public var slot2: Value? public var slot3: Value? public var bezel: Value? + public var date: Value? public enum CodingKeys: String, CodingKey, CaseIterable { case top, bottom @@ -33,6 +34,7 @@ extension Watchface { case bottom_right = "bottom-right" case slot1, slot2, slot3 case bezel + case date } public subscript(_ key: CodingKeys) -> Value? { @@ -49,6 +51,7 @@ extension Watchface { case .slot2: return slot2 case .slot3: return slot3 case .bezel: return bezel + case .date: return date } } set { @@ -64,23 +67,10 @@ extension Watchface { case .slot2: slot2 = newValue case .slot3: slot3 = newValue case .bezel: bezel = newValue + case .date: date = newValue } } } - - public init(top: Value? = nil, bottom: Value? = nil, top_left: Value? = nil, top_right: Value? = nil, bottom_left: Value? = nil, bottom_center: Value? = nil, bottom_right: Value? = nil, slot1: Value? = nil, slot2: Value? = nil, slot3: Value? = nil, bezel: Value? = nil) { - self.top = top - self.bottom = bottom - self.top_left = top_left - self.top_right = top_right - self.bottom_left = bottom_left - self.bottom_center = bottom_center - self.bottom_right = bottom_right - self.slot1 = slot1 - self.slot2 = slot2 - self.slot3 = slot3 - self.bezel = bezel - } } public struct Color: Codable { diff --git a/Watchface/Resources.swift b/Watchface/Resources.swift index 6809887..efd5edc 100644 --- a/Watchface/Resources.swift +++ b/Watchface/Resources.swift @@ -6,7 +6,28 @@ extension Watchface { /// filename -> content public var files: [String: Data] - public struct Metadata: Codable { + public enum Metadata: Codable { + /// photos or kaleidoscope (can be separated into cases...) + case photos(PhotosV1) + /// UltraCube aka Portrait + case ultraCube(UltraCubeV2) + + public init(from decoder: Decoder) throws { + self = try (try? PhotosV1(from: decoder)).map(Self.photos) + ?? (try? UltraCubeV2(from: decoder)).map(Self.ultraCube) + // generate an exception as photos + ?? Self.photos(PhotosV1(from: decoder)) + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .photos(let v): try v.encode(to: encoder) + case .ultraCube(let v): try v.encode(to: encoder) + } + } + } + + public struct PhotosV1: Codable { public var imageList: [Item] public var version: Int = 1 @@ -70,38 +91,47 @@ extension Watchface { public var originalCropX: Double public var originalCropY: Double - public init(topAnalysis: Analysis? = nil, leftAnalysis: Analysis? = nil, bottomAnalysis: Analysis? = nil, rightAnalysis: Analysis? = nil, imageURL: String, irisDuration: Double = 3, irisStillDisplayTime: Double = 0, irisVideoURL: String, isIris: Bool = true, localIdentifier: String, modificationDate: Date? = Date(), cropH: Double = 480, cropW: Double = 384, cropX: Double = 0, cropY: Double = 0, originalCropH: Double, originalCropW: Double, originalCropX: Double, originalCropY: Double) { - self.topAnalysis = topAnalysis - self.leftAnalysis = leftAnalysis - self.bottomAnalysis = bottomAnalysis - self.rightAnalysis = rightAnalysis - self.imageURL = imageURL - self.irisDuration = irisDuration - self.irisStillDisplayTime = irisStillDisplayTime - self.irisVideoURL = irisVideoURL - self.isIris = isIris - self.localIdentifier = localIdentifier - self.modificationDate = modificationDate - self.cropH = cropH - self.cropW = cropW - self.cropX = cropX - self.cropY = cropY - self.originalCropH = originalCropH - self.originalCropW = originalCropW - self.originalCropX = originalCropX - self.originalCropY = originalCropY - } - } - - public init(imageList: [Item], version: Int = 1) { - self.imageList = imageList - self.version = version } } - public init(images: Metadata, files: [String: Data]) { - self.images = images - self.files = files + public struct UltraCubeV2: Codable { + public var imageList: [Item] + public var version: Int = 2 + + public struct Item: Codable { + public var baseImageURL: String + /// paired with backgroundImageURL. nil for some photos + public var maskImageURL: String? + /// paired with maskImageURL. nil for some photos + public var backgroundImageURL: String? + + /// required for watchface sharing... it seems like PHAsset local identifier "UUID/L0/001". an empty string should work anyway. + public var localIdentifier: String + public var modificationDate: Date? = Date() + + public var cropH: Double? = 480 + public var cropW: Double? = 384 + public var cropX: Double? = 0 + public var cropY: Double? = 0 + public var originalCropH: Double + public var originalCropW: Double + public var originalCropX: Double + public var originalCropY: Double + + public var baseImageZorder: Int = 0 + public var maskedImageZorder: Int = 1 + public var timeElementZorder: Int = 2 + public var timeElementUnitBaseline: Double = 0.8035714285714286 + public var timeElementUnitHeight: Double = 0.2411167512690355 + /// 0-1? + public var imageAOTBrightness: Double = 0.5 + /// constant false? + public var parallaxFlat: Bool = false + /// constant 1.075? + public var parallaxScale: Double = 1.075 + public var userAdjusted: Bool? = false + + } } } } diff --git a/Watchface/SpecificWatchfaces/PhotosWatchface.swift b/Watchface/SpecificWatchfaces/PhotosWatchface.swift index fba7e38..5a594ce 100644 --- a/Watchface/SpecificWatchfaces/PhotosWatchface.swift +++ b/Watchface/SpecificWatchfaces/PhotosWatchface.swift @@ -28,7 +28,7 @@ public struct PhotosWatchface { } public enum Position: String { - case top, bototm + case top, bottom } public init(device_size: Int = 2, position: Position, snapshot: Data, no_borders_snapshot: Data, topComplication: Complication? = nil, bottomComplication: Complication? = nil, resources: Watchface.Resources) { diff --git a/Watchface/SpecificWatchfaces/PortraitWatchface.swift b/Watchface/SpecificWatchfaces/PortraitWatchface.swift new file mode 100644 index 0000000..ed07a36 --- /dev/null +++ b/Watchface/SpecificWatchfaces/PortraitWatchface.swift @@ -0,0 +1,131 @@ +import Foundation + +public struct PortraitWatchface { + public var device_size: Int = 2 + public var style: Style + public var snapshot: Data + public var no_borders_snapshot: Data + public var dateComplication: Complication? + public var bottomComplication: Complication? + public var resources: Resources + + public enum Style: String { + /// Classic + case style1 = "style 1" + /// Modern + case style2 = "style 2" + /// Rounded + case style3 = "style 3" + } + + public struct Resources { + public typealias Metadata = Watchface.Resources.UltraCubeV2 + public var images: Metadata + /// filename -> content + public var files: [String: Data] + + public init(images: Metadata, files: [String: Data]) { + self.images = images + self.files = files + } + } + + public struct Complication { + public var name: String + public var template: Watchface.Metadata.ComplicationTemplate + // TODO: eliminate nil? + public var faceItem: Watchface.Face.Complications.Item? + /// (filename -> content) + public var data: [String: Data]? + + public init(name: String, template: Watchface.Metadata.ComplicationTemplate, faceItem: Watchface.Face.Complications.Item? = nil, data: [String: Data]? = nil) { + self.name = name + self.template = template + self.faceItem = faceItem + self.data = data + } + } +} + +extension PortraitWatchface { + public init?(watchface: Watchface) { + guard let style = watchface.face.customization.style.flatMap(Self.Style.init), + let resources = watchface.resources, + let resourcesMetadata = Resources.Metadata(images: resources.images) else { return nil } + self.init( + device_size: watchface.metadata.device_size, + style: style, + snapshot: watchface.snapshot, + no_borders_snapshot: watchface.no_borders_snapshot, + dateComplication: watchface.metadata.complication_sample_templates.date.map { + Complication( + name: watchface.metadata.complications_names.date ?? "Off", + template: $0, + faceItem: watchface.face.complications?.date, + data: watchface.complicationData?.date) + }, + bottomComplication: watchface.metadata.complication_sample_templates.bottom.map { + Complication( + name: watchface.metadata.complications_names.bottom ?? "Off", + template: $0, + faceItem: watchface.face.complications?.bottom, + data: watchface.complicationData?.bottom) + }, + resources: .init(images: resourcesMetadata, files: resources.files)) + } +} +extension PortraitWatchface.Resources.Metadata { + public init?(images: Watchface.Resources.Metadata) { + guard case .ultraCube(let metadata) = images else { return nil } + self.init( + imageList: metadata.imageList.map { + Item( + baseImageURL: $0.baseImageURL, + maskImageURL: $0.maskImageURL, + backgroundImageURL: $0.backgroundImageURL, + localIdentifier: $0.localIdentifier, + modificationDate: $0.modificationDate, + originalCropH: $0.originalCropH, + originalCropW: $0.originalCropW, + originalCropX: $0.originalCropX, + originalCropY: $0.originalCropY, + baseImageZorder: $0.baseImageZorder, + maskedImageZorder: $0.maskedImageZorder, + timeElementZorder: $0.timeElementZorder, + imageAOTBrightness: $0.imageAOTBrightness, + parallaxFlat: $0.parallaxFlat, + parallaxScale: $0.parallaxScale, + userAdjusted: $0.userAdjusted) + }, + version: metadata.version) + } +} +extension Watchface.Resources.Metadata { + public init(images: PortraitWatchface.Resources.Metadata) { + self = .ultraCube(.init(imageList: images.imageList, version: images.version)) + } +} + +extension Watchface { + public init(portraitWatchface portrait: PortraitWatchface) { + self.init( + metadata: .init( + complication_sample_templates: .init(bottom: portrait.bottomComplication?.template, date: portrait.dateComplication?.template), + complications_names:.init( + bottom: portrait.bottomComplication?.name, + date: portrait.dateComplication?.name), + complications_item_ids: .init(), + complications_bundle_ids: nil), + face: .init( + face_type: .bundle, + bundle_id: .comAppleNTKUltraCubeFaceBundle, + resource_directory: true, + customization: .init(color: nil, content: "custom", position: nil, style: portrait.style.rawValue, typeface: nil), + complications: .init(bottom: portrait.bottomComplication?.faceItem, date: portrait.dateComplication?.faceItem)), + snapshot: portrait.snapshot, + no_borders_snapshot: portrait.no_borders_snapshot, + resources: .init(images: .init(images: portrait.resources.images), files: portrait.resources.files), + complicationData: [portrait.bottomComplication?.data, portrait.dateComplication?.data].compactMap {$0}.isEmpty ? nil : .init( + bottom: portrait.bottomComplication?.data, date: portrait.dateComplication?.data)) + } +} diff --git a/Watchface/Watchface+FileWrapper.swift b/Watchface/Watchface+FileWrapper.swift index ff5678f..dc1416e 100644 --- a/Watchface/Watchface+FileWrapper.swift +++ b/Watchface/Watchface+FileWrapper.swift @@ -32,7 +32,18 @@ public extension Watchface { throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Images.plist not found")) } let resources_metadata = try PropertyListDecoder().decode(Watchface.Resources.Metadata.self, from: resources_metadata_plist) - resources = Watchface.Resources(images: resources_metadata, files: resources_metadata.imageList.flatMap {[$0.imageURL, $0.irisVideoURL]}.reduce(into: [:]) {$0[$1] = resourcesDirectory[$1]?.regularFileContents}) // TODO: .pathfinders for kaleidoscope + resources = Watchface.Resources( + images: resources_metadata, + files: { + switch resources_metadata { + case .photos(let v): return v.imageList + .flatMap {[$0.imageURL, $0.irisVideoURL].compactMap {$0}} // TODO: .pathfinders for kaleidoscope + .reduce(into: [:]) {$0[$1] = resourcesDirectory[$1]?.regularFileContents} + case .ultraCube(let v): return v.imageList + .flatMap {[$0.baseImageURL, $0.backgroundImageURL, $0.maskImageURL].compactMap {$0}} + .reduce(into: [:]) {$0[$1] = resourcesDirectory[$1]?.regularFileContents} + } + }()) } else { resources = nil }