Fix/averaging unique ti3 #118

Merged
gronod merged 9 commits from fix/averaging-unique-ti3 into development 2026-08-28 17:18:43 +01:00
8 changed files with 271 additions and 66 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "iccery",
"version": "0.1.4",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "iccery",
"version": "0.1.4",
"version": "0.3.3",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "iccery",
"private": true,
"version": "0.3.2",
"version": "0.3.3",
"type": "module",
"scripts": {
"tauri": "tauri"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iccery"
version = "0.3.2"
version = "0.3.3"
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
authors = ["Gordon"]
edition = "2021"
+112 -1
View File
@@ -507,6 +507,68 @@ pub async fn run_average(
state.spawn(app, id, binary, args, cwd).await
}
fn sanitize_basename(basename: &str) -> Result<String, String> {
let name = basename.trim();
if name.is_empty() {
return Err("basename is empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("basename must not contain path separators".to_string());
}
Ok(name.to_string())
}
pub fn snapshot_ti3_filename(basename: &str, pass_index: u32) -> Result<String, String> {
if pass_index == 0 {
return Err("pass_index must be 1-based".to_string());
}
let name = sanitize_basename(basename)?;
Ok(format!("{}_pass{}.ti3", name, pass_index))
}
/// Copy `{cwd}/{basename}.ti3` to `{cwd}/{basename}_pass{N}.ti3` and remove the
/// canonical file so Stage 4 stays locked until Finish (#109 / #110).
#[tauri::command]
pub fn snapshot_ti3(cwd: String, basename: String, pass_index: u32) -> Result<String, String> {
let dest_name = snapshot_ti3_filename(&basename, pass_index)?;
let name = sanitize_basename(&basename)?;
let dir = std::path::Path::new(&cwd);
if !dir.is_dir() {
return Err(format!("working directory does not exist: {}", cwd));
}
let src = dir.join(format!("{}.ti3", name));
if !src.is_file() {
return Err(format!("measurement file not found: {}", src.display()));
}
let dest = dir.join(&dest_name);
std::fs::copy(&src, &dest).map_err(|e| format!("failed to snapshot .ti3: {}", e))?;
std::fs::remove_file(&src)
.map_err(|e| format!("failed to remove canonical .ti3 after snapshot: {}", e))?;
Ok(dest_name)
}
/// Copy a pass file (relative name) back to `{cwd}/{basename}.ti3`.
#[tauri::command]
pub fn promote_ti3(cwd: String, source: String, basename: String) -> Result<(), String> {
let name = sanitize_basename(&basename)?;
let src_name = source.trim();
if src_name.is_empty()
|| src_name.contains('/')
|| src_name.contains('\\')
|| src_name.contains("..")
{
return Err("source filename is invalid".to_string());
}
let dir = std::path::Path::new(&cwd);
let src = dir.join(src_name);
if !src.is_file() {
return Err(format!("source measurement file not found: {}", src.display()));
}
let dest = dir.join(format!("{}.ti3", name));
std::fs::copy(&src, &dest).map_err(|e| format!("failed to promote .ti3: {}", e))?;
Ok(())
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ColprofConfig {
pub algorithm: String,
@@ -824,6 +886,55 @@ mod tests {
assert_eq!(args, vec!["-v", "pass1.ti3", "pass2.ti3", "avg.ti3"]);
}
#[test]
fn test_build_average_args_pass_files() {
let config = AverageConfig {
inputs: vec!["job_pass1.ti3".to_string(), "job_pass2.ti3".to_string()],
output: "job.ti3".to_string(),
cwd: "/home/user".to_string(),
};
let args = build_average_args(&config);
assert_eq!(args, vec!["-v", "job_pass1.ti3", "job_pass2.ti3", "job.ti3"]);
}
#[test]
fn test_snapshot_ti3_filename() {
assert_eq!(snapshot_ti3_filename("foo", 1).unwrap(), "foo_pass1.ti3");
assert_eq!(snapshot_ti3_filename("foo", 2).unwrap(), "foo_pass2.ti3");
assert!(snapshot_ti3_filename("foo", 0).is_err());
assert!(snapshot_ti3_filename("../x", 1).is_err());
assert!(snapshot_ti3_filename("a/b", 1).is_err());
}
#[test]
fn test_snapshot_and_promote_ti3_roundtrip() {
let dir = std::env::temp_dir().join(format!(
"iccery_ti3_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let canonical = dir.join("job.ti3");
std::fs::write(&canonical, b"PASS1").unwrap();
let cwd = dir.to_string_lossy().to_string();
let name = snapshot_ti3(cwd.clone(), "job".to_string(), 1).unwrap();
assert_eq!(name, "job_pass1.ti3");
assert!(!canonical.exists(), "canonical .ti3 must be removed after snapshot");
assert_eq!(std::fs::read(dir.join("job_pass1.ti3")).unwrap(), b"PASS1");
std::fs::write(dir.join("job_pass2.ti3"), b"PASS2").unwrap();
let name2 = snapshot_ti3_filename("job", 2).unwrap();
assert_eq!(name2, "job_pass2.ti3");
assert!(dir.join("job_pass1.ti3").exists(), "pass 1 must survive pass 2");
promote_ti3(cwd, name, "job".to_string()).unwrap();
assert_eq!(std::fs::read(&canonical).unwrap(), b"PASS1");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_build_colprof_args() {
let config = ColprofConfig {
@@ -865,4 +976,4 @@ mod tests {
let candidates_exe = get_binary_candidates("targen.exe");
assert_eq!(candidates_exe, vec!["targen.exe"]);
}
}
}
+2
View File
@@ -28,6 +28,8 @@ pub fn run() {
commands::read_file_base64,
commands::read_tiff_preview_png,
commands::run_chartread,
commands::snapshot_ti3,
commands::promote_ti3,
commands::run_average,
commands::run_colprof,
commands::run_profcheck,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ICCery",
"version": "0.3.2",
"version": "0.3.3",
"identifier": "com.gronod.iccery",
"build": {
"frontendDist": "../src"
+4 -4
View File
@@ -50,7 +50,7 @@
<button class="step active" data-step="1">1. Generate Target</button>
<button class="step" data-step="2">2. Print Layout</button>
<button class="step" data-step="3">3. Measure Target</button>
<button class="step" data-step="4">4. Create Profile</button>
<button class="step" data-step="4" title="Complete measurement (Finish) before profiling">4. Create Profile</button>
<button class="step" data-step="5">5. Verify</button>
</nav>
</aside>
@@ -311,11 +311,11 @@
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3>
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
</div>
<p class="sub-label" style="margin-bottom: 12px;">You can measure additional printed copies to reduce measurement and printing noise before computing the profile.</p>
<p class="sub-label" style="margin-bottom: 12px;">Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.</p>
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
<div style="display: flex; gap: 8px;">
<button type="button" class="secondary" id="btnMeasureAnotherSheet"> Measure Another Sheet</button>
<button type="button" class="primary" id="btnFinishAndAverage">✓ Average Passes &amp; Proceed →</button>
<button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes &amp; Proceed →</button>
</div>
</div>
@@ -564,4 +564,4 @@
</div>
</dialog>
</body>
</html>
</html>
+148 -56
View File
@@ -29,6 +29,9 @@ const STATE = {
let currentState = STATE.IDLE;
let currentProcessId = "";
let measurementInProgress = false;
let currentPassIndex = 0;
const recordedPasses = [];
export function initChartread() {
const btnStartRead = document.getElementById("btnStartRead");
@@ -42,6 +45,47 @@ export function initChartread() {
const stateLabel = document.getElementById("chartreadState");
const logContainer = document.getElementById("chartreadLogContainer");
const logPre = document.getElementById("chartreadLog");
const averagingPanel = document.getElementById("chartreadAveragingPanel");
const passesList = document.getElementById("passesList");
const passCounterBadge = document.getElementById("passCounterBadge");
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
function setMeasurementBusy(busy) {
measurementInProgress = busy;
if (btnStartRead) btnStartRead.disabled = busy;
if (btnMeasureAnotherSheet) btnMeasureAnotherSheet.disabled = busy;
if (btnFinishAndAverage) {
btnFinishAndAverage.disabled = busy || recordedPasses.length === 0;
}
}
function renderPassesList() {
if (!passesList) return;
passesList.innerHTML = "";
recordedPasses.forEach((pass, i) => {
const item = document.createElement("div");
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;";
const label = document.createElement("span");
const strong = document.createElement("strong");
strong.textContent = `Sheet Pass #${i + 1}`;
label.appendChild(strong);
label.appendChild(document.createTextNode(` (${pass.filename})`));
const time = document.createElement("span");
time.style.opacity = "0.7";
time.textContent = `${pass.time}`;
item.appendChild(label);
item.appendChild(time);
passesList.appendChild(item);
});
}
function acceptStage3(basename, cwd) {
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
wizardState.updateGating();
advanceToStage4();
}
// Instrument detection logic
if (btnDetectInstruments && instrumentSelect) {
@@ -133,7 +177,10 @@ export function initChartread() {
btnCancel.classList.remove("hidden");
break;
case STATE.FINISHED:
btnStartRead.classList.remove("hidden");
// After a recorded pass, further sheets go through "Measure Another Sheet"
if (recordedPasses.length === 0) {
btnStartRead.classList.remove("hidden");
}
break;
}
}
@@ -145,6 +192,11 @@ export function initChartread() {
// Start reading button
if (btnStartRead) {
btnStartRead.addEventListener("click", async () => {
if (measurementInProgress) {
setPrompt("A measurement is already running. Wait for it to finish.");
return;
}
const basename = stage2Basename || wizardState.basename;
const cwd = stage2Cwd || wizardState.cwd;
@@ -159,6 +211,7 @@ export function initChartread() {
logPre.textContent = "";
logContainer.open = false;
logContainer.classList.remove("hidden");
setMeasurementBusy(true);
setState(STATE.CALIBRATING);
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
@@ -219,29 +272,41 @@ export function initChartread() {
stopSwatchListener();
if (event.payload.code === 0) {
setState(STATE.FINISHED);
setPrompt("✅ Sheet measurement completed!");
logPre.textContent += "\n[SUCCESS] chartread sheet measurement completed.\n";
try {
const passIndex = currentPassIndex + 1;
const filename = await invoke("snapshot_ti3", {
cwd: cwd,
basename: basename,
pass_index: passIndex,
});
// Track pass in session
currentPassIndex++;
recordedPasses.push({
index: currentPassIndex,
filename: `${basename}.ti3`,
time: new Date().toLocaleTimeString(),
});
currentPassIndex = passIndex;
recordedPasses.push({
index: currentPassIndex,
filename: filename,
time: new Date().toLocaleTimeString(),
});
renderPassesList();
if (averagingPanel) averagingPanel.classList.remove("hidden");
if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`;
setState(STATE.FINISHED);
setPrompt(`Sheet pass #${currentPassIndex} saved as ${filename}. Stage 4 stays locked until you finish.`);
logPre.textContent += `\n[SUCCESS] chartread completed. Snapshotted ${filename} (canonical ${basename}.ti3 removed until Finish).\n`;
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
renderPassesList();
if (averagingPanel) averagingPanel.classList.remove("hidden");
if (passCounterBadge) passCounterBadge.textContent = `${recordedPasses.length} Pass(es) Recorded`;
// Do not call setStage3Result / wizardState.setTarget here (#110)
} catch (snapErr) {
setState(STATE.FINISHED);
setPrompt(`Measurement finished but pass snapshot failed: ${snapErr}`);
logPre.textContent += `\n[ERROR] snapshot_ti3 failed: ${snapErr}\n`;
}
} else {
setState(STATE.FINISHED);
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
}
setMeasurementBusy(false);
});
await invoke("run_chartread", { config });
@@ -249,34 +314,19 @@ export function initChartread() {
} catch (err) {
setPrompt(`Invoke error: ${err}`);
setState(STATE.IDLE);
setMeasurementBusy(false);
}
});
}
// Multi-pass measurement controls
const averagingPanel = document.getElementById("chartreadAveragingPanel");
const passesList = document.getElementById("passesList");
const passCounterBadge = document.getElementById("passCounterBadge");
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
let currentPassIndex = 0;
const recordedPasses = [];
function renderPassesList() {
if (!passesList) return;
passesList.innerHTML = "";
recordedPasses.forEach((pass, i) => {
const item = document.createElement("div");
item.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.05); padding:6px 10px; border-radius:4px; font-size:0.85rem;";
item.innerHTML = `<span><strong>Sheet Pass #${i + 1}</strong> (${pass.filename})</span><span style="opacity:0.7;">✓ ${pass.time}</span>`;
passesList.appendChild(item);
});
}
if (btnMeasureAnotherSheet) {
btnMeasureAnotherSheet.addEventListener("click", () => {
if (measurementInProgress) {
setPrompt("Wait for the current measurement to finish and be snapshotted.");
return;
}
setState(STATE.IDLE);
setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Press 'Start Measurement'.`);
setPrompt(`Ready to measure sheet pass #${recordedPasses.length + 1}. Starting chartread...`);
if (btnStartRead) btnStartRead.click();
});
}
@@ -286,11 +336,36 @@ export function initChartread() {
const basename = stage2Basename || wizardState.basename;
const cwd = stage2Cwd || wizardState.cwd;
if (recordedPasses.length <= 1) {
setPrompt("Single pass accepted. Proceeding to Stage 4...");
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
if (!basename || !cwd) {
setPrompt("Error: No working directory or basename.");
return;
}
if (measurementInProgress) {
setPrompt("Wait for the current measurement to finish.");
return;
}
if (recordedPasses.length === 0) {
setPrompt("No measurement passes recorded yet.");
return;
}
if (recordedPasses.length === 1) {
setPrompt("Single pass accepted. Restoring canonical .ti3 and proceeding to Stage 4...");
btnFinishAndAverage.disabled = true;
try {
await invoke("promote_ti3", {
cwd: cwd,
source: recordedPasses[0].filename,
basename: basename,
});
logPre.textContent += `\n[SUCCESS] Promoted ${recordedPasses[0].filename}${basename}.ti3 (single pass, average not invoked).\n`;
acceptStage3(basename, cwd);
} catch (e) {
btnFinishAndAverage.disabled = false;
setPrompt(`Failed to restore ${basename}.ti3: ${e}`);
}
return;
}
@@ -304,21 +379,30 @@ export function initChartread() {
cwd: cwd,
};
const unlistenAvgExit = await listen("process:exit", (event) => {
logPre.textContent += `\n[average] average -v ${averageConfig.inputs.join(" ")} ${averageConfig.output}\n`;
const unlistenAvgExit = await listen("process:exit", async (event) => {
if (event.payload.id !== `average_${basename}.ti3`) return;
unlistenAvgExit();
btnFinishAndAverage.disabled = false;
if (event.payload.code === 0) {
setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`);
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`;
acceptStage3(basename, cwd);
} else {
setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Proceeding with primary pass.`);
wizardState.setTarget(basename, cwd);
setStage3Result(basename, cwd);
advanceToStage4();
setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Promoting pass 1 as fallback.`);
logPre.textContent += `\n[ERROR] average exited ${event.payload.code}. Promoting ${recordedPasses[0].filename}.\n`;
try {
await invoke("promote_ti3", {
cwd: cwd,
source: recordedPasses[0].filename,
basename: basename,
});
acceptStage3(basename, cwd);
} catch (e) {
btnFinishAndAverage.disabled = false;
setPrompt(`Average failed and fallback promote failed: ${e}`);
}
}
});
@@ -326,8 +410,7 @@ export function initChartread() {
} catch (e) {
console.error("run_average error:", e);
btnFinishAndAverage.disabled = false;
setStage3Result(basename, cwd);
advanceToStage4();
setPrompt(`run_average error: ${e}`);
}
});
}
@@ -369,9 +452,15 @@ export function initChartread() {
try {
await invoke("kill_process", { id: currentProcessId });
stopSwatchListener();
setState(STATE.IDLE);
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
setPrompt("Measurement cancelled.");
} catch (e) { console.error("kill_process error:", e); }
} catch (e) {
console.error("kill_process error:", e);
setPrompt(`Cancel failed: ${e}`);
} finally {
setMeasurementBusy(false);
stopSwatchListener();
}
});
}
@@ -385,7 +474,10 @@ function advanceToStage4() {
const stages = document.querySelectorAll('.stage');
steps.forEach(s => s.classList.remove('active'));
if (steps[3]) steps[3].classList.add('active');
if (steps[3]) {
steps[3].classList.add('active');
steps[3].classList.remove('disabled');
}
stages.forEach(s => {
s.classList.remove('active');