Files
monkeyforever2andClaude Fable 5 29d541afa3 v4: honest iPhone 17 ISP pipeline, Photographic Styles, UI fixes
Engine rewrite:
- Remove fake Display P3 stage (sRGB->XYZ->P3 round-trip was a no-op
  hiding a plain saturation multiply) and redundant strength multipliers
- Fix box blur: was a trailing window shifted diagonally with zero-padded
  borders; now centered with edge replication
- Fix non-monotonic highlight soft-clip (banding above white)
- White balance and tone mapping in linear light (luminance-preserving)
- Smart HDR: Durand-style log-luminance base/detail local tone mapping
- Color science in Oklab: hue-preserving vibrance, real skin-tone
  protection mask, subtle split-tone signature
- Single two-scale detail stage (texture + clarity) with tanh halo
  suppression, replacing three overlapping sharpeners
- Photon-weighted grain, natural-falloff vignette in linear light
- 15 iPhone 17 Photographic Styles presets (moods + undertones + B&W)
- Delete dead camera_profiles.py

LUT engine: tetrahedral interpolation (industry standard) with chunked
memory-safe application; document that the Apple Log LUT expects log input

UI: rebuild for new params; sizing via getMinHeight/getMaxHeight +
computeSize (no more guessed heights), remount safety net for workflow
reload store clears, sanitize values restored by position from old saves

Tests: 16-case suite covering blur centering, Oklab round-trip, styles,
skin protection, tetrahedral exactness, node API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:17:12 +02:00

477 lines
22 KiB
JavaScript

