feat: Stage 5 Final - 3D Gamut Visualization, Settings, Autodetection & Packaging #16
@@ -0,0 +1,15 @@
|
|||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) 2017, Mapbox
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted, provided that the above copyright notice
|
||||||
|
and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
Generated
+1
@@ -1579,6 +1579,7 @@ dependencies = [
|
|||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
|||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Mock script for chartread -u
|
||||||
|
# This script simulates the behaviour of chartread for testing purposes.
|
||||||
|
|
||||||
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
|
||||||
|
# We don't really wait for input, just wait 1 second
|
||||||
|
sleep 1
|
||||||
|
echo "Calibration successful."
|
||||||
|
echo "Hit [Space] to read strip A (or 's' to skip)."
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
echo "Reading strip A..."
|
||||||
|
|
||||||
|
# Emit mock JSON for strip A
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Hit [Space] to read strip B (or 's' to skip)."
|
||||||
|
sleep 1
|
||||||
|
echo "Reading strip B..."
|
||||||
|
|
||||||
|
# Emit mock JSON for strip B
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Ready to read... done."
|
||||||
|
exit 0
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+14
@@ -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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CGATS.17
|
||||||
|
NUMBER_OF_FIELDS 4
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
INDEX LAB_L LAB_A LAB_B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
NUMBER_OF_SETS 8
|
||||||
|
BEGIN_DATA
|
||||||
|
0 0.0 0.0 0.0
|
||||||
|
1 100.0 0.0 0.0
|
||||||
|
2 53.2 80.1 67.2
|
||||||
|
3 87.7 -86.2 83.2
|
||||||
|
4 97.1 -21.6 94.5
|
||||||
|
5 32.3 79.2 -107.9
|
||||||
|
6 60.3 98.2 -60.8
|
||||||
|
7 91.1 -48.1 -14.1
|
||||||
|
END_DATA
|
||||||
+300
-6
@@ -32,27 +32,62 @@ pub async fn kill_process(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<String, String> {
|
pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<String, String> {
|
||||||
// Resolve sidecar resource path
|
let settings = crate::settings::load_settings(app.clone()).unwrap_or_default();
|
||||||
|
if let Some(dir) = settings.argyll_binary_dir {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
let custom_path = std::path::Path::new(&dir).join(&binary_name);
|
||||||
|
if custom_path.exists() {
|
||||||
|
return Ok(custom_path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||||
|
("linux", "x86_64") => "linux-x86_64",
|
||||||
|
("windows", "x86_64") => "windows-x86_64",
|
||||||
|
("macos", "aarch64") => "macos-aarch64",
|
||||||
|
_ => "linux-x86_64",
|
||||||
|
};
|
||||||
|
|
||||||
let resource_path = app
|
let resource_path = app
|
||||||
.path()
|
.path()
|
||||||
.resolve(
|
.resolve(
|
||||||
format!("argyll/linux-x86_64/{}", binary_name),
|
format!("argyll/{}/{}", platform, binary_name),
|
||||||
tauri::path::BaseDirectory::Resource,
|
tauri::path::BaseDirectory::Resource,
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// In a real app we'd switch based on target_os, but for Milestone 1 scaffolding we'll mock the linux path.
|
|
||||||
Ok(resource_path.to_string_lossy().to_string())
|
Ok(resource_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_instruments(app: AppHandle, state: State<'_, ProcessManager>) -> Result<(), String> {
|
pub async fn detect_instruments(app: AppHandle, state: State<'_, ProcessManager>) -> Result<(), String> {
|
||||||
// We would use spawn_process directly with instlist binary and parse JSON output.
|
|
||||||
// For scaffolding, this is a placeholder.
|
|
||||||
let binary = resolve_binary(app.clone(), "instlist".to_string()).await?;
|
let binary = resolve_binary(app.clone(), "instlist".to_string()).await?;
|
||||||
state.spawn(app, "instlist".to_string(), binary, vec![], None).await
|
state.spawn(app, "instlist".to_string(), binary, vec![], None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn extract_gamut(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ProcessManager>,
|
||||||
|
icc_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let binary = resolve_binary(app.clone(), "iccgamut".to_string()).await?;
|
||||||
|
let path = std::path::Path::new(&icc_path);
|
||||||
|
let basename = path.file_stem().unwrap().to_str().unwrap();
|
||||||
|
let parent_dir = path.parent().map(|p| p.to_str().unwrap()).unwrap_or("");
|
||||||
|
|
||||||
|
let mut args = vec!["-w".to_string()];
|
||||||
|
if !parent_dir.is_empty() {
|
||||||
|
args.push("-d".to_string());
|
||||||
|
args.push(parent_dir.to_string());
|
||||||
|
}
|
||||||
|
args.push(icc_path.clone());
|
||||||
|
|
||||||
|
let id = format!("iccgamut_{}", basename);
|
||||||
|
state.spawn(app, id, binary, args, None).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct TargenConfig {
|
pub struct TargenConfig {
|
||||||
pub colour_space: String,
|
pub colour_space: String,
|
||||||
@@ -63,6 +98,16 @@ pub struct TargenConfig {
|
|||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PrinttargConfig {
|
||||||
|
pub instrument: String, // One of: "i1", "p3", "CM", "SS", "20", "22", "41", "51"
|
||||||
|
pub page_size: String, // One of: "A4", "A4R", "A3", "A2", "Letter", "LetterR", "Legal", "4x6", "11x17", or "WWWxHHH"
|
||||||
|
pub bit_depth: u8, // 8 or 16
|
||||||
|
pub dpi: u32, // TIFF resolution, e.g. 100, 200, 300
|
||||||
|
pub basename: String, // Must match the .ti1 basename from Stage 1
|
||||||
|
pub cwd: String, // Working directory where the .ti1 file resides
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"-v".to_string(),
|
"-v".to_string(),
|
||||||
@@ -92,6 +137,27 @@ pub fn build_targen_args(config: &TargenConfig) -> Vec<String> {
|
|||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"-v".to_string(),
|
||||||
|
"-u".to_string(),
|
||||||
|
"-i".to_string(),
|
||||||
|
config.instrument.clone(),
|
||||||
|
"-p".to_string(),
|
||||||
|
config.page_size.clone(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if config.bit_depth == 16 {
|
||||||
|
args.push("-T".to_string());
|
||||||
|
} else {
|
||||||
|
args.push("-t".to_string());
|
||||||
|
}
|
||||||
|
args.push(config.dpi.to_string());
|
||||||
|
|
||||||
|
args.push(config.basename.clone());
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn run_targen(
|
pub async fn run_targen(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -113,6 +179,154 @@ pub async fn run_targen(
|
|||||||
state.spawn(app, id, binary, args, cwd).await
|
state.spawn(app, id, binary, args, cwd).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_printtarg(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ProcessManager>,
|
||||||
|
config: PrinttargConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let binary = resolve_binary(app.clone(), "printtarg".to_string()).await?;
|
||||||
|
let args = build_printtarg_args(&config);
|
||||||
|
let id = format!("printtarg_{}", config.basename);
|
||||||
|
|
||||||
|
let cwd = if config.cwd.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(config.cwd.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
state.spawn(app, id, binary, args, cwd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_file_base64(path: String) -> Result<String, String> {
|
||||||
|
use std::fs;
|
||||||
|
let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||||
|
use base64::Engine;
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ChartreadConfig {
|
||||||
|
pub basename: String,
|
||||||
|
pub cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"-v".to_string(),
|
||||||
|
"-u".to_string(),
|
||||||
|
config.basename.clone(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_chartread(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ProcessManager>,
|
||||||
|
config: ChartreadConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
let id = format!("chartread_{}", 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 ColprofConfig {
|
||||||
|
pub quality: String,
|
||||||
|
pub algorithm: String,
|
||||||
|
pub description: String,
|
||||||
|
pub copyright: Option<String>,
|
||||||
|
pub basename: String,
|
||||||
|
pub cwd: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_colprof_args(config: &ColprofConfig) -> Vec<String> {
|
||||||
|
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<String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -144,4 +358,84 @@ mod tests {
|
|||||||
let args = build_targen_args(&config);
|
let args = build_targen_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-d", "4", "-f", "1500", "-B", "8", "cmyk_profile"]);
|
assert_eq!(args, vec!["-v", "-d", "4", "-f", "1500", "-B", "8", "cmyk_profile"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_printtarg_args_i1_a4_8bit() {
|
||||||
|
let config = PrinttargConfig {
|
||||||
|
instrument: "i1".to_string(),
|
||||||
|
page_size: "A4".to_string(),
|
||||||
|
bit_depth: 8,
|
||||||
|
dpi: 100,
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/tmp".to_string(),
|
||||||
|
};
|
||||||
|
let args = build_printtarg_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-t", "100", "my_profile"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_printtarg_args_cm_letter_16bit() {
|
||||||
|
let config = PrinttargConfig {
|
||||||
|
instrument: "CM".to_string(),
|
||||||
|
page_size: "Letter".to_string(),
|
||||||
|
bit_depth: 16,
|
||||||
|
dpi: 300,
|
||||||
|
basename: "cmyk_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
};
|
||||||
|
let args = build_printtarg_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-T", "300", "cmyk_profile"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_printtarg_args_custom_page_size() {
|
||||||
|
let config = PrinttargConfig {
|
||||||
|
instrument: "SS".to_string(),
|
||||||
|
page_size: "200x400".to_string(),
|
||||||
|
bit_depth: 8,
|
||||||
|
dpi: 150,
|
||||||
|
basename: "custom_target".to_string(),
|
||||||
|
cwd: "/tmp".to_string(),
|
||||||
|
};
|
||||||
|
let args = build_printtarg_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chartread_args() {
|
||||||
|
let config = ChartreadConfig {
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
};
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ pub fn emit_error(app: &AppHandle, id: &str, error: String) {
|
|||||||
error: Some(error),
|
error: Some(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct JsonRowPayload {
|
||||||
|
pub id: String,
|
||||||
|
pub json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_json_row(app: &AppHandle, id: &str, json: String) {
|
||||||
|
let _ = app.emit("process:json_row", JsonRowPayload {
|
||||||
|
id: id.to_string(),
|
||||||
|
json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,7 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod events;
|
mod events;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -13,8 +14,16 @@ pub fn run() {
|
|||||||
commands::send_stdin,
|
commands::send_stdin,
|
||||||
commands::kill_process,
|
commands::kill_process,
|
||||||
commands::resolve_binary,
|
commands::resolve_binary,
|
||||||
commands::list_instruments,
|
commands::detect_instruments,
|
||||||
|
commands::extract_gamut,
|
||||||
commands::run_targen,
|
commands::run_targen,
|
||||||
|
commands::run_printtarg,
|
||||||
|
commands::read_file_base64,
|
||||||
|
commands::run_chartread,
|
||||||
|
commands::run_colprof,
|
||||||
|
commands::run_profcheck,
|
||||||
|
settings::load_settings,
|
||||||
|
settings::save_settings,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -38,9 +38,15 @@ impl ProcessManager {
|
|||||||
let id_clone = id.clone();
|
let id_clone = id.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
|
||||||
let mut reader = BufReader::new(stdout).lines();
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
while let Ok(Some(line)) = reader.next_line().await {
|
while let Ok(Some(line)) = reader.next_line().await {
|
||||||
emit_stdout(&app_clone, &id_clone, line);
|
if line.starts_with(JSON_ROW_PREFIX) {
|
||||||
|
let json_str = line[JSON_ROW_PREFIX.len()..].to_string();
|
||||||
|
crate::events::emit_json_row(&app_clone, &id_clone, json_str);
|
||||||
|
} else {
|
||||||
|
emit_stdout(&app_clone, &id_clone, line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
|
||||||
|
pub struct AppSettings {
|
||||||
|
pub argyll_binary_dir: Option<String>,
|
||||||
|
pub default_instrument: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
|
||||||
|
let path = app.path().app_data_dir().unwrap().join("settings.json");
|
||||||
|
if let Ok(data) = fs::read_to_string(path) {
|
||||||
|
Ok(serde_json::from_str(&data).unwrap_or_default())
|
||||||
|
} else {
|
||||||
|
Ok(AppSettings::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
|
||||||
|
let path = app.path().app_data_dir().unwrap();
|
||||||
|
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||||
|
let json = serde_json::to_string_pretty(&settings).unwrap();
|
||||||
|
fs::write(path.join("settings.json"), json).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
+243
-9
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ICCery</title>
|
<title>ICCery</title>
|
||||||
<link rel="stylesheet" href="./styles/main.css" />
|
<link rel="stylesheet" href="./styles/main.css" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||||
|
<script src="./js/delaunator.min.js"></script>
|
||||||
<script type="module" src="./js/app.js" defer></script>
|
<script type="module" src="./js/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,6 +15,7 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<h1>ICCery</h1>
|
<h1>ICCery</h1>
|
||||||
|
<button id="openSettingsBtn" class="icon-btn" title="Settings">⚙️</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="stepper">
|
<nav class="stepper">
|
||||||
<button class="step active" data-step="1">1. Generate Target</button>
|
<button class="step active" data-step="1">1. Generate Target</button>
|
||||||
@@ -84,32 +88,262 @@
|
|||||||
<!-- Stage 2: printtarg -->
|
<!-- Stage 2: printtarg -->
|
||||||
<section id="stage-2" class="stage hidden">
|
<section id="stage-2" class="stage hidden">
|
||||||
<h2>Print Layout</h2>
|
<h2>Print Layout</h2>
|
||||||
<p>Prepare the target for printing based on your instrument.</p>
|
<p>Configure the target layout for your instrument and paper size, then generate printable TIFF images.</p>
|
||||||
<button class="primary" id="btnLayout">Create Layout (.ti2, .tif)</button>
|
|
||||||
|
<!-- Colour management warning banner -->
|
||||||
|
<div class="warning-banner" id="cmWarningBanner">
|
||||||
|
<span class="warning-icon">⚠️</span>
|
||||||
|
<div class="warning-text">
|
||||||
|
<strong>CRITICAL: Disable ALL printer driver colour management when printing these targets.</strong>
|
||||||
|
<p>Set your printer driver to "No Colour Adjustment" (Epson), "Off (No Colour Adjustment)" (Canon), or the equivalent setting for your printer. Failure to do this will produce an incorrect profile.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Measurement Instrument</label>
|
||||||
|
<select id="instrumentSelect">
|
||||||
|
<option value="i1" selected>X-Rite i1Pro / i1Pro2</option>
|
||||||
|
<option value="p3">X-Rite i1Pro3+</option>
|
||||||
|
<option value="CM">X-Rite ColorMunki</option>
|
||||||
|
<option value="SS">X-Rite SpectroScan</option>
|
||||||
|
<option value="20">X-Rite DTP20</option>
|
||||||
|
<option value="22">X-Rite DTP22</option>
|
||||||
|
<option value="41">X-Rite DTP41</option>
|
||||||
|
<option value="51">X-Rite DTP51</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Page Size</label>
|
||||||
|
<select id="pageSizeSelect">
|
||||||
|
<option value="A4" selected>A4 [210 × 297 mm]</option>
|
||||||
|
<option value="A4R">A4 Landscape [297 × 210 mm]</option>
|
||||||
|
<option value="A3">A3 [297 × 420 mm]</option>
|
||||||
|
<option value="A2">A2 [420 × 594 mm]</option>
|
||||||
|
<option value="Letter">US Letter [215.9 × 279.4 mm]</option>
|
||||||
|
<option value="LetterR">US Letter Landscape [279.4 × 215.9 mm]</option>
|
||||||
|
<option value="Legal">US Legal [215.9 × 355.6 mm]</option>
|
||||||
|
<option value="4x6">4×6 [101.6 × 152.4 mm]</option>
|
||||||
|
<option value="11x17">11×17 [279.4 × 431.8 mm]</option>
|
||||||
|
<option value="custom">Custom...</option>
|
||||||
|
</select>
|
||||||
|
<div id="customPageSizeRow" class="input-row hidden" style="margin-top: 8px;">
|
||||||
|
<div>
|
||||||
|
<label for="customPageW" class="sub-label">Width (mm)</label>
|
||||||
|
<input type="number" id="customPageW" min="50" max="2000" placeholder="e.g. 200">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="customPageH" class="sub-label">Height (mm)</label>
|
||||||
|
<input type="number" id="customPageH" min="50" max="2000" placeholder="e.g. 400">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>TIFF Output</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<div>
|
||||||
|
<label class="sub-label">Bit Depth</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label><input type="radio" name="bitDepth" value="8" checked> 8-bit</label>
|
||||||
|
<label><input type="radio" name="bitDepth" value="16"> 16-bit</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tiffDpi" class="sub-label">Resolution (DPI)</label>
|
||||||
|
<select id="tiffDpi">
|
||||||
|
<option value="100">100 DPI (Draft)</option>
|
||||||
|
<option value="200" selected>200 DPI (Standard)</option>
|
||||||
|
<option value="300">300 DPI (High Quality)</option>
|
||||||
|
<option value="600">600 DPI (Ultra)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||||
|
|
||||||
|
<div class="log-container hidden" id="printtargLogContainer">
|
||||||
|
<h4>Process Output</h4>
|
||||||
|
<pre id="printtargLog"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TIFF preview gallery (populated dynamically after generation) -->
|
||||||
|
<div class="tiff-gallery hidden" id="tiffGallery">
|
||||||
|
<h3>Generated Target Pages</h3>
|
||||||
|
<div class="gallery-info" id="galleryInfo"></div>
|
||||||
|
<div class="gallery-grid" id="galleryGrid"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Stage 3: chartread -->
|
<!-- Stage 3: chartread -->
|
||||||
<section id="stage-3" class="stage hidden">
|
<section id="stage-3" class="stage hidden">
|
||||||
<h2>Measure Target</h2>
|
<h2>Measure Target</h2>
|
||||||
<p>Read the printed patches with your instrument.</p>
|
<p>Read the printed target patches with your spectrophotometer.</p>
|
||||||
|
|
||||||
|
<!-- State & Prompt Area -->
|
||||||
|
<div class="chartread-status">
|
||||||
|
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||||
|
<div class="status-prompt" id="chartreadPrompt">
|
||||||
|
Press 'Start Measurement' to begin reading the printed target.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="chartread-actions">
|
||||||
|
<button class="primary" id="btnStartRead">Start Measurement</button>
|
||||||
|
<button class="primary hidden" id="btnCalibrate">✓ Calibrate</button>
|
||||||
|
<button class="secondary hidden" id="btnRetry">↻ Retry Strip</button>
|
||||||
|
<button class="secondary hidden" id="btnSkip">⏭ Skip Strip</button>
|
||||||
|
<button class="danger hidden" id="btnCancel">✕ Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="read-progress-container hidden" id="readProgressContainer">
|
||||||
|
<div class="read-progress-bar" id="readProgress" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="read-progress-text" id="readProgressText"></div>
|
||||||
|
|
||||||
|
<!-- Live Stats -->
|
||||||
|
<div class="read-stats" id="readStats"></div>
|
||||||
|
|
||||||
|
<!-- Swatch Grid -->
|
||||||
<div class="swatch-grid" id="swatchGrid"></div>
|
<div class="swatch-grid" id="swatchGrid"></div>
|
||||||
<button class="primary" id="btnRead">Start Reading (.ti3)</button>
|
|
||||||
|
<!-- Process Log -->
|
||||||
|
<div class="log-container hidden" id="chartreadLogContainer">
|
||||||
|
<h4>Process Output</h4>
|
||||||
|
<pre id="chartreadLog"></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Stage 4: colprof -->
|
<!-- Stage 4: colprof -->
|
||||||
<section id="stage-4" class="stage hidden">
|
<section id="stage-4" class="stage hidden">
|
||||||
<h2>Create Profile</h2>
|
<h2>Create Profile</h2>
|
||||||
<p>Calculate the ICC profile.</p>
|
<p>Calculate the ICC profile from your target measurement data.</p>
|
||||||
<button class="primary" id="btnProfile">Create Profile (.icc)</button>
|
|
||||||
|
<div class="form-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Quality</label>
|
||||||
|
<select id="colprofQuality">
|
||||||
|
<option value="l">Low (Fast draft)</option>
|
||||||
|
<option value="m" selected>Medium (Standard)</option>
|
||||||
|
<option value="h">High (Recommended)</option>
|
||||||
|
<option value="u">Ultra (Maximum precision - slow)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Description</label>
|
||||||
|
<input type="text" id="colprofDescription" placeholder="e.g. Epson P600 Lustre Photo">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Copyright / Creator (Optional)</label>
|
||||||
|
<input type="text" id="colprofCopyright" placeholder="e.g. Copyright (c) 2026 My Studio">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Algorithm Type</label>
|
||||||
|
<select id="colprofAlgorithm">
|
||||||
|
<option value="l" selected>Lab cLUT (Standard Lookup Table)</option>
|
||||||
|
<option value="x">XYZ cLUT</option>
|
||||||
|
<option value="X">Display XYZ cLUT + Matrix</option>
|
||||||
|
<option value="m">Matrix Only (Simple)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
||||||
|
|
||||||
|
<!-- Spinner and Progress Stage -->
|
||||||
|
<div class="spinner-container hidden" id="colprofSpinnerContainer">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="stage-label" id="colprofStageLabel">Calculating ICC Profile...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Card -->
|
||||||
|
<div class="success-card hidden" id="colprofSuccessCard">
|
||||||
|
<h3>🎉 Profile Calculation Complete</h3>
|
||||||
|
<p id="colprofSuccessInfo"></p>
|
||||||
|
<button class="primary" id="btnGoToVerify">Proceed to Verification →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Process Log -->
|
||||||
|
<div class="log-container hidden" id="colprofLogContainer">
|
||||||
|
<h4>Process Output</h4>
|
||||||
|
<pre id="colprofLog"></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Stage 5: profcheck -->
|
<!-- Stage 5: profcheck -->
|
||||||
<section id="stage-5" class="stage hidden">
|
<section id="stage-5" class="stage hidden">
|
||||||
<h2>Verify</h2>
|
<h2>Verify Profile</h2>
|
||||||
<p>Analyze the generated profile quality.</p>
|
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||||
<button class="primary" id="btnVerify">Verify Profile</button>
|
|
||||||
|
<button class="primary" id="btnVerify">Run Profile Verification</button>
|
||||||
|
|
||||||
|
<!-- Report Card -->
|
||||||
|
<div class="report-card hidden" id="profcheckReportCard">
|
||||||
|
<div class="report-header">
|
||||||
|
<h3>Profile Quality Report</h3>
|
||||||
|
<div id="profcheckBadge" class="report-badge">GOOD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-metrics">
|
||||||
|
<div class="metric-box">
|
||||||
|
<span class="metric-value" id="profcheckAvgDe">0.00</span>
|
||||||
|
<span class="metric-label">Average ΔE₀₀</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<span class="metric-value" id="profcheckMaxDe">0.00</span>
|
||||||
|
<span class="metric-label">Peak ΔE₀₀</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<span class="metric-value" id="profcheckRmsDe">0.00</span>
|
||||||
|
<span class="metric-label">RMS ΔE₀₀</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-footnote">
|
||||||
|
Professional printer profiles typically achieve average ΔE₀₀ < 1.0, and acceptable profiles < 2.0.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3D Gamut Viewer -->
|
||||||
|
<div class="gamut-viewer-section">
|
||||||
|
<h3>3D Gamut Visualization (CIELAB)</h3>
|
||||||
|
<div id="gamutViewerContainer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Process Log -->
|
||||||
|
<div class="log-container hidden" id="profcheckLogContainer">
|
||||||
|
<h4>Verification Output</h4>
|
||||||
|
<pre id="profcheckLog"></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Dialog -->
|
||||||
|
<dialog id="settingsDialog" class="settings-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Application Settings</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
|
||||||
|
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="default_instrument">Default Instrument Override</label>
|
||||||
|
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="saveSettingsBtn" class="primary">Save Settings</button>
|
||||||
|
<button id="closeSettingsBtn" class="secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+26
-4
@@ -1,4 +1,10 @@
|
|||||||
import { initTargen } from './targen.js';
|
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';
|
||||||
|
import { initSettings } from './settings.js';
|
||||||
|
import { initGamutViewer } from './gamut_viewer.js';
|
||||||
|
|
||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
@@ -20,11 +26,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
step.classList.add('active');
|
step.classList.add('active');
|
||||||
const targetStage = document.getElementById(`stage-${targetStep}`);
|
const targetStage = document.getElementById(`stage-${targetStep}`);
|
||||||
targetStage.classList.remove('hidden');
|
if (targetStage) {
|
||||||
targetStage.classList.add('active');
|
targetStage.classList.remove('hidden');
|
||||||
|
targetStage.classList.add('active');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Stage 1
|
const safeInit = (name, initFn) => {
|
||||||
initTargen();
|
try {
|
||||||
|
initFn();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ICCery Init Error] Failed to initialize ${name}:`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize all stages & features safely
|
||||||
|
safeInit('Stage 1 (Targen)', initTargen);
|
||||||
|
safeInit('Stage 2 (Printtarg)', initPrinttarg);
|
||||||
|
safeInit('Stage 3 (Chartread)', initChartread);
|
||||||
|
safeInit('Stage 4 (Colprof)', initColprof);
|
||||||
|
safeInit('Stage 5 (Profcheck)', initProfcheck);
|
||||||
|
safeInit('Settings', initSettings);
|
||||||
|
safeInit('Gamut Viewer', initGamutViewer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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 = "";
|
||||||
|
let stage2Cwd = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by printtarg.js after Stage 2 completes.
|
||||||
|
*/
|
||||||
|
export function setStage2Result(basename, cwd) {
|
||||||
|
stage2Basename = basename;
|
||||||
|
stage2Cwd = cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State machine states
|
||||||
|
const STATE = {
|
||||||
|
IDLE: "IDLE",
|
||||||
|
CALIBRATING: "CALIBRATING",
|
||||||
|
AWAITING_STRIP: "AWAITING_STRIP",
|
||||||
|
READING: "READING",
|
||||||
|
ERROR: "ERROR",
|
||||||
|
FINISHED: "FINISHED",
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentState = STATE.IDLE;
|
||||||
|
let currentProcessId = "";
|
||||||
|
|
||||||
|
export function initChartread() {
|
||||||
|
const btnStartRead = document.getElementById("btnStartRead");
|
||||||
|
const btnCalibrate = document.getElementById("btnCalibrate");
|
||||||
|
const btnRetry = document.getElementById("btnRetry");
|
||||||
|
const btnSkip = document.getElementById("btnSkip");
|
||||||
|
const btnCancel = document.getElementById("btnCancel");
|
||||||
|
const promptText = document.getElementById("chartreadPrompt");
|
||||||
|
const stateLabel = document.getElementById("chartreadState");
|
||||||
|
const logContainer = document.getElementById("chartreadLogContainer");
|
||||||
|
const logPre = document.getElementById("chartreadLog");
|
||||||
|
|
||||||
|
function setState(newState) {
|
||||||
|
currentState = newState;
|
||||||
|
if (stateLabel) stateLabel.textContent = newState;
|
||||||
|
|
||||||
|
// Show/hide buttons based on state
|
||||||
|
btnCalibrate.classList.add("hidden");
|
||||||
|
btnRetry.classList.add("hidden");
|
||||||
|
btnSkip.classList.add("hidden");
|
||||||
|
btnCancel.classList.add("hidden");
|
||||||
|
btnStartRead.classList.add("hidden");
|
||||||
|
|
||||||
|
switch (newState) {
|
||||||
|
case STATE.IDLE:
|
||||||
|
btnStartRead.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.CALIBRATING:
|
||||||
|
btnCalibrate.classList.remove("hidden");
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.AWAITING_STRIP:
|
||||||
|
btnRetry.classList.remove("hidden");
|
||||||
|
btnSkip.classList.remove("hidden");
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.READING:
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.ERROR:
|
||||||
|
btnRetry.classList.remove("hidden");
|
||||||
|
btnSkip.classList.remove("hidden");
|
||||||
|
btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.FINISHED:
|
||||||
|
btnStartRead.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrompt(text) {
|
||||||
|
if (promptText) promptText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start reading button
|
||||||
|
if (btnStartRead) {
|
||||||
|
btnStartRead.addEventListener("click", async () => {
|
||||||
|
if (!stage2Basename) {
|
||||||
|
setPrompt("Error: No .ti2 file available. Complete Stage 2 first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logPre.textContent = "";
|
||||||
|
logContainer.classList.remove("hidden");
|
||||||
|
setState(STATE.CALIBRATING);
|
||||||
|
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
basename: stage2Basename,
|
||||||
|
cwd: stage2Cwd,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentProcessId = `chartread_${stage2Basename}`;
|
||||||
|
|
||||||
|
// Start swatch grid listener
|
||||||
|
await startSwatchListener(currentProcessId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unlistenStdout = await listen("process:stdout", (event) => {
|
||||||
|
if (event.payload.id !== currentProcessId || !event.payload.line) return;
|
||||||
|
const line = event.payload.line;
|
||||||
|
|
||||||
|
logPre.textContent += line + "\n";
|
||||||
|
logPre.scrollTop = logPre.scrollHeight;
|
||||||
|
|
||||||
|
// Parse prompts for state transitions
|
||||||
|
const lineLower = line.toLowerCase();
|
||||||
|
|
||||||
|
if (lineLower.includes("calibrat") && lineLower.includes("place")) {
|
||||||
|
setState(STATE.CALIBRATING);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) {
|
||||||
|
setState(STATE.AWAITING_STRIP);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("ready to read")) {
|
||||||
|
setState(STATE.AWAITING_STRIP);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
|
||||||
|
setState(STATE.READING);
|
||||||
|
setPrompt(line);
|
||||||
|
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread")) {
|
||||||
|
setState(STATE.ERROR);
|
||||||
|
setPrompt("⚠️ " + line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||||
|
if (event.payload.id === currentProcessId && 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 !== currentProcessId) return;
|
||||||
|
unlistenStdout();
|
||||||
|
unlistenStderr();
|
||||||
|
unlistenExit();
|
||||||
|
stopSwatchListener();
|
||||||
|
|
||||||
|
if (event.payload.code === 0) {
|
||||||
|
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);
|
||||||
|
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
||||||
|
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("run_chartread", { config });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setPrompt(`Invoke error: ${err}`);
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calibrate / confirm button — sends space + newline
|
||||||
|
if (btnCalibrate) {
|
||||||
|
btnCalibrate.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: " \n" });
|
||||||
|
} catch (e) { console.error("send_stdin error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry button — sends space + newline (same as confirm)
|
||||||
|
if (btnRetry) {
|
||||||
|
btnRetry.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: " \n" });
|
||||||
|
setState(STATE.READING);
|
||||||
|
setPrompt("Retrying strip read...");
|
||||||
|
} catch (e) { console.error("send_stdin error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip button — sends "s\n"
|
||||||
|
if (btnSkip) {
|
||||||
|
btnSkip.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: "s\n" });
|
||||||
|
setState(STATE.AWAITING_STRIP);
|
||||||
|
setPrompt("Skipped current strip. Awaiting next...");
|
||||||
|
} catch (e) { console.error("send_stdin error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel button — kills the process
|
||||||
|
if (btnCancel) {
|
||||||
|
btnCancel.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await invoke("kill_process", { id: currentProcessId });
|
||||||
|
stopSwatchListener();
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
setPrompt("Measurement cancelled.");
|
||||||
|
} catch (e) { console.error("kill_process error:", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start in IDLE state
|
||||||
|
setState(STATE.IDLE);
|
||||||
|
setPrompt("Press 'Start Measurement' to begin reading the printed target.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceToStage4() {
|
||||||
|
const steps = document.querySelectorAll('.step');
|
||||||
|
const stages = document.querySelectorAll('.stage');
|
||||||
|
|
||||||
|
steps.forEach(s => s.classList.remove('active'));
|
||||||
|
if (steps[3]) steps[3].classList.add('active');
|
||||||
|
|
||||||
|
stages.forEach(s => {
|
||||||
|
s.classList.remove('active');
|
||||||
|
s.classList.add('hidden');
|
||||||
|
});
|
||||||
|
if (stages[3]) {
|
||||||
|
stages[3].classList.remove('hidden');
|
||||||
|
stages[3].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Convert CIE L*a*b* to sRGB [0–255] clamped values.
|
||||||
|
* Uses D50 illuminant reference white (standard for ICC profiles).
|
||||||
|
* @param {number} L - Lightness [0, 100]
|
||||||
|
* @param {number} a - Green-red axis [-128, 127]
|
||||||
|
* @param {number} b - Blue-yellow axis [-128, 127]
|
||||||
|
* @returns {number[]} [r, g, b] each in [0, 255]
|
||||||
|
*/
|
||||||
|
export function labToSrgb(L, a, b) {
|
||||||
|
// D50 reference white
|
||||||
|
const Xn = 0.9642;
|
||||||
|
const Yn = 1.0;
|
||||||
|
const Zn = 0.8249;
|
||||||
|
|
||||||
|
// Lab → XYZ
|
||||||
|
const fy = (L + 16) / 116;
|
||||||
|
const fx = a / 500 + fy;
|
||||||
|
const fz = fy - b / 200;
|
||||||
|
|
||||||
|
const delta = 6 / 29;
|
||||||
|
const delta3 = delta * delta * delta;
|
||||||
|
|
||||||
|
const X = Xn * (fx > delta ? fx * fx * fx : (fx - 16 / 116) * 3 * delta * delta);
|
||||||
|
const Y = Yn * (fy > delta ? fy * fy * fy : (fy - 16 / 116) * 3 * delta * delta);
|
||||||
|
const Z = Zn * (fz > delta ? fz * fz * fz : (fz - 16 / 116) * 3 * delta * delta);
|
||||||
|
|
||||||
|
// XYZ (D50) → linear sRGB via Bradford-adapted D50→D65 matrix
|
||||||
|
// Combined D50-adapted XYZ to sRGB matrix
|
||||||
|
const lr = 3.1338561 * X - 1.6168667 * Y - 0.4906146 * Z;
|
||||||
|
const lg = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
|
||||||
|
const lb = 0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
|
||||||
|
|
||||||
|
// Linear sRGB → gamma-corrected sRGB
|
||||||
|
function gammaCorrect(c) {
|
||||||
|
return c <= 0.0031308
|
||||||
|
? 12.92 * c
|
||||||
|
: 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Math.round(Math.max(0, Math.min(255, gammaCorrect(lr) * 255)));
|
||||||
|
const g = Math.round(Math.max(0, Math.min(255, gammaCorrect(lg) * 255)));
|
||||||
|
const bVal = Math.round(Math.max(0, Math.min(255, gammaCorrect(lb) * 255)));
|
||||||
|
|
||||||
|
return [r, g, bVal];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert device RGB percentages [0–100] to CSS rgb string.
|
||||||
|
* @param {number[]} device - [R%, G%, B%] each in [0, 100]
|
||||||
|
* @returns {string} CSS rgb() string
|
||||||
|
*/
|
||||||
|
export function deviceRgbToCss(device) {
|
||||||
|
const r = Math.round((device[0] / 100) * 255);
|
||||||
|
const g = Math.round((device[1] / 100) * 255);
|
||||||
|
const b = Math.round((device[2] / 100) * 255);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Lab triplet to CSS rgb string.
|
||||||
|
* @param {number[]} lab - [L, a, b]
|
||||||
|
* @returns {string} CSS rgb() string
|
||||||
|
*/
|
||||||
|
export function labToCss(lab) {
|
||||||
|
const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Compute CIEDE2000 colour difference (ΔE₀₀) between two L*a*b* values.
|
||||||
|
* Standard parametric factors: kL=1, kC=1, kH=1.
|
||||||
|
*
|
||||||
|
* Reference: Sharma, Wu, Dalal (2005) "The CIEDE2000 Color-Difference Formula"
|
||||||
|
*
|
||||||
|
* @param {number[]} lab1 - [L1, a1, b1]
|
||||||
|
* @param {number[]} lab2 - [L2, a2, b2]
|
||||||
|
* @returns {number} ΔE₀₀ value
|
||||||
|
*/
|
||||||
|
export function computeDeltaE00(lab1, lab2) {
|
||||||
|
const [L1, a1, b1] = lab1;
|
||||||
|
const [L2, a2, b2] = lab2;
|
||||||
|
|
||||||
|
const kL = 1, kC = 1, kH = 1;
|
||||||
|
|
||||||
|
const C1ab = Math.sqrt(a1 * a1 + b1 * b1);
|
||||||
|
const C2ab = Math.sqrt(a2 * a2 + b2 * b2);
|
||||||
|
const Cab_avg = (C1ab + C2ab) / 2;
|
||||||
|
|
||||||
|
const Cab_avg7 = Math.pow(Cab_avg, 7);
|
||||||
|
const G = 0.5 * (1 - Math.sqrt(Cab_avg7 / (Cab_avg7 + Math.pow(25, 7))));
|
||||||
|
|
||||||
|
const a1p = a1 * (1 + G);
|
||||||
|
const a2p = a2 * (1 + G);
|
||||||
|
|
||||||
|
const C1p = Math.sqrt(a1p * a1p + b1 * b1);
|
||||||
|
const C2p = Math.sqrt(a2p * a2p + b2 * b2);
|
||||||
|
|
||||||
|
let h1p = Math.atan2(b1, a1p) * (180 / Math.PI);
|
||||||
|
if (h1p < 0) h1p += 360;
|
||||||
|
let h2p = Math.atan2(b2, a2p) * (180 / Math.PI);
|
||||||
|
if (h2p < 0) h2p += 360;
|
||||||
|
|
||||||
|
const dLp = L2 - L1;
|
||||||
|
const dCp = C2p - C1p;
|
||||||
|
|
||||||
|
let dhp;
|
||||||
|
if (C1p * C2p === 0) {
|
||||||
|
dhp = 0;
|
||||||
|
} else if (Math.abs(h2p - h1p) <= 180) {
|
||||||
|
dhp = h2p - h1p;
|
||||||
|
} else if (h2p - h1p > 180) {
|
||||||
|
dhp = h2p - h1p - 360;
|
||||||
|
} else {
|
||||||
|
dhp = h2p - h1p + 360;
|
||||||
|
}
|
||||||
|
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2);
|
||||||
|
|
||||||
|
const Lp_avg = (L1 + L2) / 2;
|
||||||
|
const Cp_avg = (C1p + C2p) / 2;
|
||||||
|
|
||||||
|
let hp_avg;
|
||||||
|
if (C1p * C2p === 0) {
|
||||||
|
hp_avg = h1p + h2p;
|
||||||
|
} else if (Math.abs(h1p - h2p) <= 180) {
|
||||||
|
hp_avg = (h1p + h2p) / 2;
|
||||||
|
} else if (h1p + h2p < 360) {
|
||||||
|
hp_avg = (h1p + h2p + 360) / 2;
|
||||||
|
} else {
|
||||||
|
hp_avg = (h1p + h2p - 360) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T = 1
|
||||||
|
- 0.17 * Math.cos((hp_avg - 30) * Math.PI / 180)
|
||||||
|
+ 0.24 * Math.cos(2 * hp_avg * Math.PI / 180)
|
||||||
|
+ 0.32 * Math.cos((3 * hp_avg + 6) * Math.PI / 180)
|
||||||
|
- 0.20 * Math.cos((4 * hp_avg - 63) * Math.PI / 180);
|
||||||
|
|
||||||
|
const SL = 1 + (0.015 * Math.pow(Lp_avg - 50, 2)) / Math.sqrt(20 + Math.pow(Lp_avg - 50, 2));
|
||||||
|
const SC = 1 + 0.045 * Cp_avg;
|
||||||
|
const SH = 1 + 0.015 * Cp_avg * T;
|
||||||
|
|
||||||
|
const Cp_avg7 = Math.pow(Cp_avg, 7);
|
||||||
|
const RT_term = -2 * Math.sqrt(Cp_avg7 / (Cp_avg7 + Math.pow(25, 7)))
|
||||||
|
* Math.sin(60 * Math.exp(-Math.pow((hp_avg - 275) / 25, 2)) * Math.PI / 180);
|
||||||
|
|
||||||
|
const dE = Math.sqrt(
|
||||||
|
Math.pow(dLp / (kL * SL), 2) +
|
||||||
|
Math.pow(dCp / (kC * SC), 2) +
|
||||||
|
Math.pow(dHp / (kH * SH), 2) +
|
||||||
|
RT_term * (dCp / (kC * SC)) * (dHp / (kH * SH))
|
||||||
|
);
|
||||||
|
|
||||||
|
return dE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
|
||||||
|
let scene, camera, renderer, controls;
|
||||||
|
let currentProfileMesh, sRgbMesh;
|
||||||
|
|
||||||
|
export async function initGamutViewer() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('gamutViewerContainer');
|
||||||
|
if (!container || typeof THREE === 'undefined') return;
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x111116);
|
||||||
|
|
||||||
|
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 1000);
|
||||||
|
camera.position.set(150, 100, 150);
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
if (typeof THREE.OrbitControls !== 'undefined') {
|
||||||
|
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CIELAB orientation helpers (Axes: X=a*, Y=L*, Z=b*)
|
||||||
|
const axesHelper = new THREE.AxesHelper(80);
|
||||||
|
scene.add(axesHelper);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
dirLight.position.set(100, 200, 100);
|
||||||
|
scene.add(dirLight);
|
||||||
|
|
||||||
|
animate();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Gamut viewer initialization notice:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
if (controls) controls.update();
|
||||||
|
if (renderer && scene && camera) renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCGATS(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
let dataStarted = false;
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('BEGIN_DATA')) {
|
||||||
|
dataStarted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('END_DATA')) break;
|
||||||
|
|
||||||
|
if (dataStarted) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const L = parseFloat(parts[1]);
|
||||||
|
const a = parseFloat(parts[2]);
|
||||||
|
const b = parseFloat(parts[3]);
|
||||||
|
if (!isNaN(L) && !isNaN(a) && !isNaN(b)) {
|
||||||
|
points.push({ L, a, b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGamutMesh(gamFilePath, color = 0x3b82f6, isWireframe = false) {
|
||||||
|
try {
|
||||||
|
const base64Data = await invoke('read_file_base64', { path: gamFilePath });
|
||||||
|
const text = atob(base64Data);
|
||||||
|
const points = parseCGATS(text);
|
||||||
|
if (points.length < 3) return null;
|
||||||
|
|
||||||
|
const coords = [];
|
||||||
|
const vertices = new Float32Array(points.length * 3);
|
||||||
|
|
||||||
|
points.forEach((pt, i) => {
|
||||||
|
coords.push(pt.a, pt.b);
|
||||||
|
vertices[i * 3] = pt.a; // X = a*
|
||||||
|
vertices[i * 3 + 1] = pt.L; // Y = L*
|
||||||
|
vertices[i * 3 + 2] = pt.b; // Z = b*
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof Delaunator === 'undefined') return null;
|
||||||
|
const delaunay = new Delaunator(coords);
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
|
||||||
|
geometry.setIndex(Array.from(delaunay.triangles));
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
const material = new THREE.MeshLambertMaterial({
|
||||||
|
color: color,
|
||||||
|
wireframe: isWireframe,
|
||||||
|
transparent: true,
|
||||||
|
opacity: isWireframe ? 0.4 : 0.75,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load gamut mesh:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
import { setStage2Result } from './chartread.js';
|
||||||
|
|
||||||
|
// Module-level state: set by Stage 1 when it completes
|
||||||
|
let stage1Basename = "";
|
||||||
|
let stage1Cwd = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by targen.js (or app.js) after Stage 1 completes.
|
||||||
|
* Passes the basename and working directory forward.
|
||||||
|
*/
|
||||||
|
export function setStage1Result(basename, cwd) {
|
||||||
|
stage1Basename = basename;
|
||||||
|
stage1Cwd = cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPrinttarg() {
|
||||||
|
const instrumentSelect = document.getElementById("instrumentSelect");
|
||||||
|
const pageSizeSelect = document.getElementById("pageSizeSelect");
|
||||||
|
const customPageSizeRow = document.getElementById("customPageSizeRow");
|
||||||
|
const customPageW = document.getElementById("customPageW");
|
||||||
|
const customPageH = document.getElementById("customPageH");
|
||||||
|
const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]');
|
||||||
|
const tiffDpi = document.getElementById("tiffDpi");
|
||||||
|
const btnCreateLayout = document.getElementById("btnCreateLayout");
|
||||||
|
const logContainer = document.getElementById("printtargLogContainer");
|
||||||
|
const logPre = document.getElementById("printtargLog");
|
||||||
|
const tiffGallery = document.getElementById("tiffGallery");
|
||||||
|
const galleryInfo = document.getElementById("galleryInfo");
|
||||||
|
const galleryGrid = document.getElementById("galleryGrid");
|
||||||
|
|
||||||
|
// Show/hide custom page size inputs
|
||||||
|
pageSizeSelect.addEventListener("change", (e) => {
|
||||||
|
if (e.target.value === "custom") {
|
||||||
|
customPageSizeRow.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
customPageSizeRow.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Layout button
|
||||||
|
btnCreateLayout.addEventListener("click", async () => {
|
||||||
|
// Validate that Stage 1 has been completed
|
||||||
|
if (!stage1Basename) {
|
||||||
|
logPre.textContent = "[ERROR] No .ti1 file available. Complete Stage 1 first.\n";
|
||||||
|
logContainer.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logPre.textContent = "";
|
||||||
|
logContainer.classList.remove("hidden");
|
||||||
|
tiffGallery.classList.add("hidden");
|
||||||
|
galleryGrid.innerHTML = "";
|
||||||
|
btnCreateLayout.disabled = true;
|
||||||
|
|
||||||
|
// Determine page size
|
||||||
|
let pageSize = pageSizeSelect.value;
|
||||||
|
if (pageSize === "custom") {
|
||||||
|
const w = parseInt(customPageW.value, 10);
|
||||||
|
const h = parseInt(customPageH.value, 10);
|
||||||
|
if (!w || !h || w < 50 || h < 50) {
|
||||||
|
logPre.textContent = "[ERROR] Custom page size must have width and height ≥ 50mm.\n";
|
||||||
|
btnCreateLayout.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageSize = `${w}x${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine bit depth
|
||||||
|
let bitDepth = 8;
|
||||||
|
bitDepthRadios.forEach(radio => {
|
||||||
|
if (radio.checked) bitDepth = parseInt(radio.value, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
instrument: instrumentSelect.value,
|
||||||
|
page_size: pageSize,
|
||||||
|
bit_depth: bitDepth,
|
||||||
|
dpi: parseInt(tiffDpi.value, 10),
|
||||||
|
basename: stage1Basename,
|
||||||
|
cwd: stage1Cwd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const processId = `printtarg_${stage1Basename}`;
|
||||||
|
|
||||||
|
// Accumulate all stdout lines to extract JSON manifest at the end
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (event.payload.code === 0) {
|
||||||
|
logPre.textContent += "\n[SUCCESS] printtarg completed successfully.\n";
|
||||||
|
btnCreateLayout.disabled = false;
|
||||||
|
|
||||||
|
// Parse the JSON manifest from stdout
|
||||||
|
const manifest = extractManifest(stdoutAccumulator);
|
||||||
|
if (manifest && manifest.pages && manifest.pages.length > 0) {
|
||||||
|
renderTiffGallery(manifest, config.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage2Result(stage1Basename, stage1Cwd);
|
||||||
|
advanceToStage3();
|
||||||
|
} else {
|
||||||
|
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
|
||||||
|
btnCreateLayout.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logPre.textContent = "Starting printtarg...\n";
|
||||||
|
await invoke("run_printtarg", { config });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
|
||||||
|
btnCreateLayout.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the JSON manifest object from the accumulated stdout.
|
||||||
|
* The manifest is emitted by printtarg -u as a JSON block starting with { and ending with }.
|
||||||
|
* It always appears at the end of stdout, after all "Creating file..." lines.
|
||||||
|
*/
|
||||||
|
function extractManifest(stdout) {
|
||||||
|
try {
|
||||||
|
// Find the last JSON object in the output
|
||||||
|
const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"');
|
||||||
|
if (jsonStart === -1) return null;
|
||||||
|
const jsonEnd = stdout.indexOf('\n}', jsonStart);
|
||||||
|
if (jsonEnd === -1) return null;
|
||||||
|
const jsonStr = stdout.substring(jsonStart, jsonEnd + 2);
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse printtarg manifest:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render TIFF preview gallery using base64-encoded images from the backend.
|
||||||
|
* @param {Object} manifest - The parsed JSON manifest from printtarg -u
|
||||||
|
* @param {string} cwd - The working directory where TIFFs were generated
|
||||||
|
*/
|
||||||
|
async function renderTiffGallery(manifest, cwd) {
|
||||||
|
tiffGallery.classList.remove("hidden");
|
||||||
|
|
||||||
|
const pageCount = manifest.pages.length;
|
||||||
|
const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0);
|
||||||
|
const dims = manifest.pages[0];
|
||||||
|
galleryInfo.textContent = `${pageCount} page(s) · ${totalPatches} patches · ${dims.width_mm} × ${dims.height_mm} mm per page`;
|
||||||
|
|
||||||
|
for (const page of manifest.pages) {
|
||||||
|
const sep = cwd.includes('\\') ? '\\' : '/';
|
||||||
|
const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename;
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "gallery-card";
|
||||||
|
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "gallery-label";
|
||||||
|
label.textContent = `${page.filename} (${page.patches} patches)`;
|
||||||
|
card.appendChild(label);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64Data = await invoke("read_file_base64", { path: filePath });
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = `data:image/tiff;base64,${base64Data}`;
|
||||||
|
img.alt = page.filename;
|
||||||
|
// TIFF may not render natively in all browsers — provide a fallback
|
||||||
|
img.onerror = () => {
|
||||||
|
img.remove();
|
||||||
|
const fallback = document.createElement("div");
|
||||||
|
fallback.className = "gallery-fallback";
|
||||||
|
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">${page.patches} patches</span>`;
|
||||||
|
card.insertBefore(fallback, label.nextSibling);
|
||||||
|
};
|
||||||
|
card.appendChild(img);
|
||||||
|
} catch (err) {
|
||||||
|
const fallback = document.createElement("div");
|
||||||
|
fallback.className = "gallery-fallback";
|
||||||
|
fallback.innerHTML = `<span class="file-icon">📄</span><span>${page.filename}</span><span class="file-size">Preview unavailable</span>`;
|
||||||
|
card.appendChild(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryGrid.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceToStage3() {
|
||||||
|
const steps = document.querySelectorAll('.step');
|
||||||
|
const stages = document.querySelectorAll('.stage');
|
||||||
|
|
||||||
|
steps.forEach(s => s.classList.remove('active'));
|
||||||
|
if (steps[2]) steps[2].classList.add('active');
|
||||||
|
|
||||||
|
stages.forEach(s => {
|
||||||
|
s.classList.remove('active');
|
||||||
|
s.classList.add('hidden');
|
||||||
|
});
|
||||||
|
if (stages[2]) {
|
||||||
|
stages[2].classList.remove('hidden');
|
||||||
|
stages[2].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
|
export async function initSettings() {
|
||||||
|
const dialog = document.getElementById('settingsDialog');
|
||||||
|
const openBtn = document.getElementById('openSettingsBtn');
|
||||||
|
const saveBtn = document.getElementById('saveSettingsBtn');
|
||||||
|
const closeBtn = document.getElementById('closeSettingsBtn');
|
||||||
|
|
||||||
|
if (!dialog || !openBtn) return;
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const settings = await invoke('load_settings');
|
||||||
|
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
||||||
|
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
||||||
|
dialog.showModal();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load settings:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', () => dialog.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const settings = {
|
||||||
|
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
|
||||||
|
default_instrument: document.getElementById('default_instrument').value.trim() || null
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await invoke('save_settings', { settings });
|
||||||
|
dialog.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save settings:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { computeDeltaE00 } from './delta_e.js';
|
||||||
|
import { labToCss, deviceRgbToCss } from './color_convert.js';
|
||||||
|
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
|
||||||
|
let unlistenJsonRow = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for row events from a chartread process.
|
||||||
|
* @param {string} processId - The process ID (e.g. "chartread_my_profile")
|
||||||
|
*/
|
||||||
|
export async function startSwatchListener(processId) {
|
||||||
|
const grid = document.getElementById("swatchGrid");
|
||||||
|
const progressBar = document.getElementById("readProgress");
|
||||||
|
const progressText = document.getElementById("readProgressText");
|
||||||
|
const statsPanel = document.getElementById("readStats");
|
||||||
|
|
||||||
|
// Clear previous state
|
||||||
|
grid.innerHTML = "";
|
||||||
|
let totalPatches = 0;
|
||||||
|
let totalDeltaE = 0;
|
||||||
|
let patchCount = 0;
|
||||||
|
let maxDeltaE = 0;
|
||||||
|
|
||||||
|
unlistenJsonRow = await listen("process:json_row", (event) => {
|
||||||
|
if (event.payload.id !== processId) return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.payload.json);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse json_row:", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.event !== "row_complete") return;
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const progress = ((data.row_index + 1) / data.total_rows) * 100;
|
||||||
|
if (progressBar) progressBar.style.width = `${progress}%`;
|
||||||
|
if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`;
|
||||||
|
|
||||||
|
// Create row container
|
||||||
|
const rowEl = document.createElement("div");
|
||||||
|
rowEl.className = "swatch-row";
|
||||||
|
|
||||||
|
const rowLabel = document.createElement("div");
|
||||||
|
rowLabel.className = "swatch-row-label";
|
||||||
|
rowLabel.textContent = data.row_id;
|
||||||
|
rowEl.appendChild(rowLabel);
|
||||||
|
|
||||||
|
const rowPatches = document.createElement("div");
|
||||||
|
rowPatches.className = "swatch-row-patches";
|
||||||
|
|
||||||
|
for (const patch of data.patches) {
|
||||||
|
if (patch.is_pad) continue; // Skip spacer patches
|
||||||
|
|
||||||
|
const patchEl = document.createElement("div");
|
||||||
|
patchEl.className = "swatch-patch";
|
||||||
|
|
||||||
|
// Determine the display colour
|
||||||
|
let bgColor;
|
||||||
|
if (patch.measured && patch.measured.Lab) {
|
||||||
|
bgColor = labToCss(patch.measured.Lab);
|
||||||
|
} else if (patch.device && patch.device.length === 3) {
|
||||||
|
bgColor = deviceRgbToCss(patch.device);
|
||||||
|
} else {
|
||||||
|
bgColor = "#888";
|
||||||
|
}
|
||||||
|
|
||||||
|
const swatch = document.createElement("div");
|
||||||
|
swatch.className = "swatch-color";
|
||||||
|
swatch.style.backgroundColor = bgColor;
|
||||||
|
patchEl.appendChild(swatch);
|
||||||
|
|
||||||
|
// Compute and display ΔE₀₀ if both expected and measured Lab are present
|
||||||
|
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
|
||||||
|
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
|
||||||
|
|
||||||
|
const deLabel = document.createElement("div");
|
||||||
|
deLabel.className = "swatch-de";
|
||||||
|
deLabel.textContent = deltaE.toFixed(1);
|
||||||
|
|
||||||
|
// Traffic light classification
|
||||||
|
if (deltaE < 2) {
|
||||||
|
patchEl.classList.add("de-good"); // Green
|
||||||
|
} else if (deltaE < 5) {
|
||||||
|
patchEl.classList.add("de-warning"); // Amber
|
||||||
|
} else {
|
||||||
|
patchEl.classList.add("de-bad"); // Red
|
||||||
|
}
|
||||||
|
|
||||||
|
patchEl.appendChild(deLabel);
|
||||||
|
|
||||||
|
// Accumulate stats
|
||||||
|
totalDeltaE += deltaE;
|
||||||
|
patchCount++;
|
||||||
|
if (deltaE > maxDeltaE) maxDeltaE = deltaE;
|
||||||
|
}
|
||||||
|
|
||||||
|
patchEl.title = `${patch.loc} (ID: ${patch.id})`;
|
||||||
|
rowPatches.appendChild(patchEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowEl.appendChild(rowPatches);
|
||||||
|
grid.appendChild(rowEl);
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
grid.scrollTop = grid.scrollHeight;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
if (statsPanel && patchCount > 0) {
|
||||||
|
const avgDe = (totalDeltaE / patchCount).toFixed(2);
|
||||||
|
statsPanel.textContent = `Avg ΔE₀₀: ${avgDe} · Max ΔE₀₀: ${maxDeltaE.toFixed(2)} · Patches: ${patchCount}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop listening for row events.
|
||||||
|
*/
|
||||||
|
export function stopSwatchListener() {
|
||||||
|
if (unlistenJsonRow) {
|
||||||
|
unlistenJsonRow();
|
||||||
|
unlistenJsonRow = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
const { listen } = window.__TAURI__.event;
|
const { listen } = window.__TAURI__.event;
|
||||||
const { save } = window.__TAURI__.dialog;
|
const { save } = window.__TAURI__.dialog;
|
||||||
|
import { setStage1Result } from './printtarg.js';
|
||||||
|
|
||||||
export function initTargen() {
|
export function initTargen() {
|
||||||
const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]');
|
const colourSpaceRadios = document.querySelectorAll('input[name="colourSpace"]');
|
||||||
@@ -131,6 +132,7 @@ export function initTargen() {
|
|||||||
// In a real app we'd dispatch an event to advance the stepper here.
|
// In a real app we'd dispatch an event to advance the stepper here.
|
||||||
// For now, we'll manually unlock stage 2 in the state.
|
// For now, we'll manually unlock stage 2 in the state.
|
||||||
btnGenerate.disabled = false;
|
btnGenerate.disabled = false;
|
||||||
|
setStage1Result(basename, currentWorkingDir);
|
||||||
advanceToStage2();
|
advanceToStage2();
|
||||||
} else {
|
} else {
|
||||||
logPre.textContent += `\n[ERROR] Targen exited with code ${event.payload.code}.\n`;
|
logPre.textContent += `\n[ERROR] Targen exited with code ${event.payload.code}.\n`;
|
||||||
|
|||||||
+471
-1
@@ -225,7 +225,8 @@ button:disabled {
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#targenLog {
|
#targenLog,
|
||||||
|
#printtargLog {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -234,3 +235,472 @@ button:disabled {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 2: Warning Banner
|
||||||
|
======================================== */
|
||||||
|
.warning-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(255, 152, 0, 0.12);
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.4);
|
||||||
|
border-left: 4px solid #ff9800;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text strong {
|
||||||
|
display: block;
|
||||||
|
color: #ffb74d;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #ccc;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 2: TIFF Gallery
|
||||||
|
======================================== */
|
||||||
|
.tiff-gallery {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiff-gallery h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-info {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card {
|
||||||
|
background: var(--panel-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-label {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-fallback {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px 16px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-fallback .file-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-fallback .file-size {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 3: chartread State Machine UI
|
||||||
|
======================================== */
|
||||||
|
.chartread-status {
|
||||||
|
background: var(--panel-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label span {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-prompt {
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 2.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartread-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background-color: #c62828;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background-color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.read-progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-color), #4fc3f7);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-progress-text {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-stats {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 3: Swatch Grid
|
||||||
|
======================================== */
|
||||||
|
.swatch-grid {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-color);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-row-label {
|
||||||
|
min-width: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-row-patches {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-patch {
|
||||||
|
width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-color {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-de {
|
||||||
|
font-size: 0.65em;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Traffic light ΔE indicators */
|
||||||
|
.swatch-patch.de-good {
|
||||||
|
border-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-patch.de-warning {
|
||||||
|
border-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-patch.de-bad {
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Process log for chartread */
|
||||||
|
#chartreadLog {
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #ccc;
|
||||||
|
max-height: 200px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Stage 5: Gamut Viewer & Settings Modal
|
||||||
|
======================================== */
|
||||||
|
.gamut-viewer-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gamutViewerContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
background: #111116;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal {
|
||||||
|
background: var(--panel-color);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user