← back to Dw Photo Capture
public/js/adjust-panel.js
186 lines
/*
* adjust-panel.js — the pre-shot "Photoshop" panel for the simple-* capture pages (TK-12162).
*
* All 11 CapturePipeline adjustments as sliders in a bottom sheet, a live adjusted preview drawn
* over the camera <video>, and a bake() that applies the SAME tune to the captured photo — so what
* you set before the shot is what gets saved. Requires /js/capture-pipeline.js loaded first.
*
* const panel = AdjustPanel.create({
* key, // localStorage key for the tune (ignored when getTune is given)
* getTune, // optional: page already owns a tune object (Pro Booth) — panel edits it in place
* onChange, // optional: called after any slider move / reset
* toggleParent, // optional element to put the "Adjust" button in (default: floating top-left)
* toggleClass, toggleText,
* showAutoSave // default true — the "Auto-save after label read" switch
* });
* panel.attachPreview(videoEl) // adjusted canvas drawn over the video (hidden when neutral)
* panel.bake(source, maxEdge, {mirror}) // -> JPEG dataUrl (0.92), full tune incl. sharpness
* panel.bakeDataUrl(url, maxEdge) // -> Promise<dataUrl>; for the native full-res photo
* panel.autoSave() // -> boolean
* panel.sync() // re-read the tune into the sliders (after an outside reset)
*/
(function () {
var CP = window.CapturePipeline;
var AUTO_KEY = 'dwSimpleAutoSave';
var CONTROLS = [
['exposure', 'Exposure', -100, 100], ['contrast', 'Contrast', -100, 100],
['highlights', 'Highlights', -100, 100], ['shadows', 'Shadows', -100, 100],
['temp', 'Warmth', -100, 100], ['tint', 'Tint', -100, 100],
['saturation', 'Saturation', -100, 100], ['vibrance', 'Vibrance', -100, 100],
['hue', 'Hue', -180, 180], ['sharpness', 'Sharpness', 0, 100], ['straighten', 'Straighten', -15, 15]
];
var CSS =
'.ap-sheet{position:fixed;left:0;right:0;bottom:0;z-index:9000;max-height:46vh;overflow:auto;' +
'background:rgba(16,14,10,.94);color:#f2ede1;border-top:1px solid #3a3428;border-radius:18px 18px 0 0;' +
'padding:10px 16px calc(14px + env(safe-area-inset-bottom));font:14px/1.3 -apple-system,system-ui,sans-serif;' +
'transform:translateY(105%);transition:transform .22s ease}' +
'.ap-sheet.open{transform:none}' +
'.ap-head{display:flex;align-items:center;gap:10px;padding:4px 0 8px;position:sticky;top:-10px;background:inherit}' +
'.ap-head b{flex:1;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:#c8a24a}' +
'.ap-head button,.ap-toggle-float{border:1px solid #c8a24a;color:#c8a24a;background:rgba(0,0,0,.55);' +
'border-radius:999px;padding:8px 14px;font:600 13px/1 -apple-system,system-ui,sans-serif;cursor:pointer}' +
'.ap-row{display:grid;grid-template-columns:92px 1fr 40px;align-items:center;gap:10px;padding:0}' +
'.ap-row span{font-size:12px;color:#b3a98f}.ap-row output{text-align:right;color:#e2bd66;font-variant-numeric:tabular-nums}' +
'.ap-row input{width:100%;height:28px;accent-color:#c8a24a}' +
// host pages style bare button/input globally (Wizard: 70px-tall buttons) — keep the sheet immune
'.ap-sheet button{min-width:0!important;min-height:0!important;width:auto;flex:0 0 auto;box-shadow:none;' +
'font:600 13px/1 -apple-system,system-ui,sans-serif!important;padding:8px 14px!important;border-radius:999px!important}' +
'.ap-toggle,.ap-toggle-float{white-space:nowrap}' +
'.ap-auto{display:flex;align-items:center;gap:10px;padding:10px 0 2px;color:#b3a98f;font-size:13px}' +
'.ap-auto input{width:20px;height:20px;accent-color:#c8a24a}' +
'.ap-toggle-float{position:fixed;z-index:8999;left:12px;top:calc(env(safe-area-inset-top,0px) + 14px)}' +
'.ap-toggle.on,.ap-toggle-float.on{background:#c8a24a;color:#171308}' +
'.ap-preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;display:none}';
function isNeutral(t) {
for (var i = 0; i < CONTROLS.length; i++) if (t[CONTROLS[i][0]]) return false;
return true;
}
function readAuto() { try { var v = localStorage.getItem(AUTO_KEY); return v === null ? true : v === '1'; } catch (e) { return true; } }
function writeAuto(on) { try { localStorage.setItem(AUTO_KEY, on ? '1' : '0'); } catch (e) {} }
function create(opts) {
opts = opts || {};
var ownTune = opts.getTune ? null : CP.loadTune(opts.key || 'dwSimpleTune');
var getTune = opts.getTune || function () { return ownTune; };
var inputs = {}, outs = {};
if (!document.getElementById('ap-style')) {
var st = document.createElement('style'); st.id = 'ap-style'; st.textContent = CSS; document.head.appendChild(st);
}
var sheet = document.createElement('div'); sheet.className = 'ap-sheet';
var head = document.createElement('div'); head.className = 'ap-head';
head.innerHTML = '<b>Adjust before the shot</b>';
var resetB = document.createElement('button'); resetB.type = 'button'; resetB.textContent = '↺ Reset';
var doneB = document.createElement('button'); doneB.type = 'button'; doneB.textContent = 'Done';
head.appendChild(resetB); head.appendChild(doneB); sheet.appendChild(head);
CONTROLS.forEach(function (c) {
var row = document.createElement('label'); row.className = 'ap-row';
var name = document.createElement('span'); name.textContent = c[1];
var inp = document.createElement('input'); inp.type = 'range'; inp.min = c[2]; inp.max = c[3]; inp.step = 1;
var out = document.createElement('output');
inp.addEventListener('input', function () {
getTune()[c[0]] = parseInt(inp.value, 10) || 0; out.textContent = inp.value; changed();
});
inp.addEventListener('dblclick', function () { getTune()[c[0]] = 0; sync(); changed(); });
row.appendChild(name); row.appendChild(inp); row.appendChild(out); sheet.appendChild(row);
inputs[c[0]] = inp; outs[c[0]] = out;
});
var auto = null;
if (opts.showAutoSave !== false) {
var ar = document.createElement('label'); ar.className = 'ap-auto';
auto = document.createElement('input'); auto.type = 'checkbox'; auto.checked = readAuto();
auto.addEventListener('change', function () { writeAuto(auto.checked); });
ar.appendChild(auto); ar.appendChild(document.createTextNode('Auto-save as a draft once the label is read'));
sheet.appendChild(ar);
}
document.body.appendChild(sheet);
var toggle = document.createElement('button'); toggle.type = 'button';
toggle.textContent = opts.toggleText || '🎚 Adjust';
if (opts.toggleParent) { toggle.className = 'ap-toggle ' + (opts.toggleClass || ''); opts.toggleParent.appendChild(toggle); }
else { toggle.className = 'ap-toggle-float'; document.body.appendChild(toggle); }
toggle.addEventListener('click', function (e) { e.stopPropagation(); sheet.classList.toggle('open'); });
doneB.addEventListener('click', function () { sheet.classList.remove('open'); });
resetB.addEventListener('click', function () {
var t = getTune(); CONTROLS.forEach(function (c) { t[c[0]] = 0; }); sync(); changed();
});
sheet.addEventListener('click', function (e) { e.stopPropagation(); }); // pages close drawers on outside clicks
function sync() {
var t = getTune();
CONTROLS.forEach(function (c) { inputs[c[0]].value = t[c[0]] || 0; outs[c[0]].textContent = t[c[0]] || 0; });
toggle.classList.toggle('on', !isNeutral(t));
}
function changed() {
if (ownTune) CP.saveTune(opts.key || 'dwSimpleTune', ownTune);
toggle.classList.toggle('on', !isNeutral(getTune()));
if (opts.onChange) opts.onChange();
}
sync();
// ── live preview: a canvas over the <video>, same apply() as the bake (sharpness is capture-only
// by the engine's contract). Hidden while the tune is neutral so the raw video shows. ──
var previews = [];
function tick() {
var t = getTune(), neutral = isNeutral(t);
previews.forEach(function (p) {
var v = p.video, cv = p.canvas;
var show = !neutral && v.videoWidth && v.offsetParent !== null;
cv.style.display = show ? 'block' : 'none';
if (!show) return;
var k = Math.min(1, 640 / Math.max(v.videoWidth, v.videoHeight));
var w = Math.round(v.videoWidth * k), h = Math.round(v.videoHeight * k);
if (cv.width !== w) cv.width = w; if (cv.height !== h) cv.height = h;
cv.className = 'ap-preview ' + v.className; // carries e.g. the selfie .mirror transform
try {
var x = cv.getContext('2d', { willReadFrequently: true });
CP.drawSource(x, v, w, h, t.straighten);
CP.apply(x, w, h, t, { capture: false });
} catch (e) {}
});
}
var timer = null;
function attachPreview(video) {
var cv = document.createElement('canvas'); cv.className = 'ap-preview';
if (getComputedStyle(video.parentNode).position === 'static') video.parentNode.style.position = 'relative';
video.parentNode.insertBefore(cv, video.nextSibling);
previews.push({ video: video, canvas: cv });
if (!timer) timer = setInterval(tick, 80);
}
function bake(source, maxEdge, bo) {
var sw = source.videoWidth || source.naturalWidth || source.width;
var sh = source.videoHeight || source.naturalHeight || source.height;
var k = Math.min(1, (maxEdge || 2000) / Math.max(sw, sh));
var w = Math.round(sw * k), h = Math.round(sh * k), t = getTune();
var c = document.createElement('canvas'); c.width = w; c.height = h;
var x = c.getContext('2d', { willReadFrequently: true });
if (bo && bo.mirror) x.setTransform(-1, 0, 0, 1, w, 0);
CP.drawSource(x, source, w, h, t.straighten);
x.setTransform(1, 0, 0, 1, 0, 0);
if (!isNeutral(t)) CP.apply(x, w, h, t, { capture: true });
return c.toDataURL('image/jpeg', 0.92);
}
function bakeDataUrl(url, maxEdge) {
if (isNeutral(getTune())) return Promise.resolve(url);
return new Promise(function (res) {
var im = new Image();
im.onload = function () { try { res(bake(im, maxEdge)); } catch (e) { console.error('[adjust-panel] bake failed', e); res(url); } };
im.onerror = function () { res(url); };
im.src = url;
});
}
return {
attachPreview: attachPreview, bake: bake, bakeDataUrl: bakeDataUrl, sync: sync,
autoSave: function () { return auto ? auto.checked : false; },
toggle: toggle, tune: getTune, isNeutral: function () { return isNeutral(getTune()); }
};
}
window.AdjustPanel = { create: create, CONTROLS: CONTROLS };
})();