Spaces:
Running
Running
Deploy Deep Analog web app
Browse files- .gitattributes +3 -0
- static/demo/ref.jpg +3 -0
- static/demo/result.jpg +3 -0
- static/demo/target.jpg +3 -0
- static/index.html +347 -2
.gitattributes
CHANGED
|
@@ -1 +1,4 @@
|
|
| 1 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
static/demo/ref.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
static/demo/result.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
static/demo/target.jpg filter=lfs diff=lfs merge=lfs -text
|
static/demo/ref.jpg
ADDED
|
Git LFS Details
|
static/demo/result.jpg
ADDED
|
Git LFS Details
|
static/demo/target.jpg
ADDED
|
Git LFS Details
|
static/index.html
CHANGED
|
@@ -242,6 +242,60 @@
|
|
| 242 |
}
|
| 243 |
.viz-play:hover { border-color: var(--accent); }
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
/* ---------- privacy modal ---------- */
|
| 246 |
#privacyModal {
|
| 247 |
position: fixed; inset: 0; z-index: 950;
|
|
@@ -340,6 +394,33 @@
|
|
| 340 |
<div class="result-card" id="resultBody"></div>
|
| 341 |
</div>
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
<footer>
|
| 344 |
Deep Analog — open-set film emulation · research preview.<br>
|
| 345 |
Your images are processed in memory and never stored. <a class="privacy-link" id="privacyOpen2">How privacy works here</a>.
|
|
@@ -569,7 +650,9 @@
|
|
| 569 |
initCompare(cmp);
|
| 570 |
}
|
| 571 |
|
| 572 |
-
//
|
|
|
|
|
|
|
| 573 |
try { body.appendChild(makeVizCard(data.cube_lut)); }
|
| 574 |
catch (e) { /* WebGL unavailable — skip viz silently */ }
|
| 575 |
|
|
@@ -615,6 +698,226 @@
|
|
| 615 |
cmp.addEventListener('pointerup', () => { dragging = false; });
|
| 616 |
}
|
| 617 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
// ------------------------------------------------ 3D LUT visualization
|
| 619 |
// Self-contained WebGL point cloud — no libraries, no external requests.
|
| 620 |
// Shows the LUT lattice morphing from identity (perfect cube) to the
|
|
@@ -637,7 +940,12 @@
|
|
| 637 |
const wrap = document.createElement('div');
|
| 638 |
wrap.className = 'viz-wrap';
|
| 639 |
wrap.innerHTML =
|
| 640 |
-
'<div class="viz-head"><span class="t">Color space — 3D LUT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
'<span class="hint">drag to rotate · scroll to zoom</span></div>' +
|
| 642 |
'<canvas class="viz-canvas"></canvas>' +
|
| 643 |
'<div class="viz-controls">' +
|
|
@@ -846,6 +1154,43 @@
|
|
| 846 |
return wrap;
|
| 847 |
}
|
| 848 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
// ------------------------------------------------ privacy modal
|
| 850 |
['privacyOpen', 'privacyOpen2'].forEach(id => {
|
| 851 |
const el = $(id);
|
|
|
|
| 242 |
}
|
| 243 |
.viz-play:hover { border-color: var(--accent); }
|
| 244 |
|
| 245 |
+
/* ---------- help tooltip ---------- */
|
| 246 |
+
.help {
|
| 247 |
+
position: relative; display: inline-flex; align-items: center; justify-content: center;
|
| 248 |
+
width: 16px; height: 16px; margin-left: 8px;
|
| 249 |
+
border: 1px solid var(--text-dim); border-radius: 50%;
|
| 250 |
+
color: var(--text-dim); font-size: 10.5px; font-weight: 600;
|
| 251 |
+
cursor: help; vertical-align: middle; user-select: none;
|
| 252 |
+
}
|
| 253 |
+
.help:hover { border-color: var(--accent); color: var(--accent); }
|
| 254 |
+
.help .tip {
|
| 255 |
+
position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%);
|
| 256 |
+
width: min(320px, 76vw);
|
| 257 |
+
background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px;
|
| 258 |
+
padding: 12px 14px;
|
| 259 |
+
font-size: 12.5px; font-weight: 400; line-height: 1.6; color: #c9c9cf;
|
| 260 |
+
text-transform: none; letter-spacing: normal; text-align: left;
|
| 261 |
+
opacity: 0; pointer-events: none; transition: opacity .15s;
|
| 262 |
+
z-index: 30;
|
| 263 |
+
}
|
| 264 |
+
.help:hover .tip { opacity: 1; }
|
| 265 |
+
|
| 266 |
+
/* ---------- look recipe (color mapping) ---------- */
|
| 267 |
+
.flow-wrap { margin-top: 26px; }
|
| 268 |
+
.flow-grid { display: grid; grid-template-columns: 210px 1fr; gap: 18px; align-items: stretch; }
|
| 269 |
+
@media (max-width: 720px) { .flow-grid { grid-template-columns: 1fr; } }
|
| 270 |
+
.flow-ref { display: flex; flex-direction: column; gap: 8px; }
|
| 271 |
+
.flow-ref img { width: 100%; border-radius: 8px; display: block; border: 1px solid var(--line); }
|
| 272 |
+
.flow-ref .pal { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; }
|
| 273 |
+
.flow-ref .pal span { display: block; aspect-ratio: 1; border-radius: 5px; border: 1px solid rgba(255,255,255,.08); }
|
| 274 |
+
.flow-ref .cap { font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-dim); }
|
| 275 |
+
.flow-canvas {
|
| 276 |
+
width: 100%; height: 100%; min-height: 250px; display: block;
|
| 277 |
+
border-radius: 10px; border: 1px solid var(--line);
|
| 278 |
+
background: radial-gradient(500px 300px at 50% 50%, #17171c, #0a0a0c 85%);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/* ---------- demo section ---------- */
|
| 282 |
+
.demo { margin-top: 72px; }
|
| 283 |
+
.demo > h2 { font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 18px; }
|
| 284 |
+
.demo-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; }
|
| 285 |
+
.demo-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; align-items: start; }
|
| 286 |
+
@media (max-width: 760px) { .demo-grid { grid-template-columns: 1fr; } }
|
| 287 |
+
.demo-step { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 8px; }
|
| 288 |
+
.demo-step b { color: var(--accent); font-weight: 700; }
|
| 289 |
+
.demo-ref img { width: 100%; border-radius: 8px; display: block; }
|
| 290 |
+
.demo-note { font-size: 11px; color: var(--text-dim); margin-top: 8px; line-height: 1.5; }
|
| 291 |
+
.demo-actions { margin-top: 16px; display: flex; align-items: center; gap: 14px; }
|
| 292 |
+
.try-btn {
|
| 293 |
+
background: var(--accent-soft); color: var(--accent);
|
| 294 |
+
border: 1px solid var(--accent); border-radius: 10px;
|
| 295 |
+
font-size: 13.5px; font-weight: 600; padding: 10px 20px; cursor: pointer;
|
| 296 |
+
}
|
| 297 |
+
.try-btn:hover { background: var(--accent); color: #17110a; }
|
| 298 |
+
|
| 299 |
/* ---------- privacy modal ---------- */
|
| 300 |
#privacyModal {
|
| 301 |
position: fixed; inset: 0; z-index: 950;
|
|
|
|
| 394 |
<div class="result-card" id="resultBody"></div>
|
| 395 |
</div>
|
| 396 |
|
| 397 |
+
<section class="demo" id="demoSection" style="display:none">
|
| 398 |
+
<h2>See it in action</h2>
|
| 399 |
+
<div class="demo-card">
|
| 400 |
+
<div class="demo-grid">
|
| 401 |
+
<div class="demo-ref">
|
| 402 |
+
<div class="demo-step"><b>1</b> Pick a reference — any film look you love</div>
|
| 403 |
+
<img id="demoRefImg" alt="Reference film still">
|
| 404 |
+
<div class="demo-note">Still from <i>Happy Together</i> (1997), dir. Wong Kar-wai — shown for demonstration.</div>
|
| 405 |
+
</div>
|
| 406 |
+
<div>
|
| 407 |
+
<div class="demo-step"><b>2</b> Add your photo → <b>3</b> get it rendered in that look. Drag the divider to compare.</div>
|
| 408 |
+
<div class="compare" id="demoCompare">
|
| 409 |
+
<img class="base-img" id="demoResultImg" alt="Rendered result">
|
| 410 |
+
<img class="top-img" id="demoTargetImg" alt="Original photo">
|
| 411 |
+
<div class="divider"></div>
|
| 412 |
+
<span class="cmp-label" style="left:12px">RENDERED</span>
|
| 413 |
+
<span class="cmp-label" style="right:12px">ORIGINAL</span>
|
| 414 |
+
</div>
|
| 415 |
+
<div class="demo-actions">
|
| 416 |
+
<button class="try-btn" id="tryDemoBtn">Try this example yourself →</button>
|
| 417 |
+
<span style="font-size:12px;color:var(--text-dim)">loads both images into the app above</span>
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
</section>
|
| 423 |
+
|
| 424 |
<footer>
|
| 425 |
Deep Analog — open-set film emulation · research preview.<br>
|
| 426 |
Your images are processed in memory and never stored. <a class="privacy-link" id="privacyOpen2">How privacy works here</a>.
|
|
|
|
| 650 |
initCompare(cmp);
|
| 651 |
}
|
| 652 |
|
| 653 |
+
// Visualizations of the predicted LUT (both modes)
|
| 654 |
+
try { body.appendChild(makeFlowCard(data.cube_lut, refFile)); }
|
| 655 |
+
catch (e) { /* canvas unavailable — skip silently */ }
|
| 656 |
try { body.appendChild(makeVizCard(data.cube_lut)); }
|
| 657 |
catch (e) { /* WebGL unavailable — skip viz silently */ }
|
| 658 |
|
|
|
|
| 698 |
cmp.addEventListener('pointerup', () => { dragging = false; });
|
| 699 |
}
|
| 700 |
|
| 701 |
+
// ------------------------------------------------ look recipe (color mapping)
|
| 702 |
+
// Canvas ribbons: everyday input colors (top) flow into what the
|
| 703 |
+
// predicted LUT turns them into (bottom). Left: the reference image
|
| 704 |
+
// and its dominant palette — the source the model learned from.
|
| 705 |
+
|
| 706 |
+
function lutSampler(out, N) {
|
| 707 |
+
const f = N - 1;
|
| 708 |
+
function at(i, j, k, ch) { return out[((k * N + j) * N + i) * 3 + ch]; }
|
| 709 |
+
return function (r, g, b) {
|
| 710 |
+
const x = Math.min(Math.max(r, 0), 1) * f;
|
| 711 |
+
const y = Math.min(Math.max(g, 0), 1) * f;
|
| 712 |
+
const z = Math.min(Math.max(b, 0), 1) * f;
|
| 713 |
+
const x0 = Math.min(Math.floor(x), N - 2);
|
| 714 |
+
const y0 = Math.min(Math.floor(y), N - 2);
|
| 715 |
+
const z0 = Math.min(Math.floor(z), N - 2);
|
| 716 |
+
const dx = x - x0, dy = y - y0, dz = z - z0;
|
| 717 |
+
const res = [0, 0, 0];
|
| 718 |
+
for (let ch = 0; ch < 3; ch++) {
|
| 719 |
+
const c00 = at(x0,y0,z0,ch)*(1-dx) + at(x0+1,y0,z0,ch)*dx;
|
| 720 |
+
const c10 = at(x0,y0+1,z0,ch)*(1-dx) + at(x0+1,y0+1,z0,ch)*dx;
|
| 721 |
+
const c01 = at(x0,y0,z0+1,ch)*(1-dx) + at(x0+1,y0,z0+1,ch)*dx;
|
| 722 |
+
const c11 = at(x0,y0+1,z0+1,ch)*(1-dx) + at(x0+1,y0+1,z0+1,ch)*dx;
|
| 723 |
+
res[ch] = (c00*(1-dy) + c10*dy)*(1-dz) + (c01*(1-dy) + c11*dy)*dz;
|
| 724 |
+
}
|
| 725 |
+
return res;
|
| 726 |
+
};
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
function hsv2rgb(h, s, v) {
|
| 730 |
+
const i = Math.floor(h * 6), fr = h * 6 - i;
|
| 731 |
+
const p = v * (1 - s), q = v * (1 - fr * s), t = v * (1 - (1 - fr) * s);
|
| 732 |
+
const m = [[v,t,p],[q,v,p],[p,v,t],[p,q,v],[t,p,v],[v,p,q]][i % 6];
|
| 733 |
+
return m;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
function rgbHex(c) {
|
| 737 |
+
return '#' + c.map(v => Math.round(Math.min(Math.max(v,0),1) * 255)
|
| 738 |
+
.toString(16).padStart(2, '0')).join('').toUpperCase();
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
function extractPalette(file, done) {
|
| 742 |
+
createImageBitmap(file).then(bmp => {
|
| 743 |
+
const S = 48;
|
| 744 |
+
const cv = document.createElement('canvas');
|
| 745 |
+
cv.width = S; cv.height = S;
|
| 746 |
+
const cx = cv.getContext('2d');
|
| 747 |
+
cx.drawImage(bmp, 0, 0, S, S);
|
| 748 |
+
const d = cx.getImageData(0, 0, S, S).data;
|
| 749 |
+
const bins = new Map();
|
| 750 |
+
for (let i = 0; i < d.length; i += 4) {
|
| 751 |
+
const key = ((d[i] >> 4) << 8) | ((d[i+1] >> 4) << 4) | (d[i+2] >> 4);
|
| 752 |
+
let b2 = bins.get(key);
|
| 753 |
+
if (!b2) { b2 = [0, 0, 0, 0]; bins.set(key, b2); }
|
| 754 |
+
b2[0] += d[i]; b2[1] += d[i+1]; b2[2] += d[i+2]; b2[3]++;
|
| 755 |
+
}
|
| 756 |
+
const sorted = [...bins.values()].sort((a, b2) => b2[3] - a[3]);
|
| 757 |
+
const picks = [];
|
| 758 |
+
for (const b2 of sorted) {
|
| 759 |
+
const c = [b2[0]/b2[3]/255, b2[1]/b2[3]/255, b2[2]/b2[3]/255];
|
| 760 |
+
if (picks.every(p =>
|
| 761 |
+
Math.hypot(p[0]-c[0], p[1]-c[1], p[2]-c[2]) > 0.16)) picks.push(c);
|
| 762 |
+
if (picks.length >= 6) break;
|
| 763 |
+
}
|
| 764 |
+
done(picks);
|
| 765 |
+
}).catch(() => done(null));
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
function makeFlowCard(cubeText, refFileMaybe) {
|
| 769 |
+
const { N, out } = parseCube(cubeText);
|
| 770 |
+
if (!N) throw new Error('bad cube');
|
| 771 |
+
const sample = lutSampler(out, N);
|
| 772 |
+
|
| 773 |
+
const wrap = document.createElement('div');
|
| 774 |
+
wrap.className = 'flow-wrap';
|
| 775 |
+
wrap.innerHTML =
|
| 776 |
+
'<div class="viz-head"><span class="t">Look recipe — reference → output' +
|
| 777 |
+
'<span class="help">?<span class="tip">The model reads the reference’s palette ' +
|
| 778 |
+
'(left) and distills a recipe from it. Each ribbon takes one everyday color (top) ' +
|
| 779 |
+
'and shows what this look turns it into (bottom) — hover any ribbon to inspect ' +
|
| 780 |
+
'the exact values. Together, the ribbons are the DNA of the look.</span></span></span>' +
|
| 781 |
+
'<span class="hint">hover a ribbon</span></div>' +
|
| 782 |
+
'<div class="flow-grid">' +
|
| 783 |
+
'<div class="flow-ref" style="display:none">' +
|
| 784 |
+
'<span class="cap">Reference — what the model saw</span>' +
|
| 785 |
+
'<img alt="Reference"><div class="pal"></div></div>' +
|
| 786 |
+
'<canvas class="flow-canvas"></canvas></div>';
|
| 787 |
+
|
| 788 |
+
const refBox = wrap.querySelector('.flow-ref');
|
| 789 |
+
const grid = wrap.querySelector('.flow-grid');
|
| 790 |
+
const canvas = wrap.querySelector('canvas');
|
| 791 |
+
|
| 792 |
+
// left column: reference thumb + extracted palette (when decodable)
|
| 793 |
+
if (refFileMaybe && PREVIEWABLE.includes(refFileMaybe.type)) {
|
| 794 |
+
wrap.querySelector('.flow-ref img').src = URL.createObjectURL(refFileMaybe);
|
| 795 |
+
extractPalette(refFileMaybe, colors => {
|
| 796 |
+
if (!colors || colors.length < 3) return;
|
| 797 |
+
const pal = wrap.querySelector('.pal');
|
| 798 |
+
colors.forEach(c => {
|
| 799 |
+
const s = document.createElement('span');
|
| 800 |
+
s.style.background = rgbHex(c);
|
| 801 |
+
s.title = rgbHex(c);
|
| 802 |
+
pal.appendChild(s);
|
| 803 |
+
});
|
| 804 |
+
refBox.style.display = 'flex';
|
| 805 |
+
});
|
| 806 |
+
} else {
|
| 807 |
+
grid.style.gridTemplateColumns = '1fr';
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
// ribbon data: 10 hues + 3 grays + skin tone
|
| 811 |
+
const inputs = [];
|
| 812 |
+
for (let i = 0; i < 10; i++) inputs.push(hsv2rgb(i / 10, 0.62, 0.85));
|
| 813 |
+
inputs.push([0.16,0.16,0.16], [0.5,0.5,0.5], [0.85,0.85,0.85], [0.85,0.66,0.52]);
|
| 814 |
+
const pairs = inputs.map(c => ({ cin: c, cout: sample(c[0], c[1], c[2]) }));
|
| 815 |
+
|
| 816 |
+
const ctx = canvas.getContext('2d');
|
| 817 |
+
let hoverIdx = -1, reveal = 0, raf = null;
|
| 818 |
+
|
| 819 |
+
function draw() {
|
| 820 |
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
| 821 |
+
const w = canvas.clientWidth, h = Math.max(canvas.clientHeight, 250);
|
| 822 |
+
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
| 823 |
+
canvas.width = w * dpr; canvas.height = h * dpr;
|
| 824 |
+
}
|
| 825 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 826 |
+
ctx.clearRect(0, 0, w, h);
|
| 827 |
+
|
| 828 |
+
const n = pairs.length, mx = 34;
|
| 829 |
+
const step = (w - mx * 2) / (n - 1);
|
| 830 |
+
const topY = 34, botY = h - 34, sw = 20;
|
| 831 |
+
|
| 832 |
+
ctx.font = '10px ui-monospace, Menlo, monospace';
|
| 833 |
+
ctx.fillStyle = 'rgba(139,139,147,.9)';
|
| 834 |
+
ctx.fillText('ANY PHOTO — original colors', mx - 10, 14);
|
| 835 |
+
ctx.fillText('AFTER THIS LOOK', mx - 10, h - 8);
|
| 836 |
+
|
| 837 |
+
pairs.forEach((p, i) => {
|
| 838 |
+
const x = mx + step * i;
|
| 839 |
+
const prog = Math.min(Math.max(reveal * n * 1.6 - i, 0), 1);
|
| 840 |
+
const dim = hoverIdx >= 0 && hoverIdx !== i;
|
| 841 |
+
ctx.globalAlpha = (dim ? 0.18 : 0.95) * prog;
|
| 842 |
+
|
| 843 |
+
// ribbon
|
| 844 |
+
const grad = ctx.createLinearGradient(0, topY, 0, botY);
|
| 845 |
+
grad.addColorStop(0, rgbHex(p.cin));
|
| 846 |
+
grad.addColorStop(1, rgbHex(p.cout));
|
| 847 |
+
ctx.strokeStyle = grad;
|
| 848 |
+
ctx.lineWidth = hoverIdx === i ? 15 : 11;
|
| 849 |
+
ctx.lineCap = 'round';
|
| 850 |
+
const sway = Math.sin(i * 1.7) * 10;
|
| 851 |
+
ctx.beginPath();
|
| 852 |
+
ctx.moveTo(x, topY + sw / 2 + 4);
|
| 853 |
+
const endY = topY + sw / 2 + 4 + (botY - topY - sw - 8) * prog;
|
| 854 |
+
ctx.bezierCurveTo(x + sway, topY + (botY - topY) * 0.4,
|
| 855 |
+
x - sway, topY + (botY - topY) * 0.6,
|
| 856 |
+
x, endY);
|
| 857 |
+
ctx.stroke();
|
| 858 |
+
|
| 859 |
+
// swatches
|
| 860 |
+
ctx.globalAlpha = dim ? 0.3 : 1;
|
| 861 |
+
ctx.fillStyle = rgbHex(p.cin);
|
| 862 |
+
rr(ctx, x - sw/2, topY - sw/2, sw, sw, 5); ctx.fill();
|
| 863 |
+
ctx.globalAlpha = (dim ? 0.3 : 1) * prog;
|
| 864 |
+
ctx.fillStyle = rgbHex(p.cout);
|
| 865 |
+
rr(ctx, x - sw/2, botY - sw/2, sw, sw, 5); ctx.fill();
|
| 866 |
+
ctx.globalAlpha = 1;
|
| 867 |
+
});
|
| 868 |
+
|
| 869 |
+
// hover readout
|
| 870 |
+
if (hoverIdx >= 0) {
|
| 871 |
+
const p = pairs[hoverIdx];
|
| 872 |
+
const x = mx + step * hoverIdx;
|
| 873 |
+
ctx.font = '11px ui-monospace, Menlo, monospace';
|
| 874 |
+
ctx.textAlign = x > w - 90 ? 'right' : 'left';
|
| 875 |
+
const tx = x > w - 90 ? x - 16 : x + 16;
|
| 876 |
+
ctx.fillStyle = 'rgba(236,236,236,.95)';
|
| 877 |
+
ctx.fillText(rgbHex(p.cin), tx, topY + 4);
|
| 878 |
+
ctx.fillText(rgbHex(p.cout), tx, botY + 4);
|
| 879 |
+
ctx.textAlign = 'left';
|
| 880 |
+
}
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
function rr(c, x, y, w2, h2, r) {
|
| 884 |
+
c.beginPath();
|
| 885 |
+
c.moveTo(x + r, y);
|
| 886 |
+
c.arcTo(x + w2, y, x + w2, y + h2, r);
|
| 887 |
+
c.arcTo(x + w2, y + h2, x, y + h2, r);
|
| 888 |
+
c.arcTo(x, y + h2, x, y, r);
|
| 889 |
+
c.arcTo(x, y, x + w2, y, r);
|
| 890 |
+
c.closePath();
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
function animate() {
|
| 894 |
+
reveal = Math.min(reveal + 0.02, 1);
|
| 895 |
+
draw();
|
| 896 |
+
if (reveal < 1) raf = requestAnimationFrame(animate);
|
| 897 |
+
}
|
| 898 |
+
// start when it enters the viewport
|
| 899 |
+
const io = new IntersectionObserver(entries => {
|
| 900 |
+
if (entries[0].isIntersecting) { io.disconnect(); animate(); }
|
| 901 |
+
}, { threshold: 0.25 });
|
| 902 |
+
io.observe(canvas);
|
| 903 |
+
|
| 904 |
+
canvas.addEventListener('pointermove', e => {
|
| 905 |
+
const rect = canvas.getBoundingClientRect();
|
| 906 |
+
const n = pairs.length, mx = 34;
|
| 907 |
+
const step = (rect.width - mx * 2) / (n - 1);
|
| 908 |
+
const idx = Math.round((e.clientX - rect.left - mx) / step);
|
| 909 |
+
hoverIdx = (idx >= 0 && idx < n) ? idx : -1;
|
| 910 |
+
if (reveal >= 1) draw();
|
| 911 |
+
});
|
| 912 |
+
canvas.addEventListener('pointerleave', () => {
|
| 913 |
+
hoverIdx = -1;
|
| 914 |
+
if (reveal >= 1) draw();
|
| 915 |
+
});
|
| 916 |
+
window.addEventListener('resize', () => { if (reveal > 0) draw(); });
|
| 917 |
+
|
| 918 |
+
return wrap;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
// ------------------------------------------------ 3D LUT visualization
|
| 922 |
// Self-contained WebGL point cloud — no libraries, no external requests.
|
| 923 |
// Shows the LUT lattice morphing from identity (perfect cube) to the
|
|
|
|
| 940 |
const wrap = document.createElement('div');
|
| 941 |
wrap.className = 'viz-wrap';
|
| 942 |
wrap.innerHTML =
|
| 943 |
+
'<div class="viz-head"><span class="t">Color space — 3D LUT' +
|
| 944 |
+
'<span class="help">?<span class="tip">A 3D LUT (look-up table) is a color recipe: ' +
|
| 945 |
+
'for every color a photo can contain, it stores the color the film would turn it into. ' +
|
| 946 |
+
'Each glowing dot below is one color — press play to watch the film’s recipe bend ' +
|
| 947 |
+
'the entire color space. The .cube file you download is exactly this transform, ready ' +
|
| 948 |
+
'for Photoshop, Lightroom, or DaVinci Resolve.</span></span></span>' +
|
| 949 |
'<span class="hint">drag to rotate · scroll to zoom</span></div>' +
|
| 950 |
'<canvas class="viz-canvas"></canvas>' +
|
| 951 |
'<div class="viz-controls">' +
|
|
|
|
| 1154 |
return wrap;
|
| 1155 |
}
|
| 1156 |
|
| 1157 |
+
// ------------------------------------------------ demo section
|
| 1158 |
+
// Shown only if demo assets exist on the server (static/demo/*.jpg).
|
| 1159 |
+
(function initDemo() {
|
| 1160 |
+
const paths = {
|
| 1161 |
+
ref: 'static/demo/ref.jpg',
|
| 1162 |
+
target: 'static/demo/target.jpg',
|
| 1163 |
+
result: 'static/demo/result.jpg',
|
| 1164 |
+
};
|
| 1165 |
+
const probe = new Image();
|
| 1166 |
+
probe.onload = () => {
|
| 1167 |
+
$('demoRefImg').src = paths.ref;
|
| 1168 |
+
$('demoTargetImg').src = paths.target;
|
| 1169 |
+
$('demoResultImg').src = paths.result;
|
| 1170 |
+
$('demoSection').style.display = 'block';
|
| 1171 |
+
initCompare($('demoCompare'));
|
| 1172 |
+
|
| 1173 |
+
$('tryDemoBtn').addEventListener('click', async () => {
|
| 1174 |
+
try {
|
| 1175 |
+
$('tryDemoBtn').textContent = 'Loading…';
|
| 1176 |
+
const [rb, tb] = await Promise.all([
|
| 1177 |
+
fetch(paths.ref).then(r => r.blob()),
|
| 1178 |
+
fetch(paths.target).then(r => r.blob()),
|
| 1179 |
+
]);
|
| 1180 |
+
refFile = new File([rb], 'demo-reference.jpg', { type: 'image/jpeg' });
|
| 1181 |
+
tgtFile = new File([tb], 'demo-photo.jpg', { type: 'image/jpeg' });
|
| 1182 |
+
fillZone('refZone', refFile, () => { refFile = null; clearZone('refZone'); update(); });
|
| 1183 |
+
fillZone('tgtZone', tgtFile, () => { tgtFile = null; clearZone('tgtZone'); update(); });
|
| 1184 |
+
update();
|
| 1185 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 1186 |
+
} finally {
|
| 1187 |
+
$('tryDemoBtn').textContent = 'Try this example yourself →';
|
| 1188 |
+
}
|
| 1189 |
+
});
|
| 1190 |
+
};
|
| 1191 |
+
probe.src = paths.ref;
|
| 1192 |
+
})();
|
| 1193 |
+
|
| 1194 |
// ------------------------------------------------ privacy modal
|
| 1195 |
['privacyOpen', 'privacyOpen2'].forEach(id => {
|
| 1196 |
const el = $(id);
|