Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Compositor/Document/EditorSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,8 @@ final class EditorSession {
@ObservationIgnored var opensArtboard: ((PSDDocument, String) async -> Void)?
/// The layer list's Export Layers as PNGs…; set by the project controller, which shows the panels.
@ObservationIgnored var exportsLayers: (() async -> Void)?
/// The layer list's Export Layer As…, the Export As sheet for the one layer; set by the project controller too.
@ObservationIgnored var exportsLayerAs: (() async -> Void)?
/// While Volumetric Light's depth model is downloading or loading, the first time it's used (`DepthModel`).
var preparingDepth = false
/// Set while Convert to 8-Bit ends its edit, so `keepLayerDepths` lets it make 16-bit layers 8-bit.
Expand Down
29 changes: 26 additions & 3 deletions Compositor/IO/ImageExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,35 @@ actor ImageExporter {
try pngData(render(snapshot))
}

/// The layer `id` on its own, trimmed to its pixels, at every depth an Export As of it can be written at: the 8-bit
/// raster, and the 16-bit and HDR flattenings beside it when any layer in the export has such pixels, so the sheet
/// can offer a 16-bit PNG and an EXR as the canvas's does. Nil when the layer has no pixels, as an adjustment layer
/// or an empty one.
struct LayerExportRasters {
let raster: ExportRaster
let deep: ExportRaster?
let hdr: ExportRaster?
}

func layerExportRasters(_ snapshot: ProjectSnapshot, layer id: UUID) throws -> LayerExportRasters? {
let isolated = Self.isolating(id, in: snapshot)
let full = try render(isolated)
guard let rect = ImageTrim.calculateTrimRect(in: full.image, options: TrimOptions()) else { return nil }
func cropped(_ raster: ExportRaster) -> ExportRaster {
ExportRaster(image: raster.image.cropping(to: rect) ?? raster.image, resolution: raster.resolution)
}
let images = isolated.manifest.layers.filter(\.isVisible).compactMap { snapshot.images[$0.id] }
var deep: ExportRaster?, hdr: ExportRaster?
if images.contains(where: { $0.deep != nil }) { deep = cropped(try render(isolated, sixteenBit: true)) }
if images.contains(where: \.isHDR) { hdr = cropped(try render(isolated, sixteenBit: true, hdr: true)) }
return LayerExportRasters(raster: cropped(full), deep: deep, hdr: hdr)
}

/// Export Layers as PNGs: the layer `id` on its own, as it looks in the document, trimmed to its pixels. Nil when it
/// has none, as an adjustment layer or an empty one.
func layerPNGData(_ snapshot: ProjectSnapshot, layer id: UUID) throws -> Data? {
let raster = try render(Self.isolating(id, in: snapshot))
guard let trimmed = ImageTrim.trimImage(raster.image, options: TrimOptions()) else { return nil }
return try pngData(ExportRaster(image: trimmed, resolution: raster.resolution))
guard let rasters = try layerExportRasters(snapshot, layer: id) else { return nil }
return try pngData(rasters.raster)
}

/// `snapshot` with only the layer `id` showing: it, shown even if it's hidden; what's inside it, if it's a folder, and
Expand Down
49 changes: 46 additions & 3 deletions Compositor/IO/ProjectController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class ProjectController {
init(session: EditorSession) {
self.session = session
session.exportsLayers = { [weak self] in await self?.exportLayers() }
session.exportsLayerAs = { [weak self] in await self?.exportLayerAs() }
}

private func begin() -> Bool {
Expand Down Expand Up @@ -133,6 +134,47 @@ final class ProjectController {
return cleaned.isEmpty || cleaned.hasPrefix(".") ? "Layer" + cleaned : cleaned
}

/// The layer list's Export Layer As…: the one selected layer in the Export As sheet, its formats, size and preview
/// as the canvas's, trimmed to the layer's pixels and named after it.
func exportLayerAs() async {
guard let window, session.document != nil, begin() else { return }
defer { session.isProjectBusy = false }
guard let snapshot = session.projectSnapshot() else { return }
guard session.selectedLayerIDs.count <= 1, let id = session.selectedLayerIDs.first ?? session.activeLayerID,
let layer = snapshot.manifest.layers.first(where: { $0.id == id }) else { return }
do {
guard let rasters = try await ImageExporter.shared.layerExportRasters(snapshot, layer: id) else {
let alert = NSAlert()
alert.messageText = "“\(layer.name)” has nothing to export"
alert.informativeText = "A layer with no visible pixels, such as an adjustment layer, isn’t saved as an image."
alert.addButton(withTitle: "OK")
_ = await show(alert)
return
}
let chosen: (data: Data, format: ExportFormat)? = await withCheckedContinuation { continuation in
let sheet = NSWindow()
sheet.styleMask = [.titled, .fullSizeContentView]
sheet.title = "Export Layer As"
sheet.contentViewController = NSHostingController(rootView: ExportAsSheet(
raster: rasters.raster, deepRaster: rasters.deep, hdrRaster: rasters.hdr, snapshot: nil, session: session,
format: lastExportFormat) { chosen in
window.endSheet(sheet)
sheet.orderOut(nil)
// Release the hosted view and its closure after dismissal.
sheet.contentViewController = nil
continuation.resume(returning: chosen)
})
window.beginSheet(sheet)
}
guard let chosen else { return }
lastExportFormat = chosen.format
guard let url = await savePanel(for: chosen.format, named: layer.name) else { return }
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
try await ImageExporter.shared.write(chosen.data, to: url)
} catch { await showError("Couldn’t export layer", error: error) }
}

private func run(_ panel: NSSavePanel) async -> Bool {
if let window { return await panel.beginSheetModal(for: window) == .OK }
return await panel.begin() == .OK
Expand Down Expand Up @@ -179,14 +221,15 @@ final class ProjectController {
/// The format Export As last used, offered first next time.
private var lastExportFormat = ExportFormat.png

/// Where an export goes: the project's name, with the format's extension.
private func savePanel(for format: ExportFormat) async -> URL? {
/// Where an export goes: the project's name, or the layer's when one is exported, with the format's extension.
private func savePanel(for format: ExportFormat, named: String? = nil) async -> URL? {
let panel = NSSavePanel()
panel.allowedContentTypes = [format.type]
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.title = "Export " + format.rawValue
panel.nameFieldStringValue = (session.projectURL?.deletingPathExtension().lastPathComponent ?? "Untitled")
panel.nameFieldStringValue = (named.map(Self.fileName)
?? session.projectURL?.deletingPathExtension().lastPathComponent ?? "Untitled")
+ "." + (format.type.preferredFilenameExtension ?? format.rawValue.lowercased())
let response: NSApplication.ModalResponse
if let window { response = await panel.beginSheetModal(for: window) }
Expand Down
14 changes: 14 additions & 0 deletions Compositor/UI/NativeLayerList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ struct NativeLayerList: NSViewRepresentable {
exportItem.isEnabled = validateMenuItem(exportItem)
menu.addItem(exportItem)

// 14. Export Layer As…: the one layer in the Export As sheet, with the formats and size the canvas's has.
let exportAsItem = NSMenuItem(title: "Export Layer As…", action: #selector(exportLayerAsAction), keyEquivalent: "")
exportAsItem.target = self
exportAsItem.isEnabled = validateMenuItem(exportAsItem)
menu.addItem(exportAsItem)

return menu
}

Expand Down Expand Up @@ -291,6 +297,9 @@ struct NativeLayerList: NSViewRepresentable {
return session.canEditLayers && session.activeLayer != nil
case #selector(exportLayersAction):
return session.canStartProjectOperation && session.activeLayer != nil && session.exportsLayers != nil
case #selector(exportLayerAsAction):
return session.canStartProjectOperation && session.activeLayer != nil
&& session.selectedLayerIDs.count <= 1 && session.exportsLayerAs != nil
default:
if menuItem.submenu != nil && menuItem.title == "Add Mask" {
return session.canEditMask && session.activeLayer?.mask == nil
Expand All @@ -304,6 +313,11 @@ struct NativeLayerList: NSViewRepresentable {
Task { await export() }
}

@objc func exportLayerAsAction(_ sender: Any?) {
guard let export = session.exportsLayerAs else { return }
Task { await export() }
}

@objc func duplicateLayerAction(_ sender: Any?) {
session.duplicateActiveLayer()
}
Expand Down
34 changes: 34 additions & 0 deletions CompositorTests/LayerExportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ struct LayerExportTests {
#expect(try await ImageExporter.shared.layerPNGData(snapshot, layer: empty) == nil, "nothing to export")
}

@Test func layerExportRastersTrimToItsPixelsAndDepthFollows() async throws {
let red = UUID(), deep = UUID(), empty = UUID(), folder = UUID()
var folderRecord = ProjectLayerRecord(id: folder, name: "Folder", isVisible: true,
transform: LayerTransform(origin: .zero, size: CGSize(width: 10, height: 10)), imageFile: nil)
folderRecord.isGroup = true
var deepImage = ImportedImage(image: try solid(CGColor(red: 1, green: 0, blue: 0, alpha: 1)),
thumbnail: try solid(.black), name: "Deep")
deepImage.deep = try solid(CGColor(red: 1, green: 0, blue: 0, alpha: 1))
let images = [
red: ImportedImage(image: try solid(CGColor(red: 1, green: 0, blue: 0, alpha: 1)), thumbnail: try solid(.black), name: "Red"),
deep: deepImage,
empty: ImportedImage(image: try solid(.black, alpha: 0), thumbnail: try solid(.black), name: "Empty"),
]
let snapshot = ProjectSnapshot(manifest: ProjectManifest(documentID: UUID(), width: 10, height: 10, activeLayerID: red, layers: [
record(red, "Red", at: CGPoint(x: 1, y: 1), CGSize(width: 2, height: 3)),
record(deep, "Deep", at: .zero, CGSize(width: 4, height: 2)),
record(empty, "Empty", at: .zero, CGSize(width: 10, height: 10)),
folderRecord,
]), images: images)

let rasters = try #require(await ImageExporter.shared.layerExportRasters(snapshot, layer: red))
#expect(rasters.raster.image.width == 2 && rasters.raster.image.height == 3, "trimmed to the layer's pixels")
#expect(rasters.deep == nil && rasters.hdr == nil, "no 16-bit layer in this export, so no depth offered")

let deepRasters = try #require(await ImageExporter.shared.layerExportRasters(snapshot, layer: deep))
#expect(deepRasters.deep?.image.width == 4 && deepRasters.deep?.image.height == 2,
"the 16-bit flattening trimmed to the same box")
#expect(deepRasters.hdr == nil, "light brighter than white nowhere, so no EXR offered")

#expect(try await ImageExporter.shared.layerExportRasters(snapshot, layer: empty) == nil, "nothing to export")
#expect(try await ImageExporter.shared.layerExportRasters(snapshot, layer: folder) == nil,
"a folder with nothing shown in it")
}

@Test func layerNamesBecomeFileNames() {
#expect(ProjectController.fileName("Logo / Dark: v2") == "Logo - Dark- v2")
#expect(ProjectController.fileName(" ") == "Layer")
Expand Down