Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
43b684c
Accept explicit RGB exposure authority across bridge scan calls
rohanpandula Sep 8, 2026
796f194
Add gated asynchronous held-session exposure metering
rohanpandula Sep 8, 2026
8e204f3
Add sequential scan repeats, pass labels, and receipt checks
rohanpandula Sep 8, 2026
88bcb16
Persist roll exposure locks and add verified calibration collection
rohanpandula Sep 8, 2026
9091815
Forward explicitly allowed meter refusal skips through the bridge
rohanpandula Sep 8, 2026
cac076c
Persist explicit meter-refusal skips and retain calibration exception…
rohanpandula Sep 8, 2026
46eabde
Support CLI calibration setup and durable observable control sessions
rohanpandula Sep 8, 2026
5a32ff7
Keep the documentation negative fixture outside supported exit codes
rohanpandula Sep 8, 2026
0ce4572
Vendor published CoolscanPy 0.7.9 for held exposure calibration
rohanpandula Sep 8, 2026
234ee85
Add CLI session evidence, preflight, waits and shared presets
rohanpandula Sep 8, 2026
64e6eb0
fix(cli): report host loss before scan acknowledgement
rohanpandula Sep 8, 2026
65a49bf
feat(cli): report retained rolls and manage headless service plists
rohanpandula Sep 8, 2026
b986335
feat(cli): validate declarative jobs and report read-only diagnostics
rohanpandula Sep 8, 2026
132e4bb
feat(cli): recover observers and bound automation hooks
rohanpandula Sep 8, 2026
29d2fb9
test(driver): align vendored batch expectations with schema 4
rohanpandula Sep 8, 2026
75f02a0
feat(workflow): render and tag verified retained-output copies
rohanpandula Sep 8, 2026
db2b2c6
docs(cli): use output destinations for automatic frame handoff
rohanpandula Sep 8, 2026
c6c549b
fix(hardware): preserve film status during held scanner sessions
rohanpandula Sep 8, 2026
8c7a693
fix(build): defer incomplete preview registration field wiring
rohanpandula Sep 8, 2026
6ced6ae
fix: reuse driver registration and provide CI metadata tool
rohanpandula Sep 8, 2026
8676a15
feat(cli): observe physical film changes and run numbered hopper rolls
rohanpandula Sep 8, 2026
1b6ec55
docs(cli): document hopper and simulation flags
rohanpandula Sep 8, 2026
5ce794b
fix(bridge): preserve held scan rounds and bind exposure evidence
rohanpandula Sep 8, 2026
31c72ed
feat(cli): add bounded preview exposure and repair held repeat capture
rohanpandula Sep 8, 2026
95a68dc
fix(cli): resolve destination aliases in scan preflight
rohanpandula Sep 8, 2026
043c6a5
test(driver): bind held-round fixtures to session-specific paths
rohanpandula Sep 8, 2026
8eee515
release: prepare beta.18 with published CoolscanPy 0.7.10
rohanpandula Sep 8, 2026
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable 2026-08-05
with:
toolchain: '1.97.1'
- name: Install ExifTool for metadata-copy tests
run: brew install exiftool
- name: Verify exact Rust toolchain
run: python3 -I -S -B scripts/verify_pinned_rust.py
- name: Test Swift app and Rust engine
Expand Down
155 changes: 155 additions & 0 deletions app/ScanStudio/Sources/ScanStudio/BatchInspectorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ struct BatchInspectorView: View {
@State private var rawSettingsExpanded = false
@State private var positiveTiffSettingsExpanded = false
@State private var positiveJPEGSettingsExpanded = false
@State private var namedPresetNames: [String] = []
@State private var presetError: String?
@State private var reportError: String?
@State private var renderError: String?
@State private var metadataCopyPreview: ControlMetadataApplyResult?
@State private var metadataCopyResult: ControlMetadataApplyResult?
@State private var renderProfile = "sRGB"
@FocusState private var focusedGearField: GearField?
@State private var presentedRecentGear: GearField?

Expand Down Expand Up @@ -56,6 +63,7 @@ struct BatchInspectorView: View {
// project) so `sessionModel.exifToolDetection` is already populated
// before the user ever opens a frame detail view's ExifTool panel.
.task { await sessionModel.detectExifTool() }
.task { refreshNamedPresets() }
.alert("Saved file unavailable", isPresented: Binding(
get: { missingSavedOutput != nil },
set: { if !$0 { missingSavedOutput = nil } }
Expand All @@ -64,6 +72,30 @@ struct BatchInspectorView: View {
} message: {
Text("The recorded file is no longer at this location. It may have been moved or deleted.\n\(missingSavedOutput ?? "")")
}
.alert("Preset unavailable", isPresented: Binding(
get: { presetError != nil },
set: { if !$0 { presetError = nil } }
)) {
Button("OK", role: .cancel) { presetError = nil }
} message: {
Text(presetError ?? "The preset could not be loaded.")
}
.alert("Roll report unavailable", isPresented: Binding(
get: { reportError != nil },
set: { if !$0 { reportError = nil } }
)) {
Button("OK", role: .cancel) { reportError = nil }
} message: {
Text(reportError ?? "The HTML report could not be written.")
}
.alert("Re-render unavailable", isPresented: Binding(
get: { renderError != nil },
set: { if !$0 { renderError = nil } }
)) {
Button("OK", role: .cancel) { renderError = nil }
} message: {
Text(renderError ?? "The retained master could not be rendered.")
}
}

@ViewBuilder
Expand All @@ -81,6 +113,26 @@ struct BatchInspectorView: View {
revealSavedFile(URL(fileURLWithPath: directory))
}
.controlSize(.small)
Button("Generate HTML Report") {
generateRollReport(directory: directory)
}
.controlSize(.small)
if let frameIndex = sessionModel.detailFrameIndex ?? sessionModel.frameTransformTargetIndex {
HStack(spacing: 6) {
Menu {
ForEach(["sRGB", "AdobeRGB1998", "ProPhotoRGB"], id: \.self) { profile in
Button(profile) { renderProfile = profile }
}
} label: {
Label("Profile: \(renderProfile)", systemImage: "paintpalette")
}
.controlSize(.small)
Button("Re-render selected") {
renderSelected(frameIndex: frameIndex, projectDirectory: directory)
}
.controlSize(.small)
}
}
if let frameIndex = sessionModel.detailFrameIndex ?? sessionModel.frameTransformTargetIndex {
let receipt = sessionModel.receipts.last(where: { $0.frameIndex == frameIndex })
?? sessionModel.project?.frames.first(where: { $0.index == frameIndex })?.receipts.last
Expand Down Expand Up @@ -115,6 +167,50 @@ struct BatchInspectorView: View {
NSWorkspace.shared.activateFileViewerSelecting([url])
}

private func generateRollReport(directory: String) {
Task {
do {
let result = try await Task.detached(priority: .userInitiated) {
try RollHTMLReport.write(projectDirectory: URL(fileURLWithPath: directory))
}.value
NSWorkspace.shared.open(URL(fileURLWithPath: result.path))
} catch {
reportError = error.localizedDescription
}
}
}

private func renderSelected(frameIndex: Int, projectDirectory: String) {
let output = URL(fileURLWithPath: projectDirectory).appendingPathComponent("Rendered-\(renderProfile)")
Task {
guard let result = await sessionModel.renderRoll(ControlRollRenderParams(
frames: [frameIndex], profile: renderProfile, output: output.path
)) else {
renderError = sessionModel.lastErrorMessage ?? "The retained master could not be rendered."
return
}
if let first = result.files.first {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: first.path)])
}
}
}

private func refreshNamedPresets() {
namedPresetNames = (try? ScanRecipePresetStore().list()) ?? []
}

private func applyNamedPreset(_ name: String) {
do {
let preset = try ScanRecipePresetStore().load(named: name)
// The shared SessionModel path applies capture settings while
// retaining the project's persisted exposure lock.
sessionModel.applySettingsRecipes(capture: preset.capture, processing: preset.processing)
sessionModel.applyOutputRecipe(preset.output)
} catch {
presetError = String(describing: error)
}
}

private var setupInspector: some View {
Group {
InspectorSection(title: "Scan Settings") {
Expand All @@ -129,6 +225,21 @@ struct BatchInspectorView: View {
}
}
}
InspectorSettingRow(label: "Saved preset") {
Menu {
if namedPresetNames.isEmpty {
Text("No saved presets")
} else {
ForEach(namedPresetNames, id: \.self) { name in
Button(name) { applyNamedPreset(name) }
}
}
Divider()
Button("Refresh") { refreshNamedPresets() }
} label: {
Label("Choose…", systemImage: "bookmark")
}
}
if sessionModel.scanRecipePreset == .custom {
Label("This recipe has manual changes.", systemImage: "slider.horizontal.3")
.font(.system(size: 10))
Expand Down Expand Up @@ -304,6 +415,32 @@ struct BatchInspectorView: View {
.padding(.bottom, 6)

InspectorRow(label: "ExifTool", value: exifToolStatusSummary)
HStack(spacing: 8) {
Button("Preview copy metadata") {
Task { metadataCopyPreview = await sessionModel.applyMetadataCopies(metadataCopyParams(dryRun: true)) }
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(sessionModel.exifToolDetection?.available != true || metadataCopyFrames.isEmpty)
Button("Apply to new copies") {
Task { metadataCopyResult = await sessionModel.applyMetadataCopies(metadataCopyParams(dryRun: false)) }
}
.buttonStyle(.borderedProminent)
.tint(.scanStudioAmber)
.controlSize(.small)
.disabled(metadataCopyPreview == nil)
}
if let preview = metadataCopyPreview {
Text(preview.arguments.isEmpty ? "No metadata fields are set." : preview.arguments.joined(separator: " "))
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.scanStudioSecondaryText)
.textSelection(.enabled)
}
if let result = metadataCopyResult {
Text("Created \(result.files.count) metadata cop\(result.files.count == 1 ? "y" : "ies") in a new directory.")
.font(.system(size: 10))
.foregroundStyle(Color.scanStudioCyan)
}
}
InspectorSection(title: "Estimated Size") {
InspectorRow(
Expand Down Expand Up @@ -361,6 +498,24 @@ struct BatchInspectorView: View {
}
}

private var metadataCopyFrames: [Int] {
sessionModel.project?.frames.filter { !$0.receipts.isEmpty }.map(\.index) ?? []
}

private func metadataCopyParams(dryRun: Bool) -> ControlRollMetadataApplyParams {
let destination = URL(fileURLWithPath: sessionModel.positiveDestination)
.appendingPathComponent("Metadata Copies", isDirectory: true)
.standardizedFileURL.path
return ControlRollMetadataApplyParams(
frames: metadataCopyFrames,
to: destination,
template: sessionModel.positiveFilenameTemplate,
kind: "positive",
metadata: sessionModel.rollMetadataDraft,
dryRun: dryRun
)
}

private var saveOutputsSection: some View {
InspectorSection(title: "Outputs for next scan") {
Label(
Expand Down
102 changes: 102 additions & 0 deletions app/ScanStudio/Sources/ScanStudioKit/CalibrationWireProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation

public struct ControlRollVerifyParams: Codable, Equatable, Sendable {
public var pass: String?
public var exposureIdentical: Bool
public var noClipping: Bool

public init(pass: String? = nil, exposureIdentical: Bool = false, noClipping: Bool = false) {
self.pass = pass
self.exposureIdentical = exposureIdentical
self.noClipping = noClipping
}

private enum CodingKeys: String, CodingKey {
case pass, exposureIdentical, noClipping
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pass = try container.decodeIfPresent(String.self, forKey: .pass)
exposureIdentical = try container.decodeIfPresent(Bool.self, forKey: .exposureIdentical) ?? false
noClipping = try container.decodeIfPresent(Bool.self, forKey: .noClipping) ?? false
}
}

public struct CalibrationVerificationIssue: Codable, Equatable, Sendable {
public var status: String
public var jobId: String?
public var frameIndex: Int?
public var field: String
public var detail: String
}

public struct CalibrationVerificationReport: Codable, Equatable, Sendable {
public var status: String
public var exposureIdentical: Bool
public var noClipping: Bool
public var checkedReceipts: Int
public var issues: [CalibrationVerificationIssue]
}

public struct CalibrationCollectionMetadata: Codable, Equatable, Sendable {
public var stock: String
public var pass: String
public var slotMap: [String: Int]
public var firmware: String?
public var adapter: String?
public var host: String?
public var operatorName: String?
public var appVersion: String?
public var driverVersion: String?

enum CodingKeys: String, CodingKey {
case stock, pass, slotMap, firmware, adapter, host, appVersion, driverVersion
case operatorName = "operator"
}

public init(
stock: String,
pass: String,
slotMap: [String: Int],
firmware: String? = nil,
adapter: String? = nil,
host: String? = nil,
operatorName: String? = nil,
appVersion: String? = nil,
driverVersion: String? = nil
) {
self.stock = stock
self.pass = pass
self.slotMap = slotMap
self.firmware = firmware
self.adapter = adapter
self.host = host
self.operatorName = operatorName
self.appVersion = appVersion
self.driverVersion = driverVersion
}
}

public struct ControlRollCollectParams: Codable, Equatable, Sendable {
public var to: String
public var metadata: CalibrationCollectionMetadata

public init(to: String, metadata: CalibrationCollectionMetadata) {
self.to = to
self.metadata = metadata
}
}

public struct CalibrationCollectedFile: Codable, Equatable, Sendable {
public var path: String
public var byteLength: UInt64
public var sha256: String
}

public struct CalibrationCollectionResult: Codable, Equatable, Sendable {
public var destination: String
public var files: [CalibrationCollectedFile]
public var metadataPath: String
public var hashesPath: String
}
6 changes: 6 additions & 0 deletions app/ScanStudio/Sources/ScanStudioKit/ControlCLISupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ public enum ControlCLIExitCode: Int32, Sendable {
case noHostReachable = 69
case internalError = 70
case busy = 75
case hostExited = 76
case confirmationRequired = 77
case schemaVersionMismatch = 78
case waitTimedOut = 124

/// Maps a wire-level error `code` (either `ControlErrorPayload.code`
/// from the channel, or one of `ControlCLIErrorCode`'s own strings) onto
Expand Down Expand Up @@ -55,6 +57,8 @@ public enum ControlCLIExitCode: Int32, Sendable {
case ControlErrorCode.schemaVersionMismatch.rawValue,
ControlErrorCode.helloRequired.rawValue:
return .schemaVersionMismatch
case ControlCLIErrorCode.waitTimeout.rawValue:
return .waitTimedOut
default:
// GATE_REFUSED, JOB_NOT_FOUND, and every engine/bridge
// passthrough code land here (65) -- see the doc comment above.
Expand All @@ -75,6 +79,7 @@ public enum ControlCLIErrorCode: String, Sendable {
case jobNotFound = "JOB_NOT_FOUND"
case internalCode = "INTERNAL"
case hostAlreadyRunning = "HOST_ALREADY_RUNNING"
case waitTimeout = "WAIT_TIMEOUT"
}

// MARK: - Output envelope (D-09/OUT-01)
Expand Down Expand Up @@ -445,6 +450,7 @@ public struct ControlRunReceipt: Codable, Equatable, Sendable {
/// attempted or the write failed, so a caller can tell "no project
/// directory yet" and "write failed" apart from "here is the file."
public var receiptPath: String?
public var approvedJobPath: String?

public init() {}

Expand Down
Loading
Loading