import { app } from "../../scripts/app.js";
/**
* 📸 Camera Forensic Realism Engine — Premium UI Dashboard (v4)
* ==============================================================
* Gold/Black themed DOM widget replacing ALL default drawing.
* Matches the v4 iPhone 17 pipeline parameter set.
*/
const NODE_NAME = "CameraForensicRealismEngine";
const NODE_MIN_WIDTH = 420;
const NODE_TITLE_HEIGHT = 40;
const SECTIONS = {
master: { icon: "⚡", label: "Master", toggle: null },
wb: { icon: "☀️", label: "White Balance", toggle: "enable_white_balance" },
tone: { icon: "🌓", label: "Global Tone", toggle: "enable_tone" },
hdr: { icon: "✨", label: "Smart HDR", toggle: "enable_smart_hdr" },
color: { icon: "🎨", label: "Color Science (Oklab)", toggle: "enable_color" },
detail: { icon: "🔪", label: "Detail", toggle: "enable_detail" },
optics: { icon: "📷", label: "Optics & Sensor", toggle: "enable_optics" },
};
const WIDGET_META = {
photographic_style: { type: "combo", section: "master", label: "Style", desc: "iPhone 17 Photographic Style preset. Undertones steer warmth, moods steer tone+color. Applied as offsets on top of your sliders." },
master_strength: { type: "slider", section: "master", label: "Master Strength", min: 0, max: 1, step: 0.01, desc: "Final blend between original and fully processed image." },
seed: { type: "number", section: "master", label: "Seed", desc: "Random seed for grain reproducibility." },
control_after_generate: { type: "combo", section: "master", label: "Seed Mode", desc: "Change seed behavior after generation." },
enable_white_balance: { type: "toggle", section: "wb", label: "Enable", desc: "Channel gains in linear light, luminance-preserving." },
wb_temperature: { type: "slider", section: "wb", label: "Temperature", min: -1, max: 1, step: 0.01, desc: "Blue-Orange axis. iPhone AWB sits slightly warm (~0.1)." },
wb_tint: { type: "slider", section: "wb", label: "Tint", min: -1, max: 1, step: 0.01, desc: "Green-Magenta axis. 0 = neutral." },
enable_tone: { type: "toggle", section: "tone", label: "Enable", desc: "Linear-light global tone mapping." },
exposure: { type: "slider", section: "tone", label: "Exposure", min: -1, max: 1, step: 0.01, desc: "Exposure in EV. iPhone meters slightly bright." },
contrast: { type: "slider", section: "tone", label: "Contrast", min: -1, max: 1, step: 0.01, desc: "Midtone contrast around the 0.18 photographic pivot." },
shadows: { type: "slider", section: "tone", label: "Shadows", min: 0, max: 1, step: 0.01, desc: "Luminance-masked shadow lift. iPhone never crushes blacks." },
highlights: { type: "slider", section: "tone", label: "Highlights", min: 0, max: 1, step: 0.01, desc: "Reinhard soft-knee rolloff — highlights compress smoothly, never clip." },
enable_smart_hdr: { type: "toggle", section: "hdr", label: "Enable", desc: "Local tone mapping in log-luminance (Smart HDR 5 style fusion look)." },
hdr_strength: { type: "slider", section: "hdr", label: "Strength", min: 0, max: 1, step: 0.01, desc: "Local dynamic range compression — shadows up, highlights down, detail untouched." },
enable_color: { type: "toggle", section: "color", label: "Enable", desc: "Perceptual color rendering in Oklab space." },
vibrance: { type: "slider", section: "color", label: "Vibrance", min: 0, max: 1, step: 0.01, desc: "Smart saturation: boosts muted colors more than saturated ones. Hue-preserving." },
skin_protection: { type: "slider", section: "color", label: "Skin Protection", min: 0, max: 1, step: 0.01, desc: "Shields skin tones from vibrance and caps skin chroma — no orange faces." },
shadow_tint: { type: "slider", section: "color", label: "Shadow Tint", min: 0, max: 1, step: 0.01, desc: "Subtle cool/blue tint in shadows — the iPhone blue-black signature." },
highlight_warmth: { type: "slider", section: "color", label: "Highlight Warmth", min: 0, max: 1, step: 0.01, desc: "Golden warmth in bright areas, complements the cool shadows." },
enable_detail: { type: "toggle", section: "detail", label: "Enable", desc: "Two-scale luminance detail with halo suppression." },
texture: { type: "slider", section: "detail", label: "Texture", min: 0, max: 1, step: 0.01, desc: "Fine-scale detail (~1px): pores, fabric, hair." },
clarity: { type: "slider", section: "detail", label: "Clarity", min: 0, max: 1, step: 0.01, desc: "Mid-scale local punch. Keep low for a natural look." },
enable_optics: { type: "toggle", section: "optics", label: "Enable", desc: "Lens vignette + sensor grain." },
grain: { type: "slider", section: "optics", label: "Grain", min: 0, max: 1, step: 0.01, desc: "Photon-weighted sensor noise, stronger in shadows." },
vignette: { type: "slider", section: "optics", label: "Vignette", min: 0, max: 1, step: 0.01, desc: "Natural illumination falloff applied in linear light." },
};
/* ── CSS injection ───────────────────────────────────────────────────── */
function injectCSS() {
if (document.getElementById("cfr-styles")) return;
const style = document.createElement("style");
style.id = "cfr-styles";
style.textContent = `
.cfr-root {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #0d0d08;
border-radius: 8px;
color: #d0d0c0;
user-select: none;
width: 100%;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #333322 transparent;
}
.cfr-root::-webkit-scrollbar { width: 6px; }
.cfr-root::-webkit-scrollbar-thumb { background: #333322; border-radius: 3px; }
.cfr-inner { padding: 12px; box-sizing: border-box; }
.cfr-header {
text-align: center;
padding: 4px 0 12px;
border-bottom: 1px solid rgba(255, 215, 0, 0.15);
margin-bottom: 12px;
}
.cfr-title {
font-size: 16px; font-weight: 800; letter-spacing: 2px;
text-transform: uppercase;
background: linear-gradient(135deg, #FFD700, #b89b00);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text; margin: 0;
}
.cfr-subtitle {
font-size: 9px; color: #887; letter-spacing: 2px;
text-transform: uppercase; margin-top: 4px; font-weight: 600;
}
.cfr-info {
background: rgba(255, 215, 0, 0.04);
border: 1px solid rgba(255, 215, 0, 0.1);
border-radius: 8px; padding: 10px 12px;
margin-bottom: 12px; font-size: 10px; line-height: 1.4;
}
.cfr-info-badge {
display: inline-block; font-size: 8px; font-weight: 800;
letter-spacing: 1.5px; padding: 3px 6px; border-radius: 4px;
background: rgba(255, 215, 0, 0.15); color: #FFD700; margin-bottom: 6px;
}
.cfr-info-desc { color: #bbb; margin: 4px 0; }
.cfr-info-tip { color: #887; font-size: 9px; font-style: italic; margin-top: 6px; }
.cfr-section {
display: flex; align-items: center; gap: 8px;
margin: 16px 0 8px; font-size: 10px; font-weight: 700;
letter-spacing: 1.5px; text-transform: uppercase; color: #FFD700;
}
.cfr-section::after {
content: ""; flex: 1; height: 1px;
background: linear-gradient(90deg, rgba(255,215,0,0.2), transparent);
}
.cfr-row {
display: flex; align-items: center; gap: 10px; margin: 6px 0;
height: 24px; transition: opacity 0.2s, max-height 0.2s;
}
.cfr-row.hidden { opacity: 0; max-height: 0; margin: 0; pointer-events: none; overflow: hidden; }
.cfr-label {
min-width: 100px; font-size: 11px; color: #aaa;
text-align: right; cursor: help; font-weight: 500;
}
.cfr-label:hover { color: #FFD700; }
.cfr-slider {
-webkit-appearance: none; appearance: none;
flex: 1; height: 6px; border-radius: 3px;
background: linear-gradient(90deg, #1f1f14, #333322);
outline: none; cursor: pointer;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.5);
}
.cfr-slider:hover { background: linear-gradient(90deg, #1f1f14, #444422); }
.cfr-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px;
border-radius: 50%;
background: radial-gradient(circle, #fff 10%, #FFD700 80%);
border: 1px solid #b89b00; cursor: grab;
box-shadow: 0 0 6px rgba(255, 215, 0, 0.4);
margin-top: -4px; transition: transform 0.1s;
}
.cfr-slider::-webkit-slider-thumb:hover {
transform: scale(1.2); box-shadow: 0 0 10px rgba(255, 215, 0, 0.6);
}
.cfr-slider-val {
min-width: 40px; text-align: right; font-size: 11px;
font-family: 'Consolas','Monaco',monospace;
color: #FFD700; font-weight: 700;
}
.cfr-tooltip { position: relative; }
.cfr-tooltip::after {
content: attr(data-tip);
position: absolute; bottom: 125%; left: 0%;
background: #1a1a14; color: #ccc;
border: 1px solid rgba(255,215,0,0.3);
padding: 6px 10px; border-radius: 6px;
font-size: 10px; width: 220px;
white-space: normal; pointer-events: none; text-align: left;
opacity: 0; transition: opacity 0.2s; z-index: 999; line-height: 1.4;
box-shadow: 0 4px 12px rgba(0,0,0,0.5); font-weight: normal; font-family: sans-serif;
}
.cfr-tooltip:hover::after { opacity: 1; }
.cfr-switch { display: flex; align-items: center; gap: 8px; cursor: pointer; flex: 1; }
.cfr-switch-text { font-size: 10px; font-weight: 700; color: #666; width: 26px; text-align: right; letter-spacing: 0.5px; }
.cfr-switch input { display: none; }
.cfr-switch-track { width: 32px; height: 16px; background: #222; border-radius: 8px; position: relative; transition: 0.2s; border: 1px solid #333; box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); }
.cfr-switch-thumb { width: 12px; height: 12px; background: #777; border-radius: 50%; position: absolute; top: 1px; left: 2px; transition: 0.2s; }
.cfr-switch input:checked + .cfr-switch-text { color: #FFD700; }
.cfr-switch input:checked ~ .cfr-switch-track { background: rgba(255,215,0,0.15); border-color: rgba(255,215,0,0.4); }
.cfr-switch input:checked ~ .cfr-switch-track .cfr-switch-thumb { background: #FFD700; transform: translateX(14px); box-shadow: 0 0 8px rgba(255,215,0,0.5); }
.cfr-input { flex: 1; background: #1a1a14; border: 1px solid rgba(255,215,0,0.2); color: #FFD700; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-family: inherit; font-weight: 600; outline: none; }
.cfr-input:focus { border-color: #FFD700; }
`;
document.head.appendChild(style);
}
/* ── Build DOM ───────────────────────────────────────────────────────── */
function buildUI() {
const el = document.createElement("div");
el.className = "cfr-root";
const inner = document.createElement("div");
inner.className = "cfr-inner";
el.appendChild(inner);
// Header & Description
let html = `
<div class="cfr-header">
<div class="cfr-title">Camera Forensic Realism</div>
<div class="cfr-subtitle">iPhone 17 • Photonic Engine • Photographic Styles</div>
</div>
<div class="cfr-info">
<span class="cfr-info-badge">★ iPHONE 17 ISP PIPELINE</span>
<div class="cfr-info-desc">Honest color science: linear-light white balance & tone, Smart HDR local mapping, Oklab vibrance with real skin protection, halo-suppressed detail, photon-weighted grain.</div>
<div class="cfr-info-tip">💡 Pick a Photographic Style, then fine-tune. Place after detailers, before SaveImage.</div>
</div>
`;
// Generate Sections & Controls
Object.entries(SECTIONS).forEach(([secId, sec]) => {
html += `<div class="cfr-section" id="sec_${secId}">${sec.icon} ${sec.label}</div>`;
html += `<div id="wrap_${secId}">`;
Object.entries(WIDGET_META).forEach(([wName, w]) => {
if (w.section !== secId) return;
html += `<div class="cfr-row" id="row_${wName}">
<span class="cfr-label cfr-tooltip" data-tip="${w.desc}">${w.label}</span>`;
if (w.type === "slider") {
html += `
<input type="range" class="cfr-slider" id="inp_${wName}" min="${w.min}" max="${w.max}" step="${w.step}">
<span class="cfr-slider-val" id="val_${wName}"></span>`;
} else if (w.type === "toggle") {
html += `
<label class="cfr-switch">
<input type="checkbox" id="inp_${wName}">
<span class="cfr-switch-text" id="val_${wName}">OFF</span>
<div class="cfr-switch-track"><div class="cfr-switch-thumb"></div></div>
</label>`;
} else if (w.type === "combo") {
html += `<select class="cfr-input" id="inp_${wName}"></select>`;
} else if (w.type === "number") {
html += `<input type="number" class="cfr-input" id="inp_${wName}">`;
}
html += `</div>`;
});
html += `</div>`;
});
inner.innerHTML = html;
return el;
}
function getWidget(node, name) {
return node.widgets?.find(w => w.name === name);
}
function hideDefaultWidget(w) {
if (!w) return;
w.hidden = true; w.type = "hidden"; w.computeSize = () => [0, -4];
w.draw = function () { }; w.mouse = function () { return false; };
}
function updateSectionVisibility(el, node) {
// Hide a section's controls when its enable toggle is off
Object.entries(SECTIONS).forEach(([secId, sec]) => {
if (!sec.toggle) return;
const toggleW = getWidget(node, sec.toggle);
if (!toggleW) return;
const isEnabled = !!toggleW.value;
Object.entries(WIDGET_META).forEach(([name, meta]) => {
if (meta.section === secId && meta.type !== "toggle") {
const row = el.querySelector(`#row_${name}`);
if (row) row.classList.toggle("hidden", !isEnabled);
}
});
});
}
/* ── Extension ───────────────────────────────────────────────────────── */
app.registerExtension({
name: "CameraForensicRealism.PremiumUI",
nodeCreated(node) {
if (node.comfyClass !== NODE_NAME) return;
injectCSS();
const paramNames = Object.keys(WIDGET_META);
// Hide all default widgets
for (const name of paramNames) hideDefaultWidget(getWidget(node, name));
/* The frontend's DOM-widget layout (DOMWidgetImpl.computeLayoutSize
+ _arrangeWidgets) sizes the widget area from getMinHeight /
getMaxHeight, and re-runs on EVERY canvas redraw — including
clicks. Report the panel's real measured content height as both
min and max: the layout always allocates exactly enough space and
can never stretch the panel into leftover node body space. */
const el = buildUI();
const inner = el.querySelector(".cfr-inner");
let contentH = 600;
const widgetOptions = {
serialize: false,
getMinHeight: () => contentH,
getMaxHeight: () => contentH,
};
let domWidget = node.addDOMWidget(
"cfr_premium_ui", "customwidget", el, widgetOptions);
/* On workflow reload, the frontend clears its DOM-widget store
while nodes are being rebuilt; depending on timing our early
registration can be wiped, leaving the panel orphaned — visible
detached or gone, never tracking the node. Re-adding the widget
re-registers it and the Vue overlay adopts the panel again. */
function remount() {
if (document.contains(el)) return;
const idx = node.widgets.indexOf(domWidget);
if (idx >= 0) node.widgets.splice(idx, 1);
try { domWidget.onRemove?.(); } catch (e) { /* stale store entry */ }
domWidget = node.addDOMWidget(
"cfr_premium_ui", "customwidget", el, widgetOptions);
node.setDirtyCanvas?.(true, true);
}
function measure() {
if (inner.offsetHeight) contentH = inner.offsetHeight + 2;
}
/* Track reflow (e.g. text wrapping after a width change) so the
layout's min/max height stays accurate. Passive: never resizes
the node itself. */
new ResizeObserver(() => measure()).observe(inner);
/* Resize the node to its authoritative minimum: computeSize()
already includes our getMinHeight via computeLayoutSize, so this
both grows undersized and shrinks oversized nodes (the frontend
itself only ever auto-grows). Width: keep the user's width but
clamp out garbage serialized by older versions of this node. */
function fitHeight() {
requestAnimationFrame(() => {
measure();
if (!inner.offsetHeight) return;
const fit = node.computeSize();
const width = Math.min(
Math.max(node.size[0], NODE_MIN_WIDTH), 800);
node.setSize([width, fit[1]]);
node.setDirtyCanvas?.(true, true);
});
}
/* The panel is mounted into the canvas overlay lazily (first time
the widget becomes visible), so right after configure it may not
be measurable yet — retry until it is. If it stays unmounted,
the store registration was lost on reload: remount it. */
let fitTries = 0;
function fitWhenReady() {
measure();
if (!inner.offsetHeight) {
if (fitTries === 6 || fitTries === 14) remount();
if (fitTries++ < 25) {
setTimeout(fitWhenReady, 150);
return;
}
return;
}
fitHeight();
}
node.color = "#1A1A00"; node.bgcolor = "#0A0A0A";
// Bind DOM elements to LiteGraph Widgets
Object.entries(WIDGET_META).forEach(([name, meta]) => {
const domInp = el.querySelector(`#inp_${name}`);
const domVal = el.querySelector(`#val_${name}`);
const lgW = getWidget(node, name);
if (!domInp || !lgW) return;
if (meta.type === "slider") {
domInp.addEventListener("input", () => {
lgW.value = parseFloat(domInp.value);
if (lgW.callback) lgW.callback(lgW.value);
if (domVal) domVal.textContent = Number(lgW.value).toFixed(2);
});
} else if (meta.type === "toggle") {
domInp.addEventListener("change", (e) => {
lgW.value = e.target.checked;
if (lgW.callback) lgW.callback(lgW.value);
if (domVal) domVal.textContent = lgW.value ? "ON" : "OFF";
updateSectionVisibility(el, node);
fitHeight();
});
} else if (meta.type === "combo") {
(lgW.options?.values || []).forEach(opt => {
const o = document.createElement("option"); o.value = opt; o.innerText = opt;
domInp.appendChild(o);
});
domInp.addEventListener("change", (e) => {
lgW.value = e.target.value;
if (lgW.callback) lgW.callback(lgW.value);
});
} else if (meta.type === "number") {
domInp.addEventListener("input", (e) => {
lgW.value = parseFloat(e.target.value);
if (lgW.callback) lgW.callback(lgW.value);
});
}
});
function syncFromWidgets() {
Object.entries(WIDGET_META).forEach(([name, meta]) => {
const domInp = el.querySelector(`#inp_${name}`);
const domVal = el.querySelector(`#val_${name}`);
const lgW = getWidget(node, name);
if (!domInp || !lgW) return;
if (meta.type === "slider") {
domInp.value = lgW.value;
if (domVal) domVal.textContent = Number(lgW.value).toFixed(2);
} else if (meta.type === "toggle") {
domInp.checked = !!lgW.value;
if (domVal) domVal.textContent = lgW.value ? "ON" : "OFF";
} else {
domInp.value = lgW.value;
}
});
updateSectionVisibility(el, node);
}
/* Repair values from workflows saved with the old node version:
widget values restore by position, so removed v3 params can land
in today's params with the wrong type or out-of-range values. */
function sanitize() {
Object.entries(WIDGET_META).forEach(([name, meta]) => {
const lgW = getWidget(node, name);
if (!lgW) return;
if (meta.type === "slider") {
let v = Number(lgW.value);
if (!Number.isFinite(v)) v = meta.min;
v = Math.min(meta.max, Math.max(meta.min, v));
if (v !== lgW.value) { lgW.value = v; lgW.callback?.(v); }
} else if (meta.type === "toggle") {
if (typeof lgW.value !== "boolean") {
lgW.value = !!lgW.value;
lgW.callback?.(lgW.value);
}
} else if (meta.type === "combo") {
const values = lgW.options?.values || [];
if (values.length && !values.includes(lgW.value)) {
lgW.value = values[0];
lgW.callback?.(lgW.value);
}
}
});
}
const origConfigure = node.onConfigure;
node.onConfigure = function (info) {
origConfigure?.apply(this, arguments);
setTimeout(() => {
sanitize();
syncFromWidgets();
fitTries = 0;
fitWhenReady();
}, 80);
};
const origBG = node.onDrawBackground;
node.onDrawBackground = function (ctx) {
if (origBG) origBG.apply(this, arguments);
if (this.flags?.collapsed) return;
ctx.save();
ctx.fillStyle = "#1A1A00"; ctx.fillRect(0, -NODE_TITLE_HEIGHT, this.size[0], NODE_TITLE_HEIGHT);
ctx.restore();
};
setTimeout(() => {
sanitize();
syncFromWidgets();
fitWhenReady();
}, 150);
},
});