rainbow-dragon/Sources/ForgeGrade/GradeSnapshot.swift

142 lines
4.3 KiB
Swift

import Foundation
import ForgeColor
/// Versioned, deterministic JSON serialization of a GradeStack.
/// Sorted keys -> byte-stable re-encoding, snapshots diff cleanly.
public enum GradeSnapshot {
public static let currentVersion = 1
public enum SnapshotError: Error, Equatable {
case unsupportedVersion(Int)
case unknownNodeKind(String)
}
public static func encode(_ stack: GradeStack) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return try encoder.encode(StackDTO(stack))
}
public static func decode(_ data: Data) throws -> GradeStack {
let dto = try JSONDecoder().decode(StackDTO.self, from: data)
guard dto.version == currentVersion else {
throw SnapshotError.unsupportedVersion(dto.version)
}
return GradeStack(nodes: try dto.nodes.map { try $0.toNode() })
}
}
// MARK: - DTOs
private struct StackDTO: Codable {
var version: Int
var nodes: [NodeDTO]
init(_ stack: GradeStack) {
version = GradeSnapshot.currentVersion
nodes = stack.nodes.map(NodeDTO.init)
}
}
private struct NodeDTO: Codable {
var id: UUID
var isEnabled: Bool
var kind: KindDTO
init(_ node: GradeNode) {
id = node.id
isEnabled = node.isEnabled
kind = KindDTO(node.kind)
}
func toNode() throws -> GradeNode {
GradeNode(id: id, kind: try kind.toKind(), isEnabled: isEnabled)
}
}
private struct KindDTO: Codable {
var type: String
// Flat optional payloads only the one matching `type` is set.
var inputTransform: InputTransform?
var outputTransform: OutputTransform?
var cdl: CDLDTO?
var saturation: Float?
var curves: CurveSet?
var lutSize: Int?
var lutTable: [Float]?
init(_ kind: GradeNode.Kind) {
switch kind {
case .inputTransform(let t):
type = "inputTransform"
inputTransform = t
case .outputTransform(let t):
type = "outputTransform"
outputTransform = t
case .cdl(let c):
type = "cdl"
cdl = CDLDTO(c)
case .saturation(let s):
type = "saturation"
saturation = s
case .curves(let c):
type = "curves"
curves = c
case .lut3d(let lut):
type = "lut3d"
lutSize = lut.size
lutTable = lut.table
}
}
func toKind() throws -> GradeNode.Kind {
switch type {
case "inputTransform":
guard let t = inputTransform else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
return .inputTransform(t)
case "outputTransform":
guard let t = outputTransform else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
return .outputTransform(t)
case "cdl":
guard let c = cdl else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
return .cdl(c.toCDL())
case "saturation":
guard let s = saturation else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
return .saturation(s)
case "curves":
guard let c = curves else { throw GradeSnapshot.SnapshotError.unknownNodeKind(type) }
return .curves(c)
case "lut3d":
guard let size = lutSize, let table = lutTable,
table.count == size * size * size * 3 else {
throw GradeSnapshot.SnapshotError.unknownNodeKind(type)
}
return .lut3d(Lut3D(size: size, table: table))
default:
throw GradeSnapshot.SnapshotError.unknownNodeKind(type)
}
}
}
/// SIMD3 is not Codable-stable across platforms; explicit arrays.
private struct CDLDTO: Codable {
var slope: [Float]
var offset: [Float]
var power: [Float]
var saturation: Float
init(_ c: CDL) {
slope = [c.slope.x, c.slope.y, c.slope.z]
offset = [c.offset.x, c.offset.y, c.offset.z]
power = [c.power.x, c.power.y, c.power.z]
saturation = c.saturation
}
func toCDL() -> CDL {
CDL(
slope: SIMD3(slope[0], slope[1], slope[2]),
offset: SIMD3(offset[0], offset[1], offset[2]),
power: SIMD3(power[0], power[1], power[2]),
saturation: saturation)
}
}