Compare commits
No commits in common. "172b554205c7d3dc2f5c63cb341980d38e340d0d" and "d0c7753ef7dc608d5b81b41d08ec06f7ea1c673e" have entirely different histories.
172b554205
...
d0c7753ef7
10 changed files with 325 additions and 781 deletions
|
|
@ -20,7 +20,6 @@ let package = Package(
|
|||
"ForgeCamera",
|
||||
.product(name: "NIO", package: "swift-nio"),
|
||||
.product(name: "NIOHTTP1", package: "swift-nio"),
|
||||
.product(name: "NIOWebSocket", package: "swift-nio"),
|
||||
]),
|
||||
// Vendor drivers
|
||||
.target(name: "ForgeCameraARRI", dependencies: [
|
||||
|
|
@ -31,8 +30,6 @@ let package = Package(
|
|||
.target(name: "ForgeCameraRED", dependencies: [
|
||||
"ForgeCamera",
|
||||
.product(name: "NIO", package: "swift-nio"),
|
||||
.product(name: "NIOHTTP1", package: "swift-nio"),
|
||||
.product(name: "NIOWebSocket", package: "swift-nio"),
|
||||
]),
|
||||
.target(name: "ForgeCameraSony", dependencies: [
|
||||
"ForgeCamera",
|
||||
|
|
|
|||
|
|
@ -82,40 +82,19 @@ public actor GradeGang {
|
|||
guard let driver = drivers[target] else { continue }
|
||||
let caps = driver.capabilities
|
||||
do {
|
||||
if caps.lut3dSizes.isEmpty {
|
||||
// CDL-only camera (RED over RCP2: no live LUT upload).
|
||||
// Split CDL out; if the baked remainder is non-identity the
|
||||
// camera cannot reproduce the grade — hard error so the
|
||||
// operator knows monitor ≠ grade.
|
||||
guard caps.supportsNativeCDL else {
|
||||
throw CameraError.unsupportedLook("camera accepts neither LUTs nor CDL")
|
||||
}
|
||||
let look = Flattener.flatten(
|
||||
stack: grade,
|
||||
latticeSize: 17, // cheap lattice, only used for the identity check
|
||||
splitLeadingCDL: true)
|
||||
guard look.cdl != nil else {
|
||||
throw CameraError.unsupportedLook("grade has no leading CDL; CDL-only camera cannot represent it")
|
||||
}
|
||||
if let lut = look.lut, !lut.isApproximatelyIdentity() {
|
||||
throw CameraError.unsupportedLook("grade has nodes beyond CDL; CDL-only camera cannot represent them")
|
||||
}
|
||||
try await driver.push(look: FlattenedLook(cdl: look.cdl, lut: nil, latticeSize: 0))
|
||||
} else {
|
||||
// Prefer exact requested lattice; else largest supported size
|
||||
// *below* it (downgrade only — baking a 17³-quality grade into a
|
||||
// 99³ lattice wastes upload time and implies false precision).
|
||||
guard let size = caps.lut3dSizes.contains(latticeSize)
|
||||
? latticeSize
|
||||
: caps.lut3dSizes.filter({ $0 < latticeSize }).max() else {
|
||||
throw CameraError.unsupportedLook("no compatible LUT size <= \(latticeSize) (camera accepts \(caps.lut3dSizes))")
|
||||
}
|
||||
let look = Flattener.flatten(
|
||||
stack: grade,
|
||||
latticeSize: size,
|
||||
splitLeadingCDL: caps.supportsNativeCDL)
|
||||
try await driver.push(look: look)
|
||||
// Prefer exact requested lattice; else largest supported size
|
||||
// *below* it (downgrade only — baking a 17³-quality grade into a
|
||||
// 99³ lattice wastes upload time and implies false precision).
|
||||
guard let size = caps.lut3dSizes.contains(latticeSize)
|
||||
? latticeSize
|
||||
: caps.lut3dSizes.filter({ $0 < latticeSize }).max() else {
|
||||
throw CameraError.unsupportedLook("no compatible LUT size <= \(latticeSize) (camera accepts \(caps.lut3dSizes))")
|
||||
}
|
||||
let look = Flattener.flatten(
|
||||
stack: grade,
|
||||
latticeSize: size,
|
||||
splitLeadingCDL: caps.supportsNativeCDL)
|
||||
try await driver.push(look: look)
|
||||
} catch {
|
||||
failures.append((slot: target, error: error))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,44 @@
|
|||
import Foundation
|
||||
import NIO
|
||||
import NIOHTTP1
|
||||
import NIOWebSocket
|
||||
import ForgeCamera
|
||||
import ForgeGrade
|
||||
import ForgeColor
|
||||
|
||||
/// RCP2 param IDs (wire form: RCP_PARAM_ prefix stripped).
|
||||
/// Verified against RED parameter docs via docs/research/red-rcp2-protocol.md.
|
||||
#if canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
/// RED parameter names — single source of truth, correct against RED SDK
|
||||
/// (RCP2) when hardware/SDK agreement lands. Sim mirrors these.
|
||||
enum RedParams {
|
||||
static let recordState = "RECORD_STATE" // cur.val: 0 idle, 1 recording, set "2" = toggle
|
||||
static let recordState = "RECORD_STATE"
|
||||
static let clipName = "CLIP_NAME"
|
||||
static let iso = "ISO"
|
||||
static let colorTemp = "COLOR_TEMPERATURE"
|
||||
static let colorTemp = "COLOR_TEMP"
|
||||
static let tint = "TINT"
|
||||
static let sensorFPS = "SENSOR_FRAME_RATE"
|
||||
static let sensorFPS = "SENSOR_FPS"
|
||||
static let timecode = "TIMECODE"
|
||||
static let cdlEnable = "CDL_ENABLE"
|
||||
static let cdlSlopeR = "CDL_SLOPE_RED"
|
||||
static let cdlSlopeG = "CDL_SLOPE_GREEN"
|
||||
static let cdlSlopeB = "CDL_SLOPE_BLUE"
|
||||
static let cdlOffsetR = "CDL_OFFSET_RED"
|
||||
static let cdlOffsetG = "CDL_OFFSET_GREEN"
|
||||
static let cdlOffsetB = "CDL_OFFSET_BLUE"
|
||||
static let cdlPowerR = "CDL_POWER_RED"
|
||||
static let cdlPowerG = "CDL_POWER_GREEN"
|
||||
static let cdlPowerB = "CDL_POWER_BLUE"
|
||||
static let cdlSaturation = "CDL_SATURATION"
|
||||
static let ipp2CDL = "IPP2_CDL"
|
||||
}
|
||||
|
||||
/// DSMC3 driver — real RCP2: WebSocket ws://<ip>:9998/rcp, JSON text frames.
|
||||
/// Handshake: rcp_config w/ client name. CDL pushed as per-channel rcp_set ops.
|
||||
/// NO live LUT upload (RCP2 limitation) — capabilities.lut3dSizes empty; grade
|
||||
/// pipeline keeps camera-native CDL only, remainder must stay identity.
|
||||
/// DSMC3 driver: RCP2-style length-prefixed JSON over TCP.
|
||||
/// Request/response serialized through the actor — one in-flight request.
|
||||
public actor RedDriver: CameraDriver {
|
||||
public nonisolated let capabilities = CameraCapabilities(
|
||||
vendor: .red,
|
||||
supportsNativeCDL: true,
|
||||
lut3dSizes: [],
|
||||
lut3dSizes: [17, 33],
|
||||
metadataFields: [.clipName, .exposureIndex, .whiteBalance, .tint, .fps, .timecode])
|
||||
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let pollInterval: Duration
|
||||
|
||||
private var group: MultiThreadedEventLoopGroup?
|
||||
private var channel: Channel?
|
||||
private var socketFD: Int32 = -1
|
||||
private var pollTask: Task<Void, Never>?
|
||||
|
||||
/// Latest values per param, merged into CameraState on any change.
|
||||
private var paramCache: [String: String] = [:]
|
||||
private var lastState = CameraState(connection: .disconnected)
|
||||
private var stateContinuations: [UUID: AsyncStream<CameraState>.Continuation] = [:]
|
||||
|
||||
public init(host: String, port: Int = 9998, pollInterval: Duration = .milliseconds(150)) {
|
||||
public init(host: String, port: Int, pollInterval: Duration = .milliseconds(150)) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.pollInterval = pollInterval
|
||||
|
|
@ -84,71 +69,111 @@ public actor RedDriver: CameraDriver {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: WebSocket transport
|
||||
// MARK: Socket transport (blocking, actor-serialized, short timeouts)
|
||||
|
||||
private func openSocket() throws {
|
||||
let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0)
|
||||
guard fd >= 0 else { throw CameraError.connectionFailed("socket() failed") }
|
||||
|
||||
var tv = timeval(tv_sec: 3, tv_usec: 0)
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = in_port_t(UInt16(port).bigEndian)
|
||||
guard inet_pton(AF_INET, host, &addr.sin_addr) == 1 else {
|
||||
close(fd)
|
||||
throw CameraError.connectionFailed("bad host \(host)")
|
||||
}
|
||||
let rc = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
Glibc.connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard rc == 0 else {
|
||||
close(fd)
|
||||
throw CameraError.connectionFailed("connect to \(host):\(port) failed")
|
||||
}
|
||||
socketFD = fd
|
||||
}
|
||||
|
||||
private func closeSocket() {
|
||||
if socketFD >= 0 {
|
||||
close(socketFD)
|
||||
socketFD = -1
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAll(_ data: Data) throws {
|
||||
var sent = 0
|
||||
try data.withUnsafeBytes { (buf: UnsafeRawBufferPointer) in
|
||||
while sent < buf.count {
|
||||
let n = write(socketFD, buf.baseAddress!.advanced(by: sent), buf.count - sent)
|
||||
guard n > 0 else { throw CameraError.connectionFailed("write failed") }
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recvExact(_ count: Int) throws -> Data {
|
||||
var out = Data(capacity: count)
|
||||
var remaining = count
|
||||
var chunk = [UInt8](repeating: 0, count: 64 * 1024)
|
||||
while remaining > 0 {
|
||||
let n = read(socketFD, &chunk, min(remaining, chunk.count))
|
||||
guard n > 0 else { throw CameraError.connectionFailed("read failed/timeout") }
|
||||
out.append(contentsOf: chunk[0..<n])
|
||||
remaining -= n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Send framed JSON request, await framed JSON reply.
|
||||
private func request(_ obj: [String: Any]) throws -> [String: Any] {
|
||||
guard socketFD >= 0 else { throw CameraError.connectionFailed("not connected") }
|
||||
let payload = try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])
|
||||
var frame = Data()
|
||||
var lenBE = UInt32(payload.count).bigEndian
|
||||
withUnsafeBytes(of: &lenBE) { frame.append(contentsOf: $0) }
|
||||
frame.append(payload)
|
||||
try sendAll(frame)
|
||||
|
||||
let header = try recvExact(4)
|
||||
// Byte-wise assembly — Data slices may be misaligned for a u32 load.
|
||||
let length = header.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
let body = try recvExact(Int(length))
|
||||
guard let reply = try JSONSerialization.jsonObject(with: body) as? [String: Any] else {
|
||||
throw CameraError.connectionFailed("bad reply JSON")
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
private func getParam(_ name: String) throws -> String {
|
||||
let reply = try request(["type": "get", "param": name])
|
||||
guard reply["ok"] as? Bool == true, let value = reply["value"] as? String else {
|
||||
throw CameraError.connectionFailed("get \(name) failed")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func setParam(_ name: String, _ value: String) throws {
|
||||
let reply = try request(["type": "set", "param": name, "value": value])
|
||||
guard reply["ok"] as? Bool == true else {
|
||||
throw CameraError.pushFailed("set \(name) failed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CameraDriver
|
||||
|
||||
public func connect() async throws {
|
||||
await teardown()
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
self.group = group
|
||||
|
||||
let upgradeResult: EventLoopPromise<Channel> = group.next().makePromise(of: Channel.self)
|
||||
let driver = self
|
||||
|
||||
let upgrader = NIOWebSocketClientUpgrader(
|
||||
requestKey: Data((0..<16).map { _ in UInt8.random(in: 0...255) }).base64EncodedString(),
|
||||
upgradePipelineHandler: { channel, _ in
|
||||
channel.pipeline.addHandler(RedClientWSHandler(driver: driver)).map {
|
||||
upgradeResult.succeed(channel)
|
||||
}
|
||||
})
|
||||
|
||||
let bootstrap = ClientBootstrap(group: group)
|
||||
.channelInitializer { channel in
|
||||
let upgradeConfig = NIOHTTPClientUpgradeConfiguration(
|
||||
upgraders: [upgrader],
|
||||
completionHandler: { _ in
|
||||
channel.pipeline.removeHandler(name: "sendInitialRequest", promise: nil)
|
||||
})
|
||||
return channel.pipeline.addHTTPClientHandlers(withClientUpgrade: upgradeConfig).flatMap {
|
||||
channel.pipeline.addHandler(
|
||||
SendUpgradeRequestHandler(host: self.host, port: self.port, path: "/rcp"),
|
||||
name: "sendInitialRequest")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let ch = try await bootstrap.connect(host: host, port: port).get()
|
||||
_ = ch
|
||||
// Wait for upgrade completion (or fail after timeout).
|
||||
let upgraded: Channel = try await withThrowingTaskGroup(of: Channel.self) { grp in
|
||||
grp.addTask {
|
||||
try await upgradeResult.futureResult.get()
|
||||
}
|
||||
grp.addTask {
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
throw CameraError.connectionFailed("WS upgrade timeout")
|
||||
}
|
||||
let first = try await grp.next()!
|
||||
grp.cancelAll()
|
||||
return first
|
||||
}
|
||||
channel = upgraded
|
||||
} catch {
|
||||
upgradeResult.fail(error)
|
||||
await teardown()
|
||||
throw CameraError.connectionFailed("connect \(host):\(port): \(error)")
|
||||
closeSocket()
|
||||
try openSocket()
|
||||
let reply = try request(["type": "handshake"])
|
||||
guard reply["ok"] as? Bool == true else {
|
||||
closeSocket()
|
||||
throw CameraError.connectionFailed("handshake rejected")
|
||||
}
|
||||
|
||||
// RCP2 handshake.
|
||||
try send([
|
||||
"type": "rcp_config",
|
||||
"strings_decoded": 0,
|
||||
"json_minified": 1,
|
||||
"include_cacheable_flags": 0,
|
||||
"encoding_type": "html",
|
||||
"client": ["name": "Forge"],
|
||||
])
|
||||
|
||||
emit(CameraState(connection: .connected))
|
||||
startPolling()
|
||||
}
|
||||
|
|
@ -156,183 +181,65 @@ public actor RedDriver: CameraDriver {
|
|||
public func disconnect() async {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
await teardown()
|
||||
closeSocket()
|
||||
emit(CameraState(connection: .disconnected))
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
if let ch = channel {
|
||||
_ = try? await ch.close()
|
||||
public func push(look: FlattenedLook) async throws {
|
||||
if let lut = look.lut, !capabilities.lut3dSizes.contains(lut.size) {
|
||||
throw CameraError.unsupportedLook("LUT size \(lut.size) not in \(capabilities.lut3dSizes)")
|
||||
}
|
||||
channel = nil
|
||||
if let g = group {
|
||||
try? await g.shutdownGracefully()
|
||||
if let cdl = look.cdl {
|
||||
let obj: [String: Any] = [
|
||||
"slope": [cdl.slope.x, cdl.slope.y, cdl.slope.z],
|
||||
"offset": [cdl.offset.x, cdl.offset.y, cdl.offset.z],
|
||||
"power": [cdl.power.x, cdl.power.y, cdl.power.z],
|
||||
"saturation": cdl.saturation,
|
||||
]
|
||||
let json = String(data: try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), encoding: .utf8)!
|
||||
try setParam(RedParams.ipp2CDL, json)
|
||||
}
|
||||
group = nil
|
||||
}
|
||||
|
||||
private func send(_ obj: [String: Any]) throws {
|
||||
guard let ch = channel else {
|
||||
throw CameraError.connectionFailed("not connected")
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])
|
||||
var buf = ch.allocator.buffer(capacity: data.count)
|
||||
buf.writeBytes(data)
|
||||
let frame = WebSocketFrame(fin: true, opcode: .text, maskKey: randomMask(), data: buf)
|
||||
ch.writeAndFlush(NIOAny(frame), promise: nil)
|
||||
}
|
||||
|
||||
private func randomMask() -> WebSocketMaskingKey {
|
||||
WebSocketMaskingKey([UInt8.random(in: 0...255), .random(in: 0...255), .random(in: 0...255), .random(in: 0...255)])!
|
||||
}
|
||||
|
||||
// MARK: Inbound (called from WS handler)
|
||||
|
||||
func handleInbound(_ data: Data) {
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = obj["id"] as? String else { return }
|
||||
// Value under cur.val (int/double/string).
|
||||
guard let cur = obj["cur"] as? [String: Any], let val = cur["val"] else { return }
|
||||
let stringValue: String
|
||||
if let s = val as? String {
|
||||
stringValue = s
|
||||
} else if let i = val as? Int {
|
||||
stringValue = String(i)
|
||||
} else if let d = val as? Double {
|
||||
stringValue = String(d)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
paramCache[id] = stringValue
|
||||
rebuildState()
|
||||
}
|
||||
|
||||
private func rebuildState() {
|
||||
let newState = CameraState(
|
||||
connection: .connected,
|
||||
isRecording: paramCache[RedParams.recordState] == "1",
|
||||
clipName: (paramCache[RedParams.clipName]?.isEmpty ?? true) ? nil : paramCache[RedParams.clipName],
|
||||
metadata: CameraMetadata(
|
||||
exposureIndex: paramCache[RedParams.iso].flatMap(Int.init),
|
||||
whiteBalance: paramCache[RedParams.colorTemp].flatMap(Int.init),
|
||||
tint: paramCache[RedParams.tint].flatMap(Int.init),
|
||||
fps: paramCache[RedParams.sensorFPS].flatMap(Double.init),
|
||||
timecode: paramCache[RedParams.timecode]))
|
||||
if newState != lastState {
|
||||
emit(newState)
|
||||
if let lut = look.lut {
|
||||
let reply = try request(["type": "lut", "size": lut.size, "table": lut.table])
|
||||
guard reply["ok"] as? Bool == true else {
|
||||
throw CameraError.pushFailed("LUT upload rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleChannelClosed() {
|
||||
if lastState.connection == .connected {
|
||||
emit(CameraState(connection: .disconnected))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Polling (rcp_get; responses arrive via handleInbound)
|
||||
// MARK: Polling
|
||||
|
||||
private func startPolling() {
|
||||
pollTask?.cancel()
|
||||
pollTask = Task {
|
||||
let polled = [
|
||||
RedParams.recordState, RedParams.clipName, RedParams.iso,
|
||||
RedParams.colorTemp, RedParams.tint, RedParams.sensorFPS, RedParams.timecode,
|
||||
]
|
||||
while !Task.isCancelled {
|
||||
for p in polled {
|
||||
try? self.send(["type": "rcp_get", "id": p])
|
||||
}
|
||||
pollOnce()
|
||||
try? await Task.sleep(for: pollInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Look push
|
||||
|
||||
public func push(look: FlattenedLook) async throws {
|
||||
// RCP2 cannot upload LUTs. Non-identity LUT = grade won't match on camera.
|
||||
if let lut = look.lut, !lut.isApproximatelyIdentity() {
|
||||
throw CameraError.unsupportedLook(
|
||||
"RED RCP2 cannot receive live 3D LUTs — use CDL-only grade or pre-load LUT on camera")
|
||||
private func pollOnce() {
|
||||
do {
|
||||
let rec = try getParam(RedParams.recordState) == "1"
|
||||
let clip = try getParam(RedParams.clipName)
|
||||
let newState = CameraState(
|
||||
connection: .connected,
|
||||
isRecording: rec,
|
||||
clipName: clip.isEmpty ? nil : clip,
|
||||
metadata: CameraMetadata(
|
||||
exposureIndex: Int(try getParam(RedParams.iso)),
|
||||
whiteBalance: Int(try getParam(RedParams.colorTemp)),
|
||||
tint: Int(try getParam(RedParams.tint)),
|
||||
fps: Double(try getParam(RedParams.sensorFPS)),
|
||||
timecode: try getParam(RedParams.timecode)))
|
||||
if newState != lastState {
|
||||
emit(newState)
|
||||
}
|
||||
} catch {
|
||||
if lastState.connection == .connected {
|
||||
emit(CameraState(connection: .disconnected))
|
||||
}
|
||||
}
|
||||
guard let cdl = look.cdl else {
|
||||
throw CameraError.unsupportedLook("nothing to push (no CDL)")
|
||||
}
|
||||
let sets: [(String, Any)] = [
|
||||
(RedParams.cdlSlopeR, Double(cdl.slope.x)),
|
||||
(RedParams.cdlSlopeG, Double(cdl.slope.y)),
|
||||
(RedParams.cdlSlopeB, Double(cdl.slope.z)),
|
||||
(RedParams.cdlOffsetR, Double(cdl.offset.x)),
|
||||
(RedParams.cdlOffsetG, Double(cdl.offset.y)),
|
||||
(RedParams.cdlOffsetB, Double(cdl.offset.z)),
|
||||
(RedParams.cdlPowerR, Double(cdl.power.x)),
|
||||
(RedParams.cdlPowerG, Double(cdl.power.y)),
|
||||
(RedParams.cdlPowerB, Double(cdl.power.z)),
|
||||
(RedParams.cdlSaturation, Double(cdl.saturation)),
|
||||
(RedParams.cdlEnable, 1),
|
||||
]
|
||||
for (param, value) in sets {
|
||||
try send(["type": "rcp_set", "id": param, "value": value])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NIO handlers
|
||||
|
||||
/// Sends the initial HTTP upgrade request when the channel becomes active.
|
||||
final class SendUpgradeRequestHandler: ChannelInboundHandler, RemovableChannelHandler, @unchecked Sendable {
|
||||
typealias InboundIn = HTTPClientResponsePart
|
||||
typealias OutboundOut = HTTPClientRequestPart
|
||||
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let path: String
|
||||
|
||||
init(host: String, port: Int, path: String) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.path = path
|
||||
}
|
||||
|
||||
func channelActive(context: ChannelHandlerContext) {
|
||||
var headers = HTTPHeaders()
|
||||
headers.add(name: "Host", value: "\(host):\(port)")
|
||||
headers.add(name: "Content-Length", value: "0")
|
||||
let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: path, headers: headers)
|
||||
context.write(wrapOutboundOut(.head(head)), promise: nil)
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
|
||||
context.fireChannelActive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbound WS frames -> driver actor.
|
||||
final class RedClientWSHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = WebSocketFrame
|
||||
|
||||
private let driver: RedDriver
|
||||
|
||||
init(driver: RedDriver) {
|
||||
self.driver = driver
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
let frame = unwrapInboundIn(data)
|
||||
switch frame.opcode {
|
||||
case .text:
|
||||
var buf = frame.unmaskedData
|
||||
guard let bytes = buf.readBytes(length: buf.readableBytes) else { return }
|
||||
let payload = Data(bytes)
|
||||
Task { await driver.handleInbound(payload) }
|
||||
case .connectionClose:
|
||||
context.close(promise: nil)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
Task { await driver.handleChannelClosed() }
|
||||
context.fireChannelInactive()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,26 +42,6 @@ public struct Lut3D: Sendable {
|
|||
return SIMD3(table[i], table[i + 1], table[i + 2])
|
||||
}
|
||||
|
||||
/// True if lattice ≈ identity within tolerance (sampled grid corners).
|
||||
/// Used to detect "nothing left after CDL split" flatten results.
|
||||
public func isApproximatelyIdentity(tolerance: Float = 1.0 / 512) -> Bool {
|
||||
let denom = Float(size - 1)
|
||||
let step = max(1, size / 4)
|
||||
for b in stride(from: 0, to: size, by: step) {
|
||||
for g in stride(from: 0, to: size, by: step) {
|
||||
for r in stride(from: 0, to: size, by: step) {
|
||||
let idx = ((b * size + g) * size + r) * 3
|
||||
if abs(table[idx] - Float(r) / denom) > tolerance ||
|
||||
abs(table[idx + 1] - Float(g) / denom) > tolerance ||
|
||||
abs(table[idx + 2] - Float(b) / denom) > tolerance {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Trilinear interpolation; input clamped to [0,1].
|
||||
public func sample(_ rgb: SIMD3<Float>) -> SIMD3<Float> {
|
||||
let maxIdx = Float(size - 1)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
/// ASC Media Hash List v2.0 (urn:ASC:MHL:v2.0) manifest.
|
||||
/// Structure per official XSD (github.com/ascmitc/mhl, xsd/ASCMHL.xsd):
|
||||
/// hashlist > creatorinfo (creationdate, hostname, tool) + processinfo (process)
|
||||
/// + hashes > hash > path[@size] + <xxh64 action hashdate>.
|
||||
/// Findings documented in docs/research/red-rcp2-protocol.md (MHL section).
|
||||
/// Media Hash List (ASC MHL-style) manifest: per-file path/size/xxh64.
|
||||
/// Structure follows MHL v2 hashlist shape; full ASC MHL chain/directory
|
||||
/// hashes can layer on later without breaking this format.
|
||||
public enum MHL {
|
||||
|
||||
public struct Failure: Equatable, Sendable {
|
||||
|
|
@ -35,35 +33,17 @@ public enum MHL {
|
|||
.replacingOccurrences(of: "&", with: "&")
|
||||
}
|
||||
|
||||
private static func isoNow() -> String {
|
||||
ISO8601DateFormatter().string(from: Date())
|
||||
}
|
||||
|
||||
private static func hostname() -> String {
|
||||
ProcessInfo.processInfo.hostName
|
||||
}
|
||||
|
||||
/// Build ASC MHL v2.0 manifest XML for files (relative paths) under root.
|
||||
/// process: "transfer" for offloads, "in-place" for verification-only seals.
|
||||
public static func generate(
|
||||
root: String,
|
||||
creator: String,
|
||||
files: [String],
|
||||
process: String = "transfer"
|
||||
) throws -> String {
|
||||
/// Build manifest XML for files (relative paths) under root.
|
||||
public static func generate(root: String, creator: String, files: [String]) throws -> String {
|
||||
let fm = FileManager.default
|
||||
let now = isoNow()
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
var out = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hashlist version="2.0" xmlns="urn:ASC:MHL:v2.0">
|
||||
<hashlist version="2.0">
|
||||
<creatorinfo>
|
||||
<creationdate>\(now)</creationdate>
|
||||
<hostname>\(escape(hostname()))</hostname>
|
||||
<tool version="0.1">\(escape(creator))</tool>
|
||||
<creationdate>\(dateFormatter.string(from: Date()))</creationdate>
|
||||
<tool>\(escape(creator))</tool>
|
||||
</creatorinfo>
|
||||
<processinfo>
|
||||
<process>\(process)</process>
|
||||
</processinfo>
|
||||
<hashes>
|
||||
|
||||
"""
|
||||
|
|
@ -76,8 +56,9 @@ public enum MHL {
|
|||
let hash = try FileHasher.xxh64(path: full)
|
||||
out += """
|
||||
<hash>
|
||||
<path size="\(size)">\(escape(rel))</path>
|
||||
<xxh64 action="original" hashdate="\(now)">\(hash)</xxh64>
|
||||
<path>\(escape(rel))</path>
|
||||
<size>\(size)</size>
|
||||
<xxh64>\(hash)</xxh64>
|
||||
</hash>
|
||||
|
||||
"""
|
||||
|
|
@ -87,14 +68,8 @@ public enum MHL {
|
|||
}
|
||||
|
||||
/// Write manifest to file.
|
||||
public static func write(
|
||||
root: String,
|
||||
creator: String,
|
||||
files: [String],
|
||||
to path: String,
|
||||
process: String = "transfer"
|
||||
) throws {
|
||||
let xml = try generate(root: root, creator: creator, files: files, process: process)
|
||||
public static func write(root: String, creator: String, files: [String], to path: String) throws {
|
||||
let xml = try generate(root: root, creator: creator, files: files)
|
||||
try xml.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
|
|
@ -109,12 +84,10 @@ public enum MHL {
|
|||
failures.append(Failure(path: e.path, reason: .missing))
|
||||
continue
|
||||
}
|
||||
if let expectedSize = e.size {
|
||||
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
|
||||
if size != expectedSize {
|
||||
failures.append(Failure(path: e.path, reason: .sizeMismatch))
|
||||
continue
|
||||
}
|
||||
let size = (try fm.attributesOfItem(atPath: full)[.size] as? UInt64) ?? 0
|
||||
if size != e.size {
|
||||
failures.append(Failure(path: e.path, reason: .sizeMismatch))
|
||||
continue
|
||||
}
|
||||
let hash = try FileHasher.xxh64(path: full)
|
||||
if hash != e.xxh64 {
|
||||
|
|
@ -126,12 +99,11 @@ public enum MHL {
|
|||
|
||||
struct Entry {
|
||||
let path: String
|
||||
let size: UInt64?
|
||||
let size: UInt64
|
||||
let xxh64: String
|
||||
}
|
||||
|
||||
/// Scanner-based parse of <hash> blocks. Accepts v2.0 attribute-style size
|
||||
/// and (legacy, our v1) element-style <size>.
|
||||
/// Scanner-based parse of <hash> blocks.
|
||||
static func parse(_ xml: String) throws -> [Entry] {
|
||||
guard xml.contains("<hashlist") else {
|
||||
throw OffloadError.readFailed("not an MHL manifest")
|
||||
|
|
@ -141,37 +113,18 @@ public enum MHL {
|
|||
while let blockStart = xml.range(of: "<hash>", range: searchRange),
|
||||
let blockEnd = xml.range(of: "</hash>", range: blockStart.upperBound..<xml.endIndex) {
|
||||
let block = String(xml[blockStart.upperBound..<blockEnd.lowerBound])
|
||||
|
||||
// Path element with optional attributes: <path size="N" ...>rel</path>
|
||||
guard let pathOpen = block.range(of: "<path"),
|
||||
let pathTagEnd = block.range(of: ">", range: pathOpen.upperBound..<block.endIndex),
|
||||
let pathClose = block.range(of: "</path>", range: pathTagEnd.upperBound..<block.endIndex) else {
|
||||
throw OffloadError.readFailed("malformed <hash> entry: no path")
|
||||
func field(_ tag: String) -> String? {
|
||||
guard let open = block.range(of: "<\(tag)>"),
|
||||
let close = block.range(of: "</\(tag)>"),
|
||||
open.upperBound <= close.lowerBound else { return nil }
|
||||
return String(block[open.upperBound..<close.lowerBound])
|
||||
}
|
||||
let pathAttrs = String(block[pathOpen.upperBound..<pathTagEnd.lowerBound])
|
||||
let path = unescape(String(block[pathTagEnd.upperBound..<pathClose.lowerBound]))
|
||||
|
||||
// size attribute (v2.0) or legacy <size> element.
|
||||
var size: UInt64?
|
||||
if let m = pathAttrs.range(of: "size=\"") {
|
||||
let rest = pathAttrs[m.upperBound...]
|
||||
if let q = rest.firstIndex(of: "\"") {
|
||||
size = UInt64(rest[..<q])
|
||||
}
|
||||
} else if let sizeOpen = block.range(of: "<size>"),
|
||||
let sizeClose = block.range(of: "</size>") {
|
||||
size = UInt64(block[sizeOpen.upperBound..<sizeClose.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
guard let path = field("path"),
|
||||
let sizeStr = field("size"), let size = UInt64(sizeStr),
|
||||
let hash = field("xxh64") else {
|
||||
throw OffloadError.readFailed("malformed <hash> entry")
|
||||
}
|
||||
|
||||
// xxh64 element with optional attributes.
|
||||
guard let hOpen = block.range(of: "<xxh64"),
|
||||
let hTagEnd = block.range(of: ">", range: hOpen.upperBound..<block.endIndex),
|
||||
let hClose = block.range(of: "</xxh64>", range: hTagEnd.upperBound..<block.endIndex) else {
|
||||
throw OffloadError.readFailed("malformed <hash> entry: no xxh64")
|
||||
}
|
||||
let hash = String(block[hTagEnd.upperBound..<hClose.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
entries.append(Entry(path: path, size: size, xxh64: hash))
|
||||
entries.append(Entry(path: unescape(path), size: size, xxh64: hash))
|
||||
searchRange = blockEnd.upperBound..<xml.endIndex
|
||||
}
|
||||
guard !entries.isEmpty else {
|
||||
|
|
|
|||
|
|
@ -1,150 +1,85 @@
|
|||
import Foundation
|
||||
import NIO
|
||||
import NIOHTTP1
|
||||
import NIOWebSocket
|
||||
|
||||
/// RCP2-faithful RED camera simulator.
|
||||
/// Wire format verified against real Komodo clients (docs/research/red-rcp2-protocol.md):
|
||||
/// - WebSocket server at /rcp (real camera: port 9998)
|
||||
/// - Text frames, one JSON object each
|
||||
/// - Handshake: client sends {"type":"rcp_config",...}, sim replies ack
|
||||
/// - {"type":"rcp_get","id":P} -> {"id":P,"cur":{"val":...}}
|
||||
/// - {"type":"rcp_set","id":P,"value":V} -> stores, replies current value
|
||||
/// - pushNotification() sends unsolicited {"id":P,"cur":{"val":...}} to all clients
|
||||
/// RCP2-style TCP simulator: 4-byte big-endian length prefix + JSON message.
|
||||
/// Message types: handshake, get {param}, set {param,value}, lut {size,table}.
|
||||
/// Replies mirror type with {ok:true} or value payloads.
|
||||
/// NOTE: real RCP2 framing/params verified against RED SDK in hardware phase;
|
||||
/// this encodes the documented shape (length-prefixed JSON over TCP).
|
||||
public actor RedSimulator {
|
||||
|
||||
/// Param values are ints, doubles, or strings on the wire.
|
||||
public enum ParamValue: Sendable, Equatable {
|
||||
case int(Int)
|
||||
case double(Double)
|
||||
case string(String)
|
||||
|
||||
var jsonValue: Any {
|
||||
switch self {
|
||||
case .int(let i): return i
|
||||
case .double(let d): return d
|
||||
case .string(let s): return s
|
||||
}
|
||||
}
|
||||
|
||||
static func from(_ any: Any) -> ParamValue? {
|
||||
if let i = any as? Int { return .int(i) }
|
||||
if let d = any as? Double { return .double(d) }
|
||||
if let s = any as? String { return .string(s) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var group: MultiThreadedEventLoopGroup?
|
||||
private var channel: Channel?
|
||||
public private(set) var boundPort: Int?
|
||||
|
||||
public private(set) var params: [String: ParamValue] = [
|
||||
"RECORD_STATE": .int(0),
|
||||
"CLIP_NAME": .string(""),
|
||||
"ISO": .int(800),
|
||||
"COLOR_TEMPERATURE": .int(5600),
|
||||
"TINT": .int(0),
|
||||
"SENSOR_FRAME_RATE": .string("24"),
|
||||
"TIMECODE": .string("00:00:00:00"),
|
||||
"CDL_ENABLE": .int(0),
|
||||
"CAMERA_TYPE": .string("V-RAPTOR-SIM"),
|
||||
public private(set) var handshakeCount = 0
|
||||
public private(set) var params: [String: String] = [
|
||||
"RECORD_STATE": "0",
|
||||
"CLIP_NAME": "",
|
||||
"ISO": "800",
|
||||
"COLOR_TEMP": "5600",
|
||||
"TINT": "0",
|
||||
"SENSOR_FPS": "24",
|
||||
"TIMECODE": "00:00:00:00",
|
||||
"MODEL": "V-RAPTOR-SIM",
|
||||
]
|
||||
/// Raw rcp_config messages received (handshake verification; decode in consumers).
|
||||
public private(set) var receivedConfigsData: [Data] = []
|
||||
|
||||
private var clients: [ObjectIdentifier: Channel] = [:]
|
||||
public struct UploadedLut: Sendable {
|
||||
public let size: Int
|
||||
public let entryCount: Int
|
||||
}
|
||||
public private(set) var uploadedLuts: [UploadedLut] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func setParam(_ key: String, value: ParamValue) {
|
||||
public func setParam(_ key: String, value: String) {
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
/// Send unsolicited notification to all connected clients (camera-initiated update).
|
||||
public func pushNotification(param: String, value: ParamValue) {
|
||||
params[param] = value
|
||||
let payload: [String: Any] = ["id": param, "type": "rcp_cur_int", "cur": ["val": value.jsonValue]]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { return }
|
||||
for ch in clients.values {
|
||||
Self.sendText(data, over: ch)
|
||||
/// Data-in/Data-out across the actor boundary (JSON dicts aren't Sendable).
|
||||
func handleFrame(_ payload: Data) -> Data {
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else {
|
||||
return (try? JSONSerialization.data(withJSONObject: ["ok": false, "error": "bad JSON"])) ?? Data()
|
||||
}
|
||||
let reply = handle(message: obj)
|
||||
return (try? JSONSerialization.data(withJSONObject: reply, options: [.sortedKeys])) ?? Data()
|
||||
}
|
||||
|
||||
// MARK: Frame handling
|
||||
|
||||
func clientConnected(_ ch: Channel) {
|
||||
clients[ObjectIdentifier(ch)] = ch
|
||||
}
|
||||
|
||||
func clientDisconnected(_ ch: Channel) {
|
||||
clients.removeValue(forKey: ObjectIdentifier(ch))
|
||||
}
|
||||
|
||||
func handleText(_ data: Data, from ch: Channel) {
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = obj["type"] as? String else { return }
|
||||
|
||||
switch type {
|
||||
case "rcp_config":
|
||||
receivedConfigsData.append(data)
|
||||
reply(["type": "rcp_config_ack", "ok": true], to: ch)
|
||||
case "rcp_get":
|
||||
guard let id = obj["id"] as? String else { return }
|
||||
if let value = params[id] {
|
||||
reply(["id": id, "type": "rcp_cur_int", "cur": ["val": value.jsonValue]], to: ch)
|
||||
} else {
|
||||
reply(["id": id, "type": "rcp_error", "error": "unknown param"], to: ch)
|
||||
func handle(message: [String: Any]) -> [String: Any] {
|
||||
switch message["type"] as? String {
|
||||
case "handshake":
|
||||
handshakeCount += 1
|
||||
return ["type": "handshake", "ok": true, "model": params["MODEL"] ?? ""]
|
||||
case "get":
|
||||
guard let param = message["param"] as? String, let value = params[param] else {
|
||||
return ["type": "get", "ok": false, "error": "unknown param"]
|
||||
}
|
||||
case "rcp_set":
|
||||
guard let id = obj["id"] as? String,
|
||||
let raw = obj["value"], let value = ParamValue.from(raw) else { return }
|
||||
params[id] = value
|
||||
reply(["id": id, "type": "rcp_cur_int", "cur": ["val": value.jsonValue]], to: ch)
|
||||
return ["type": "get", "ok": true, "param": param, "value": value]
|
||||
case "set":
|
||||
guard let param = message["param"] as? String, let value = message["value"] as? String else {
|
||||
return ["type": "set", "ok": false, "error": "bad set"]
|
||||
}
|
||||
params[param] = value
|
||||
return ["type": "set", "ok": true, "param": param]
|
||||
case "lut":
|
||||
guard let size = message["size"] as? Int,
|
||||
let table = message["table"] as? [Any],
|
||||
table.count == size * size * size * 3 else {
|
||||
return ["type": "lut", "ok": false, "error": "bad lut"]
|
||||
}
|
||||
uploadedLuts.append(UploadedLut(size: size, entryCount: table.count))
|
||||
return ["type": "lut", "ok": true]
|
||||
default:
|
||||
reply(["type": "rcp_error", "error": "unsupported type \(type)"], to: ch)
|
||||
return ["ok": false, "error": "unknown type"]
|
||||
}
|
||||
}
|
||||
|
||||
private func reply(_ obj: [String: Any], to ch: Channel) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]) else { return }
|
||||
Self.sendText(data, over: ch)
|
||||
}
|
||||
|
||||
private static func sendText(_ data: Data, over ch: Channel) {
|
||||
var buf = ch.allocator.buffer(capacity: data.count)
|
||||
buf.writeBytes(data)
|
||||
let frame = WebSocketFrame(fin: true, opcode: .text, data: buf)
|
||||
ch.writeAndFlush(NIOAny(frame), promise: nil)
|
||||
}
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
public func start(port: Int) async throws {
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
self.group = group
|
||||
let sim = self
|
||||
|
||||
let upgrader = NIOWebSocketServerUpgrader(
|
||||
shouldUpgrade: { channel, head in
|
||||
// Real camera serves /rcp.
|
||||
guard head.uri == "/rcp" else {
|
||||
return channel.eventLoop.makeSucceededFuture(nil)
|
||||
}
|
||||
return channel.eventLoop.makeSucceededFuture(HTTPHeaders())
|
||||
},
|
||||
upgradePipelineHandler: { channel, _ in
|
||||
Task { await sim.clientConnected(channel) }
|
||||
return channel.pipeline.addHandler(RedWSHandler(sim: sim))
|
||||
})
|
||||
|
||||
let bootstrap = ServerBootstrap(group: group)
|
||||
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelInitializer { channel in
|
||||
let config = NIOHTTPServerUpgradeConfiguration(
|
||||
upgraders: [upgrader],
|
||||
completionHandler: { _ in })
|
||||
return channel.pipeline.configureHTTPServerPipeline(withServerUpgrade: config)
|
||||
channel.pipeline.addHandler(RCPFrameHandler(sim: sim))
|
||||
}
|
||||
let channel = try await bootstrap.bind(host: "127.0.0.1", port: port).get()
|
||||
self.channel = channel
|
||||
|
|
@ -152,10 +87,6 @@ public actor RedSimulator {
|
|||
}
|
||||
|
||||
public func stop() async throws {
|
||||
for ch in clients.values {
|
||||
_ = try? await ch.close()
|
||||
}
|
||||
clients.removeAll()
|
||||
try await channel?.close()
|
||||
try await group?.shutdownGracefully()
|
||||
channel = nil
|
||||
|
|
@ -164,43 +95,41 @@ public actor RedSimulator {
|
|||
}
|
||||
}
|
||||
|
||||
/// Inbound WebSocket frames -> sim actor.
|
||||
final class RedWSHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = WebSocketFrame
|
||||
typealias OutboundOut = WebSocketFrame
|
||||
/// Length-prefixed JSON framing: u32 BE length + payload.
|
||||
final class RCPFrameHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = ByteBuffer
|
||||
typealias OutboundOut = ByteBuffer
|
||||
|
||||
private let sim: RedSimulator
|
||||
private var buffer = ByteBuffer()
|
||||
|
||||
init(sim: RedSimulator) {
|
||||
self.sim = sim
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
let frame = unwrapInboundIn(data)
|
||||
switch frame.opcode {
|
||||
case .text:
|
||||
var buf = frame.unmaskedData
|
||||
guard let bytes = buf.readBytes(length: buf.readableBytes) else { return }
|
||||
var incoming = unwrapInboundIn(data)
|
||||
buffer.writeBuffer(&incoming)
|
||||
|
||||
while true {
|
||||
guard buffer.readableBytes >= 4,
|
||||
let length = buffer.getInteger(at: buffer.readerIndex, as: UInt32.self),
|
||||
buffer.readableBytes >= 4 + Int(length) else { break }
|
||||
buffer.moveReaderIndex(forwardBy: 4)
|
||||
guard let bytes = buffer.readBytes(length: Int(length)) else { break }
|
||||
let payload = Data(bytes)
|
||||
let ch = context.channel
|
||||
Task { await sim.handleText(payload, from: ch) }
|
||||
case .connectionClose:
|
||||
let ch = context.channel
|
||||
Task { await sim.clientDisconnected(ch) }
|
||||
context.close(promise: nil)
|
||||
case .ping:
|
||||
var buf = frame.unmaskedData
|
||||
let pong = WebSocketFrame(fin: true, opcode: .pong, data: buf)
|
||||
context.writeAndFlush(wrapOutboundOut(pong), promise: nil)
|
||||
_ = buf
|
||||
default:
|
||||
break
|
||||
|
||||
let channel = context.channel
|
||||
let loop = context.eventLoop
|
||||
Task {
|
||||
let replyData = await self.sim.handleFrame(payload)
|
||||
loop.execute {
|
||||
var out = channel.allocator.buffer(capacity: replyData.count + 4)
|
||||
out.writeInteger(UInt32(replyData.count))
|
||||
out.writeBytes(replyData)
|
||||
channel.writeAndFlush(out, promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
let ch = context.channel
|
||||
Task { await sim.clientDisconnected(ch) }
|
||||
context.fireChannelInactive()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ import ForgeColor
|
|||
import ForgeSim
|
||||
@testable import ForgeCameraRED
|
||||
|
||||
/// Tests against verified RCP2 wire protocol:
|
||||
/// WebSocket ws://<ip>:9998/rcp, rcp_config handshake, rcp_get/rcp_set,
|
||||
/// responses {"id":..., "cur":{"val":...}}, per-channel CDL params,
|
||||
/// no live LUT upload (docs/research/red-rcp2-protocol.md).
|
||||
final class RedDriverTests: XCTestCase {
|
||||
|
||||
var sim: RedSimulator!
|
||||
|
|
@ -29,16 +25,16 @@ final class RedDriverTests: XCTestCase {
|
|||
return RedDriver(host: "127.0.0.1", port: port, pollInterval: pollInterval)
|
||||
}
|
||||
|
||||
// RED: native CDL yes, live LUT upload NO (RCP2 has no LUT-push op).
|
||||
// Capabilities: RED = native CDL (IPP2), 17/33 LUTs.
|
||||
func testCapabilities() async {
|
||||
let driver = await makeDriver()
|
||||
XCTAssertEqual(driver.capabilities.vendor, .red)
|
||||
XCTAssertTrue(driver.capabilities.supportsNativeCDL)
|
||||
XCTAssertTrue(driver.capabilities.lut3dSizes.isEmpty)
|
||||
XCTAssertTrue(driver.capabilities.lut3dSizes.contains(33))
|
||||
}
|
||||
|
||||
// Connect: WS upgrade on /rcp + rcp_config handshake with client name.
|
||||
func testConnectSendsRcpConfig() async throws {
|
||||
// Connect performs RCP-style handshake, emits connected.
|
||||
func testConnectHandshake() async throws {
|
||||
let driver = await makeDriver()
|
||||
var iterator = driver.state.makeAsyncIterator()
|
||||
try await driver.connect()
|
||||
|
|
@ -50,22 +46,8 @@ final class RedDriverTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
// Config delivery is async post-upgrade; allow brief settle.
|
||||
var configsData: [Data] = []
|
||||
for _ in 0..<20 {
|
||||
configsData = await sim.receivedConfigsData
|
||||
if !configsData.isEmpty { break }
|
||||
try await Task.sleep(for: .milliseconds(25))
|
||||
}
|
||||
XCTAssertEqual(configsData.count, 1)
|
||||
guard let first = configsData.first else {
|
||||
await driver.disconnect()
|
||||
return
|
||||
}
|
||||
let config = try JSONSerialization.jsonObject(with: first) as? [String: Any]
|
||||
XCTAssertEqual(config?["type"] as? String, "rcp_config")
|
||||
let client = config?["client"] as? [String: Any]
|
||||
XCTAssertEqual(client?["name"] as? String, "Forge")
|
||||
let handshakes = await sim.handshakeCount
|
||||
XCTAssertEqual(handshakes, 1)
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
|
|
@ -77,25 +59,25 @@ final class RedDriverTests: XCTestCase {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// Rec state: RECORD_STATE cur.val 1 -> recording, clip name via CLIP_NAME.
|
||||
// Rec state + clip name via param polling.
|
||||
func testRecStateEvents() async throws {
|
||||
let driver = await makeDriver()
|
||||
var iterator = driver.state.makeAsyncIterator()
|
||||
try await driver.connect()
|
||||
|
||||
await sim.setParam("RECORD_STATE", value: .int(1))
|
||||
await sim.setParam("CLIP_NAME", value: .string("B001_C001_0710AB"))
|
||||
// Params arrive one frame at a time — wait for a state with BOTH rec + clip.
|
||||
await sim.setParam("RECORD_STATE", value: "1")
|
||||
await sim.setParam("CLIP_NAME", value: "B001_C001_0710AB")
|
||||
var sawRec = false
|
||||
for _ in 0..<60 {
|
||||
if let s = await iterator.next(), s.isRecording, s.clipName == "B001_C001_0710AB" {
|
||||
for _ in 0..<30 {
|
||||
if let s = await iterator.next(), s.isRecording {
|
||||
XCTAssertEqual(s.clipName, "B001_C001_0710AB")
|
||||
sawRec = true
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(sawRec)
|
||||
|
||||
await sim.setParam("RECORD_STATE", value: .int(0))
|
||||
await sim.setParam("RECORD_STATE", value: "0")
|
||||
var sawStop = false
|
||||
for _ in 0..<30 {
|
||||
if let s = await iterator.next(), !s.isRecording {
|
||||
|
|
@ -107,25 +89,24 @@ final class RedDriverTests: XCTestCase {
|
|||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// Metadata via real param names: ISO, COLOR_TEMPERATURE, TINT, SENSOR_FRAME_RATE, TIMECODE.
|
||||
// Metadata params surface.
|
||||
func testMetadata() async throws {
|
||||
await sim.setParam("ISO", value: .int(1600))
|
||||
await sim.setParam("COLOR_TEMPERATURE", value: .int(4500))
|
||||
await sim.setParam("TINT", value: .int(3))
|
||||
await sim.setParam("SENSOR_FRAME_RATE", value: .string("23.98"))
|
||||
await sim.setParam("TIMECODE", value: .string("15:30:00:00"))
|
||||
await sim.setParam("ISO", value: "1600")
|
||||
await sim.setParam("COLOR_TEMP", value: "4500")
|
||||
await sim.setParam("TINT", value: "3")
|
||||
await sim.setParam("SENSOR_FPS", value: "23.98")
|
||||
await sim.setParam("TIMECODE", value: "15:30:00:00")
|
||||
|
||||
let driver = await makeDriver()
|
||||
var iterator = driver.state.makeAsyncIterator()
|
||||
try await driver.connect()
|
||||
|
||||
// Wait for a state where ALL polled params have landed.
|
||||
var got = false
|
||||
for _ in 0..<60 {
|
||||
if let s = await iterator.next(), let md = s.metadata,
|
||||
md.exposureIndex == 1600, md.whiteBalance == 4500,
|
||||
md.tint == 3, md.timecode == "15:30:00:00",
|
||||
let fps = md.fps, abs(fps - 23.98) < 0.001 {
|
||||
for _ in 0..<30 {
|
||||
if let s = await iterator.next(), let md = s.metadata, md.exposureIndex == 1600 {
|
||||
XCTAssertEqual(md.whiteBalance, 4500)
|
||||
XCTAssertEqual(md.tint, 3)
|
||||
XCTAssertEqual(md.timecode, "15:30:00:00")
|
||||
got = true
|
||||
break
|
||||
}
|
||||
|
|
@ -134,99 +115,37 @@ final class RedDriverTests: XCTestCase {
|
|||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// CDL push: 10 rcp_set ops (9 SOP channels + saturation) + CDL_ENABLE.
|
||||
func testPushCDLSetsPerChannelParams() async throws {
|
||||
// Push: CDL to IPP2 slots + LUT upload.
|
||||
func testPushLookWithNativeCDL() async throws {
|
||||
let driver = await makeDriver()
|
||||
try await driver.connect()
|
||||
|
||||
let cdl = CDL(
|
||||
slope: SIMD3(1.1, 1.0, 0.9),
|
||||
offset: SIMD3(0.02, 0.0, -0.01),
|
||||
power: SIMD3(0.95, 1.0, 1.05),
|
||||
saturation: 1.3)
|
||||
try await driver.push(look: FlattenedLook(cdl: cdl, lut: nil, latticeSize: 33))
|
||||
let cdl = CDL(slope: SIMD3(1.1, 1.0, 0.9), offset: SIMD3(0.02, 0, 0), power: .one, saturation: 1.3)
|
||||
let look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 33), latticeSize: 33)
|
||||
try await driver.push(look: look)
|
||||
|
||||
// rcp_set frames are fire-and-forget; wait for sim to process all 11.
|
||||
var params: [String: RedSimulator.ParamValue] = [:]
|
||||
for _ in 0..<40 {
|
||||
params = await sim.params
|
||||
if params["CDL_SATURATION"] != nil, case .int(1)? = params["CDL_ENABLE"] { break }
|
||||
try await Task.sleep(for: .milliseconds(25))
|
||||
}
|
||||
func f(_ key: String) -> Float? {
|
||||
if case .double(let d)? = params[key] { return Float(d) }
|
||||
if case .int(let i)? = params[key] { return Float(i) }
|
||||
return nil
|
||||
}
|
||||
XCTAssertEqual(f("CDL_SLOPE_RED") ?? 0, 1.1, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_SLOPE_GREEN") ?? 0, 1.0, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_SLOPE_BLUE") ?? 0, 0.9, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_OFFSET_RED") ?? 0, 0.02, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_OFFSET_BLUE") ?? 0, -0.01, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_POWER_RED") ?? 0, 0.95, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_POWER_BLUE") ?? 0, 1.05, accuracy: 1e-4)
|
||||
XCTAssertEqual(f("CDL_SATURATION") ?? 0, 1.3, accuracy: 1e-4)
|
||||
if case .int(let enabled)? = params["CDL_ENABLE"] {
|
||||
XCTAssertEqual(enabled, 1)
|
||||
} else {
|
||||
XCTFail("CDL_ENABLE not set")
|
||||
}
|
||||
let cdlValue = await sim.params["IPP2_CDL"]
|
||||
XCTAssertNotNil(cdlValue)
|
||||
let obj = try JSONSerialization.jsonObject(with: Data(cdlValue!.utf8)) as? [String: Any]
|
||||
let slope = obj?["slope"] as? [Double]
|
||||
XCTAssertEqual(slope?[0] ?? 0, 1.1, accuracy: 1e-5)
|
||||
|
||||
let luts = await sim.uploadedLuts
|
||||
XCTAssertEqual(luts.count, 1)
|
||||
XCTAssertEqual(luts[0].size, 33)
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// LUT in look -> rejected: RCP2 cannot upload LUTs live.
|
||||
func testPushWithLutRejected() async throws {
|
||||
// Unsupported LUT size rejected.
|
||||
func testUnsupportedLutRejected() async throws {
|
||||
let driver = await makeDriver()
|
||||
try await driver.connect()
|
||||
let look = FlattenedLook(cdl: .identity, lut: Lut3D.build(size: 17) { $0 * 0.9 }, latticeSize: 17)
|
||||
do {
|
||||
try await driver.push(look: look)
|
||||
try await driver.push(look: FlattenedLook(cdl: nil, lut: Lut3D.identity(size: 65), latticeSize: 65))
|
||||
XCTFail("expected unsupportedLook")
|
||||
} catch let e as CameraError {
|
||||
guard case .unsupportedLook = e else { return XCTFail("wrong error") }
|
||||
}
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// Identity LUT in look tolerated (flattener artifact), CDL still pushed.
|
||||
func testPushWithIdentityLutAccepted() async throws {
|
||||
let driver = await makeDriver()
|
||||
try await driver.connect()
|
||||
let cdl = CDL(slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)
|
||||
let look = FlattenedLook(cdl: cdl, lut: Lut3D.identity(size: 17), latticeSize: 17)
|
||||
try await driver.push(look: look)
|
||||
var pushed = false
|
||||
for _ in 0..<40 {
|
||||
let params = await sim.params
|
||||
if case .double(let d)? = params["CDL_SLOPE_RED"], abs(Float(d) - 1.2) < 1e-4 {
|
||||
pushed = true
|
||||
break
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(25))
|
||||
}
|
||||
XCTAssertTrue(pushed, "CDL not pushed")
|
||||
await driver.disconnect()
|
||||
}
|
||||
|
||||
// Unsolicited camera notification (rec start w/o poll) surfaces in state stream.
|
||||
func testUnsolicitedNotification() async throws {
|
||||
let driver = await makeDriver(pollInterval: .seconds(10)) // poll effectively off
|
||||
var iterator = driver.state.makeAsyncIterator()
|
||||
try await driver.connect()
|
||||
// Drain the connected event.
|
||||
_ = await iterator.next()
|
||||
|
||||
await sim.pushNotification(param: "RECORD_STATE", value: .int(1))
|
||||
await sim.pushNotification(param: "CLIP_NAME", value: .string("B001_C009"))
|
||||
|
||||
var sawRec = false
|
||||
for _ in 0..<10 {
|
||||
if let s = await iterator.next(), s.isRecording {
|
||||
sawRec = true
|
||||
break
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(sawRec)
|
||||
await driver.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,42 +109,6 @@ final class GangTests: XCTestCase {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// CDL-only camera (RED/RCP2, lut3dSizes empty): CDL-only grade pushes as bare CDL.
|
||||
func testCDLOnlyCameraPushesCDLOnlyGrade() async throws {
|
||||
let red = RecordingDriver(nativeCDL: true, lutSizes: [])
|
||||
let gang = GradeGang(latticeSize: 33)
|
||||
// Grade = single CDL node — representable on a CDL-only camera.
|
||||
let grade = GradeStack(nodes: [
|
||||
GradeNode(kind: .cdl(CDL(slope: SIMD3(1.3, 1, 1), offset: .zero, power: .one, saturation: 1.1))),
|
||||
])
|
||||
await gang.setGrade(grade, for: "R")
|
||||
await gang.register(slot: "R", driver: red)
|
||||
try await gang.pushGrade(from: "R")
|
||||
|
||||
let pushes = await red.pushedLooks
|
||||
XCTAssertEqual(pushes.count, 1)
|
||||
XCTAssertNotNil(pushes[0].cdl)
|
||||
XCTAssertNil(pushes[0].lut)
|
||||
XCTAssertEqual(pushes[0].cdl?.slope.x ?? 0, 1.3, accuracy: 1e-5)
|
||||
}
|
||||
|
||||
// CDL-only camera + grade with non-CDL nodes: hard error (camera can't show it).
|
||||
func testCDLOnlyCameraRejectsComplexGrade() async throws {
|
||||
let red = RecordingDriver(nativeCDL: true, lutSizes: [])
|
||||
let gang = GradeGang(latticeSize: 33)
|
||||
await gang.setGrade(makeGrade(), for: "R") // CDL + saturation node
|
||||
await gang.register(slot: "R", driver: red)
|
||||
do {
|
||||
try await gang.pushGrade(from: "R")
|
||||
XCTFail("expected partial failure")
|
||||
} catch let e as GangError {
|
||||
guard case .partialFailure(let failures) = e else { return XCTFail("wrong error") }
|
||||
XCTAssertEqual(failures.count, 1)
|
||||
}
|
||||
let pushes = await red.pushedLooks
|
||||
XCTAssertTrue(pushes.isEmpty)
|
||||
}
|
||||
|
||||
// Push failure on one camera doesn't block others (best-effort multi-push).
|
||||
func testPushPartialFailure() async throws {
|
||||
let good = RecordingDriver(nativeCDL: true)
|
||||
|
|
|
|||
|
|
@ -18,23 +18,14 @@ final class MHLTests: XCTestCase {
|
|||
}
|
||||
|
||||
// MHL XML structure: hashlist root, creatorinfo, hash entries with file/size/xxh64.
|
||||
// ASC MHL v2.0 XSD shape (urn:ASC:MHL:v2.0):
|
||||
// hashlist > creatorinfo (creationdate, hostname, tool) + processinfo (process)
|
||||
// + hashes > hash > path[@size] + xxh64[@action][@hashdate].
|
||||
func testWriteStructure() throws {
|
||||
let xml = try MHL.generate(root: root, creator: "Forge 0.1", files: ["CLIPS/C001.mov", "CLIPS/C002.mov"])
|
||||
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\" xmlns=\"urn:ASC:MHL:v2.0\">"))
|
||||
XCTAssertTrue(xml.hasPrefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<hashlist version=\"2.0\">"))
|
||||
XCTAssertTrue(xml.contains("<creatorinfo>"))
|
||||
XCTAssertTrue(xml.contains("<creationdate>"))
|
||||
XCTAssertTrue(xml.contains("<hostname>"))
|
||||
XCTAssertTrue(xml.contains("<tool version=\"0.1\">Forge 0.1</tool>"))
|
||||
// processinfo required by XSD; offload = transfer.
|
||||
XCTAssertTrue(xml.contains("<processinfo>"))
|
||||
XCTAssertTrue(xml.contains("<process>transfer</process>"))
|
||||
// size is a path ATTRIBUTE per XSD, not an element.
|
||||
XCTAssertTrue(xml.contains("<path size=\"17\">CLIPS/C001.mov</path>"))
|
||||
XCTAssertTrue(xml.contains("<xxh64 action=\"original\" hashdate="))
|
||||
XCTAssertFalse(xml.contains("<size>"))
|
||||
XCTAssertTrue(xml.contains("<tool>Forge 0.1</tool>"))
|
||||
XCTAssertTrue(xml.contains("<path>CLIPS/C001.mov</path>"))
|
||||
XCTAssertTrue(xml.contains("<size>17</size>"))
|
||||
XCTAssertTrue(xml.contains("<xxh64>"))
|
||||
XCTAssertTrue(xml.hasSuffix("</hashlist>\n"))
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +76,7 @@ final class MHLTests: XCTestCase {
|
|||
func testPathEscaping() throws {
|
||||
try Data("x".utf8).write(to: URL(fileURLWithPath: root + "/a&b.mov"))
|
||||
let xml = try MHL.generate(root: root, creator: "Forge", files: ["a&b.mov"])
|
||||
XCTAssertTrue(xml.contains(">a&b.mov</path>"))
|
||||
XCTAssertTrue(xml.contains("<path>a&b.mov</path>"))
|
||||
let result = try MHL.verify(manifest: xml, root: root)
|
||||
XCTAssertTrue(result.passed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
# RED RCP2 Protocol — verified findings
|
||||
|
||||
Sources:
|
||||
- `kjayasa/commander` — `components/rcp/rcp.cpp`: full RCP2 parameter table (293 params w/ types + supported JSON ops), extracted from official RED docs (910-0327 protocol + per-camera parameter PDFs)
|
||||
- `intGus/komodo-remote` — `code.py`: working client against real Komodo, exact wire format
|
||||
|
||||
## Transport (corrects our v1 driver)
|
||||
|
||||
- **WebSocket**, `ws://<camera_ip>:9998/rcp` — NOT raw TCP, NOT length-prefixed
|
||||
- Text frames, one JSON object per frame
|
||||
- Camera can push unsolicited notifications (e.g. RECORD_STATE changes)
|
||||
|
||||
## Handshake
|
||||
|
||||
First message after connect:
|
||||
```json
|
||||
{"type":"rcp_config","strings_decoded":0,"json_minified":1,
|
||||
"include_cacheable_flags":0,"encoding_type":"html",
|
||||
"client":{"name":"Forge"}}
|
||||
```
|
||||
Camera replies once; then normal traffic.
|
||||
|
||||
## Messages
|
||||
|
||||
- Get: `{"type":"rcp_get","id":"RECORD_STATE"}`
|
||||
- Set: `{"type":"rcp_set","id":"RECORD_STATE","value":"2"}`
|
||||
- Param IDs = `RCP_PARAM_*` names **with `RCP_PARAM_` prefix stripped**
|
||||
- Response/notification: `{"id":"RECORD_STATE","cur":{"val":<int>}, ...}` — value under `cur.val`
|
||||
- Other ops exist: `rcp_get_list`, `rcp_get_status`, `rcp_notification`, `rcp_subscribe` (see rcp.cpp enum)
|
||||
|
||||
## Parameters we use
|
||||
|
||||
| Purpose | Param (wire id) | Type |
|
||||
|---|---|---|
|
||||
| Rec state | `RECORD_STATE` | value; 0=idle 1=recording 2=toggle-request |
|
||||
| Clip name | `CLIP_NAME` | status |
|
||||
| ISO/EI | `ISO` | value |
|
||||
| White balance | `COLOR_TEMPERATURE` | value |
|
||||
| Tint | `TINT` | value |
|
||||
| FPS | `SENSOR_FRAME_RATE` | value |
|
||||
| Timecode | `TIMECODE` | status |
|
||||
| CDL enable | `CDL_ENABLE` | value |
|
||||
| CDL slope | `CDL_SLOPE_RED/GREEN/BLUE` | value (per channel!) |
|
||||
| CDL offset | `CDL_OFFSET_RED/GREEN/BLUE` | value |
|
||||
| CDL power | `CDL_POWER_RED/GREEN/BLUE` | value |
|
||||
| CDL sat | `CDL_SATURATION` | value |
|
||||
| Active LUT name | `APPLIED_CAMERA_LUT` | status (read-only) |
|
||||
| Select camera LUT | `CAMERA_LUT` | value (selects from camera's stored list) |
|
||||
| Import LUT from media | `MEDIA_LUT_IMPORT_TO_CAMERA` | action |
|
||||
|
||||
## Capability corrections for our driver
|
||||
|
||||
1. **CDL is per-channel scalar params**, not a JSON blob. 10 sets per full CDL push (9 SOP + sat). CDL_ENABLE=1 required.
|
||||
2. **No live 3D LUT upload over RCP2.** `CAMERA_LUT` only selects LUTs already on camera; `MEDIA_LUT_IMPORT_*` pulls from inserted media. So RED live-grade path = **native CDL only**; creative LUT must be pre-loaded to camera or baked into monitor path elsewhere.
|
||||
→ capabilities: `supportsNativeCDL=true`, `lut3dSizes=[]`.
|
||||
3. Gang: for RED slots, only the leading-CDL portion of a node stack is pushable live. Remainder LUT must be identity, else surface a warning state (grade preview ≠ camera monitor).
|
||||
|
||||
# ASC MHL v2.0 — verified findings
|
||||
|
||||
Source: `ascmitc/mhl-specification` + reference impl `ascmitc/mhl` (XSD fetched, stored below).
|
||||
|
||||
Corrections to our v1 MHL writer:
|
||||
- Root: `<hashlist version="2.0" xmlns="urn:ASC:MHL:v2.0">`
|
||||
- Required: `<creatorinfo>` (creationdate, hostname, tool) AND `<processinfo>` (process ∈ in-place|transfer|flatten)
|
||||
- Hash entries: `<hash><path size="..." lastmodificationdate="...">rel/path</path><xxh64 action="original|verified|failed" hashdate="...">hex</xxh64></hash>`
|
||||
— size is an ATTRIBUTE of path, hash algo is element name, action attribute tracks chain state
|
||||
- Folder convention: manifests live in `ascmhl/` subfolder, named `NNNN_<foldername>_<date>.mhl`, plus `ascmhl_chain.xml`
|
||||
- Directory hashes optional (content + structure)
|
||||
|
||||
XSD: https://raw.githubusercontent.com/ascmitc/mhl/master/xsd/ASCMHL.xsd
|
||||
|
||||
# Other useful repos found
|
||||
|
||||
- `ascmitc/mhl` — Python reference impl, good for cross-validation of our manifests
|
||||
- Tangent Hub: no public TIPC repos of note; Tangent's official "Tangent Hub" SDK ships the mapping XML + TIPC docs with the installer (macOS phase task)
|
||||
Loading…
Reference in a new issue