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) } }