diff --git a/src-tauri/argyll/linux-x86_64/colprof.mock b/src-tauri/argyll/linux-x86_64/colprof.mock new file mode 100755 index 0000000..8bbd22c --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/colprof.mock @@ -0,0 +1,20 @@ +#!/bin/bash +# Mock script for colprof +# Simulates colprof execution and outputs progress log + +basename="$1" +# Find last argument if -D or other flags are used +for arg in "$@"; do + basename="$arg" +done + +echo "colprof: Starting profile calculation for $basename" +sleep 1 +echo "Gamut mapping calculation..." +sleep 1 +echo "Fitting cLUT grid points..." +sleep 1 +echo "Writing ICC profile $basename.icc..." +touch "$basename.icc" +echo "Done." +exit 0 diff --git a/src-tauri/argyll/linux-x86_64/profcheck.mock b/src-tauri/argyll/linux-x86_64/profcheck.mock new file mode 100755 index 0000000..78da1f3 --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/profcheck.mock @@ -0,0 +1,14 @@ +#!/bin/bash +# Mock script for profcheck +# Simulates profcheck verification output + +echo "profcheck: Checking profile accuracy..." +sleep 1 +cat << 'EOF' +{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02} +EOF +echo "Summary:" +echo " avg. dE = 0.85" +echo " max. dE = 2.41" +echo " rms. dE = 1.02" +exit 0 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 635f18b..8a22ec2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -204,6 +204,94 @@ pub async fn run_chartread( state.spawn(app, id, binary, args, cwd).await } +#[derive(Debug, Deserialize, Serialize)] +pub struct ColprofConfig { + pub quality: String, + pub algorithm: String, + pub description: String, + pub copyright: Option, + pub basename: String, + pub cwd: String, +} + +pub fn build_colprof_args(config: &ColprofConfig) -> Vec { + let mut args = vec![ + "-v".to_string(), + "-u".to_string(), + "-q".to_string(), + config.quality.clone(), + "-a".to_string(), + config.algorithm.clone(), + "-D".to_string(), + config.description.clone(), + ]; + + if let Some(copyright) = &config.copyright { + if !copyright.trim().is_empty() { + args.push("-C".to_string()); + args.push(copyright.clone()); + } + } + + args.push(config.basename.clone()); + args +} + +#[tauri::command] +pub async fn run_colprof( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ColprofConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "colprof".to_string()).await?; + let args = build_colprof_args(&config); + let id = format!("colprof_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProfcheckConfig { + pub ti3_path: String, + pub icc_path: String, + pub cwd: String, +} + +pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec { + vec![ + "-v".to_string(), + "-k".to_string(), + "-u".to_string(), + config.ti3_path.clone(), + config.icc_path.clone(), + ] +} + +#[tauri::command] +pub async fn run_profcheck( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ProfcheckConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "profcheck".to_string()).await?; + let args = build_profcheck_args(&config); + let id = format!("profcheck_{}", config.ti3_path); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + #[cfg(test)] mod tests { use super::*; @@ -287,4 +375,32 @@ mod tests { let args = build_chartread_args(&config); assert_eq!(args, vec!["-v", "-u", "my_profile"]); } + + #[test] + fn test_build_colprof_args() { + let config = ColprofConfig { + quality: "h".to_string(), + algorithm: "l".to_string(), + description: "My Profile".to_string(), + copyright: Some("2026 ACME".to_string()), + basename: "my_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_colprof_args(&config); + assert_eq!( + args, + vec!["-v", "-u", "-q", "h", "-a", "l", "-D", "My Profile", "-C", "2026 ACME", "my_profile"] + ); + } + + #[test] + fn test_build_profcheck_args() { + let config = ProfcheckConfig { + ti3_path: "my_profile.ti3".to_string(), + icc_path: "my_profile.icc".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_profcheck_args(&config); + assert_eq!(args, vec!["-v", "-k", "-u", "my_profile.ti3", "my_profile.icc"]); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 161fe3e..c0eb57c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,8 @@ pub fn run() { commands::run_printtarg, commands::read_file_base64, commands::run_chartread, + commands::run_colprof, + commands::run_profcheck, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index 410a8c8..065f8c3 100644 --- a/src/index.html +++ b/src/index.html @@ -218,15 +218,101 @@ diff --git a/src/js/app.js b/src/js/app.js index 3641fd2..a107b81 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,6 +1,8 @@ import { initTargen } from './targen.js'; import { initPrinttarg } from './printtarg.js'; import { initChartread } from './chartread.js'; +import { initColprof } from './colprof.js'; +import { initProfcheck } from './profcheck.js'; const { invoke } = window.__TAURI__.core; @@ -35,4 +37,10 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize Stage 3 initChartread(); + + // Initialize Stage 4 + initColprof(); + + // Initialize Stage 5 + initProfcheck(); }); diff --git a/src/js/chartread.js b/src/js/chartread.js index 8af8e1b..b6c69f6 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -1,6 +1,7 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; import { startSwatchListener, stopSwatchListener } from './swatch_grid.js'; +import { setStage3Result } from './colprof.js'; // Module-level state: set by Stage 2 when it completes let stage2Basename = ""; @@ -150,6 +151,7 @@ export function initChartread() { setState(STATE.FINISHED); setPrompt("āœ… Measurement complete! .ti3 file has been saved."); logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n"; + setStage3Result(stage2Basename, stage2Cwd); advanceToStage4(); } else { setState(STATE.FINISHED); diff --git a/src/js/colprof.js b/src/js/colprof.js new file mode 100644 index 0000000..2a7f8c0 --- /dev/null +++ b/src/js/colprof.js @@ -0,0 +1,133 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; +import { setStage4Result } from './profcheck.js'; + +let chartreadBasename = ""; +let chartreadCwd = ""; + +/** + * Called by chartread.js after Stage 3 completes. + */ +export function setStage3Result(basename, cwd) { + chartreadBasename = basename; + chartreadCwd = cwd; +} + +export function initColprof() { + const qualitySelect = document.getElementById("colprofQuality"); + const algorithmSelect = document.getElementById("colprofAlgorithm"); + const descInput = document.getElementById("colprofDescription"); + const copyrightInput = document.getElementById("colprofCopyright"); + const btnCreateProfile = document.getElementById("btnCreateProfile"); + const spinnerContainer = document.getElementById("colprofSpinnerContainer"); + const stageLabel = document.getElementById("colprofStageLabel"); + const logContainer = document.getElementById("colprofLogContainer"); + const logPre = document.getElementById("colprofLog"); + const successCard = document.getElementById("colprofSuccessCard"); + const successInfo = document.getElementById("colprofSuccessInfo"); + const btnGoToVerify = document.getElementById("btnGoToVerify"); + + if (!btnCreateProfile) return; + + btnCreateProfile.addEventListener("click", async () => { + const basename = chartreadBasename || "test_target"; + const cwd = chartreadCwd || ""; + + const description = descInput.value.trim() || basename; + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + spinnerContainer.classList.remove("hidden"); + successCard.classList.add("hidden"); + btnCreateProfile.disabled = true; + stageLabel.textContent = "Initializing colprof..."; + + const config = { + quality: qualitySelect.value, + algorithm: algorithmSelect.value, + description: description, + copyright: copyrightInput.value.trim() || null, + basename: basename, + cwd: cwd, + }; + + const processId = `colprof_${basename}`; + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id === processId && event.payload.line) { + const line = event.payload.line; + logPre.textContent += line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + + // Parse coarse progress stages from stdout + const lineLower = line.toLowerCase(); + if (lineLower.includes("gamut mapping")) { + stageLabel.textContent = "Gamut mapping calculation in progress..."; + } else if (lineLower.includes("fitting") || lineLower.includes("clut")) { + stageLabel.textContent = "Fitting cLUT grid points..."; + } else if (lineLower.includes("writing") || lineLower.includes("icc profile")) { + stageLabel.textContent = "Writing ICC profile header & tags..."; + } + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === processId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id === processId) { + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + spinnerContainer.classList.add("hidden"); + btnCreateProfile.disabled = false; + + if (event.payload.code === 0) { + logPre.textContent += "\n[SUCCESS] colprof completed. Profile generated.\n"; + const iccFilename = `${basename}.icc`; + successInfo.textContent = `Profile: ${iccFilename} (${description})`; + successCard.classList.remove("hidden"); + + setStage4Result(basename, cwd); + } else { + logPre.textContent += `\n[ERROR] colprof exited with code ${event.payload.code}.\n`; + } + } + }); + + await invoke("run_colprof", { config }); + } catch (err) { + logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; + spinnerContainer.classList.add("hidden"); + btnCreateProfile.disabled = false; + } + }); + + if (btnGoToVerify) { + btnGoToVerify.addEventListener("click", () => { + advanceToStage5(); + }); + } +} + +function advanceToStage5() { + const steps = document.querySelectorAll('.step'); + const stages = document.querySelectorAll('.stage'); + + steps.forEach(s => s.classList.remove('active')); + if (steps[4]) steps[4].classList.add('active'); + + stages.forEach(s => { + s.classList.remove('active'); + s.classList.add('hidden'); + }); + if (stages[4]) { + stages[4].classList.remove('hidden'); + stages[4].classList.add('active'); + } +} diff --git a/src/js/profcheck.js b/src/js/profcheck.js new file mode 100644 index 0000000..49677c6 --- /dev/null +++ b/src/js/profcheck.js @@ -0,0 +1,137 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; + +let profileBasename = ""; +let profileCwd = ""; + +/** + * Called by colprof.js after Stage 4 completes. + */ +export function setStage4Result(basename, cwd) { + profileBasename = basename; + profileCwd = cwd; +} + +export function initProfcheck() { + const btnVerify = document.getElementById("btnVerify"); + const logContainer = document.getElementById("profcheckLogContainer"); + const logPre = document.getElementById("profcheckLog"); + const reportCard = document.getElementById("profcheckReportCard"); + const avgDeEl = document.getElementById("profcheckAvgDe"); + const maxDeEl = document.getElementById("profcheckMaxDe"); + const rmsDeEl = document.getElementById("profcheckRmsDe"); + const badgeEl = document.getElementById("profcheckBadge"); + + if (!btnVerify) return; + + btnVerify.addEventListener("click", async () => { + const basename = profileBasename || "test_target"; + const cwd = profileCwd || ""; + + const sep = cwd.includes('\\') ? '\\' : '/'; + const ti3Path = cwd ? `${cwd}${sep}${basename}.ti3` : `${basename}.ti3`; + const iccPath = cwd ? `${cwd}${sep}${basename}.icc` : `${basename}.icc`; + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + reportCard.classList.add("hidden"); + btnVerify.disabled = true; + + const config = { + ti3_path: ti3Path, + icc_path: iccPath, + cwd: cwd, + }; + + const processId = `profcheck_${ti3Path}`; + let stdoutAccumulator = ""; + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id === processId && event.payload.line) { + stdoutAccumulator += event.payload.line + "\n"; + logPre.textContent += event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === processId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id === processId) { + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + btnVerify.disabled = false; + + if (event.payload.code === 0) { + logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n"; + parseAndRenderReport(stdoutAccumulator); + } else { + logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`; + } + } + }); + + await invoke("run_profcheck", { config }); + } catch (err) { + logPre.textContent += `\n[INVOKE ERROR] ${err}\n`; + btnVerify.disabled = false; + } + }); + + function parseAndRenderReport(stdout) { + reportCard.classList.remove("hidden"); + + let avgDe = 0.0; + let maxDe = 0.0; + let rmsDe = 0.0; + + // Check if JSON output is present + const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/); + if (jsonMatch) { + try { + const json = JSON.parse(jsonMatch[0]); + avgDe = json.avg_de || 0; + maxDe = json.max_de || json.peak_de || 0; + rmsDe = json.rms_de || 0; + } catch (e) { + console.error("JSON parse error:", e); + } + } else { + // Regex fallbacks for standard profcheck output + const avgMatch = stdout.match(/avg\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/average\s*dE\s*:\s*([\d\.]+)/i); + const maxMatch = stdout.match(/max\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/peak\s*dE\s*:\s*([\d\.]+)/i); + const rmsMatch = stdout.match(/rms\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/rms\s*dE\s*:\s*([\d\.]+)/i); + + if (avgMatch) avgDe = parseFloat(avgMatch[1]); + if (maxMatch) maxDe = parseFloat(maxMatch[1]); + if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]); + } + + avgDeEl.textContent = avgDe.toFixed(2); + maxDeEl.textContent = maxDe.toFixed(2); + rmsDeEl.textContent = rmsDe.toFixed(2); + + // Quality verdict + badgeEl.className = "report-badge"; + if (avgDe < 1.0) { + badgeEl.textContent = "EXCELLENT"; + badgeEl.classList.add("badge-excellent"); + } else if (avgDe < 2.0) { + badgeEl.textContent = "GOOD"; + badgeEl.classList.add("badge-good"); + } else if (avgDe < 4.0) { + badgeEl.textContent = "ACCEPTABLE"; + badgeEl.classList.add("badge-acceptable"); + } else { + badgeEl.textContent = "POOR"; + badgeEl.classList.add("badge-poor"); + } + } +} diff --git a/src/styles/main.css b/src/styles/main.css index e70398e..686c4e5 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -499,3 +499,146 @@ button.danger:hover { overflow-y: auto; white-space: pre-wrap; } + +/* ======================================== + Stage 4: Spinner & Success Card + ======================================== */ +.spinner-container { + display: flex; + align-items: center; + gap: 16px; + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + margin-top: 16px; + margin-bottom: 16px; +} + +.spinner { + width: 24px; + height: 24px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.stage-label { + font-size: 1rem; + color: #ddd; +} + +.success-card { + background: rgba(76, 175, 80, 0.1); + border: 1px solid #4caf50; + border-radius: 8px; + padding: 20px; + margin-top: 16px; + margin-bottom: 16px; +} + +.success-card h3 { + color: #4caf50; + margin-top: 0; + margin-bottom: 8px; +} + +.success-card p { + color: #ccc; + margin-bottom: 16px; +} + +/* ======================================== + Stage 5: Report Card + ======================================== */ +.report-card { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-top: 16px; + margin-bottom: 16px; +} + +.report-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.report-header h3 { + margin: 0; + font-size: 1.2rem; +} + +.report-badge { + padding: 4px 12px; + border-radius: 12px; + font-weight: bold; + font-size: 0.85rem; + letter-spacing: 0.5px; +} + +.badge-excellent { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; + border: 1px solid #4caf50; +} + +.badge-good { + background: rgba(33, 150, 243, 0.2); + color: #2196f3; + border: 1px solid #2196f3; +} + +.badge-acceptable { + background: rgba(255, 152, 0, 0.2); + color: #ff9800; + border: 1px solid #ff9800; +} + +.badge-poor { + background: rgba(244, 67, 54, 0.2); + color: #f44336; + border: 1px solid #f44336; +} + +.report-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.metric-box { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 16px; + text-align: center; +} + +.metric-value { + display: block; + font-size: 1.8rem; + font-weight: bold; + color: #fff; +} + +.metric-label { + font-size: 0.8rem; + color: #888; +} + +.report-footnote { + font-size: 0.85rem; + color: #888; + border-top: 1px dashed var(--border-color); + padding-top: 12px; +}