deep-analog / static /index.html
EtonMu's picture
Update app code and page
551d142 verified
Raw
History Blame Contribute Delete
109 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<title>DEEP ANALOG: Machine Learning Powered Color Rebuild</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cdefs%3E%3CclipPath id='c'%3E%3Ccircle cx='12' cy='12' r='9.6'/%3E%3C/clipPath%3E%3C/defs%3E%3Cg clip-path='url(%23c)' fill='%23E8A33D'%3E%3Cg transform='rotate(0 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(45 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(90 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(135 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(180 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(225 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(270 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3Cg transform='rotate(315 12 12)'%3E%3Cpath d='M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E">
<style>
:root {
--bg: #0b0b0d;
--bg-2: #131316;
--bg-3: #1a1a1f;
--line: #26262c;
--text: #ececec;
--text-dim: #8b8b93;
--accent: #e8a33d;
--accent-soft: rgba(232, 163, 61, .12);
--danger: #e06060;
--radius: 14px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { color-scheme: dark; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
/* film grain overlay */
body::before {
content: "";
position: fixed; inset: 0;
pointer-events: none;
opacity: .05;
z-index: 1000;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
}
.mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
/* ---------- gate ---------- */
#gate {
position: fixed; inset: 0; z-index: 900;
display: flex; align-items: center; justify-content: center;
background:
radial-gradient(1200px 600px at 70% -10%, rgba(232,163,61,.07), transparent 60%),
var(--bg);
}
.gate-card {
width: min(380px, 90vw);
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 40px 36px;
text-align: center;
}
.gate-card h1 { font-size: 22px; letter-spacing: .16em; font-weight: 600; }
.gate-card p { color: var(--text-dim); font-size: 13.5px; margin: 10px 0 26px; }
.gate-card input {
width: 100%;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
font-size: 18px; letter-spacing: .4em; text-align: center;
padding: 12px;
outline: none;
transition: border-color .2s;
}
.gate-card input:focus { border-color: var(--accent); }
.gate-card button {
width: 100%; margin-top: 14px;
background: var(--accent); border: none; border-radius: 10px;
color: #17110a; font-size: 14px; font-weight: 600;
padding: 12px; cursor: pointer;
}
.gate-err { color: var(--danger); font-size: 12.5px; margin-top: 12px; min-height: 16px; }
/* ---------- layout ---------- */
.wrap { max-width: 1060px; margin: 0 auto; padding: 0 24px 80px; }
header {
display: flex; align-items: baseline; justify-content: space-between;
padding: 34px 0 8px;
}
.brand { font-size: 19px; font-weight: 700; letter-spacing: .18em; }
.brand span { color: var(--accent); }
.privacy-link {
color: var(--text-dim); font-size: 13px; text-decoration: none;
border-bottom: 1px dotted var(--text-dim); cursor: pointer;
}
.privacy-link:hover { color: var(--text); }
.brand-sub {
font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase;
color: var(--text-dim); margin-top: 5px;
}
.privacy-blurb {
display: block; max-width: 300px; text-align: right;
font-size: 11.5px; line-height: 1.55;
border-bottom: none;
text-decoration: underline;
text-decoration-color: var(--text-dim);
text-underline-offset: 3px;
}
.privacy-blurb:hover { color: var(--accent); text-decoration-color: var(--accent); }
header { align-items: flex-start; }
.tagline { color: var(--text-dim); font-size: 14.5px; margin: 4px 0 36px; max-width: 640px; line-height: 1.55; }
/* ---------- dropzones ---------- */
.zones { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 720px) { .zones { grid-template-columns: 1fr; } }
.zone {
position: relative;
background: var(--bg-2);
border: 1.5px dashed var(--line);
border-radius: var(--radius);
min-height: 240px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 22px;
cursor: pointer;
transition: border-color .2s, background .2s;
overflow: hidden;
}
.zone:hover, .zone.drag { border-color: var(--accent); background: var(--accent-soft); }
.zone.filled { border-style: solid; border-color: var(--line); cursor: default; }
.zone .z-tag {
position: absolute; top: 14px; left: 16px;
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
color: var(--text-dim); z-index: 3;
}
.zone .z-req { color: var(--accent); }
.zone .z-icon { font-size: 30px; opacity: .55; }
.zone .z-title { font-size: 15px; font-weight: 600; }
.zone .z-sub { font-size: 12.5px; color: var(--text-dim); text-align: center; line-height: 1.5; max-width: 320px; }
.zone img.preview {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; z-index: 1; opacity: .92;
}
.zone .file-chip {
position: absolute; bottom: 12px; left: 12px; right: 12px; z-index: 3;
background: rgba(10,10,12,.82); backdrop-filter: blur(6px);
border: 1px solid var(--line); border-radius: 9px;
padding: 8px 12px; font-size: 12.5px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-chip .clear {
color: var(--text-dim); cursor: pointer; font-size: 15px; line-height: 1;
background: none; border: none; padding: 2px 4px;
}
.file-chip .clear:hover { color: var(--danger); }
.zone input[type=file] { display: none; }
/* ---------- controls ---------- */
.controls {
margin-top: 18px; background: var(--bg-2); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 22px;
display: none;
}
.controls.visible { display: block; }
.controls h3 { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 16px; }
.sliders { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 22px; }
@media (max-width: 720px) { .sliders { grid-template-columns: 1fr; } }
.help .tip img { display: block; width: 100%; border-radius: 6px; margin-top: 9px; }
.slider .help { width: 15px; height: 15px; font-size: 10px; margin-left: 6px; }
.slider .tip { width: min(356px, 82vw); }
.slider label { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 8px; color: var(--text); }
.slider label output { color: var(--accent); }
input[type=range] {
width: 100%; appearance: none; height: 3px; border-radius: 2px;
background: var(--line); outline: none;
}
input[type=range]::-webkit-slider-thumb {
appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer; border: 3px solid var(--bg-2);
}
/* ---------- action ---------- */
.action { margin-top: 22px; display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
#processBtn {
background: var(--accent); color: #17110a;
border: none; border-radius: 11px;
font-size: 15px; font-weight: 700; letter-spacing: .02em;
padding: 14px 36px; cursor: pointer;
transition: transform .1s, opacity .2s;
}
#processBtn:hover:not(:disabled) { transform: translateY(-1px); }
#processBtn:disabled { opacity: .35; cursor: not-allowed; }
.status { font-size: 13.5px; color: var(--text-dim); display: flex; align-items: center; gap: 10px; }
.spinner {
width: 15px; height: 15px; border-radius: 50%;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; display: none;
}
.status.busy .spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.err { color: var(--danger); }
/* ---------- results ---------- */
#results { margin-top: 44px; display: none; }
#results h2 { font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 18px; }
.result-card {
background: var(--bg-2); border: 1px solid var(--line);
border-radius: var(--radius); padding: 22px; margin-bottom: 18px;
}
.compare { position: relative; border-radius: 10px; overflow: hidden; user-select: none; touch-action: none; }
.compare img { display: block; width: 100%; }
.compare .top-img {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; clip-path: inset(0 0 0 50%);
}
.compare .divider {
position: absolute; top: 0; bottom: 0; left: 50%; width: 2px;
background: var(--accent); cursor: ew-resize; z-index: 5;
}
.compare .divider::after {
content: "◂ ▸"; position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: var(--accent); color: #17110a;
font-size: 11px; padding: 5px 8px; border-radius: 20px; white-space: nowrap;
}
.compare .cmp-label {
position: absolute; top: 12px; font-size: 11px; letter-spacing: .1em;
background: rgba(10,10,12,.72); padding: 4px 10px; border-radius: 6px; z-index: 4;
}
.chart-pair { display: grid; gap: 10px; }
.chart-pair figure { margin: 0; }
.chart-pair img { width: 100%; border-radius: 8px; display: block; }
.chart-pair figcaption { font-size: 11.5px; color: var(--text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 6px; }
/* portrait results shouldn't take over the page */
#results .compare { width: fit-content; max-width: 100%; margin: 0 auto; }
#results .compare img.base-img {
display: block; width: auto; max-width: 100%; max-height: 68vh;
}
.downloads { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; align-items: center; }
.dl-wrap { display: inline-flex; align-items: center; gap: 7px; }
.dl-btn svg { flex: 0 0 auto; }
.dl-btn.primary { background: var(--accent); border-color: var(--accent); color: #17110a; }
.dl-btn.primary .kind { color: rgba(23,17,10,.7); }
.dl-btn.primary:hover { box-shadow: 0 4px 26px rgba(232,163,61,.45); }
.dl-btn .dlm { color: var(--text-dim); display: inline-flex; margin-left: 2px; transition: color .15s, transform .15s; }
.dl-btn:hover .dlm { color: var(--accent); transform: translateY(2px); }
.dl-btn.primary .dlm, .dl-btn.primary:hover .dlm { color: #17110a; }
/* ---------- reference tabs (playground col 1) ---------- */
.ref-tabs2 {
position: relative; display: flex;
background: var(--bg-3); border: 1px solid var(--line);
border-radius: 11px; padding: 4px; margin-bottom: 12px;
}
.rtab {
flex: 1; position: relative; z-index: 1;
background: none; border: none;
color: var(--text-dim); font-size: 12.5px; font-weight: 700;
padding: 9px 6px; cursor: pointer; border-radius: 8px;
transition: color .3s;
}
.rtab.sel { color: #17110a; }
.tab-ind {
position: absolute; top: 4px; bottom: 4px; left: 4px;
width: calc(50% - 4px);
background: var(--accent); border-radius: 8px;
box-shadow: 0 2px 16px rgba(232,163,61,.4);
transition: transform .4s cubic-bezier(.3,1.5,.4,1);
}
.pane.enter { animation: paneIn .32s cubic-bezier(.2,.7,.2,1); }
@keyframes paneIn { from { opacity: 0; transform: translateY(10px); } }
.film-hint { font-size: 11px; color: var(--text-dim); margin: 10px 2px 0; line-height: 1.5; }
/* film card: canister sits left of the caption text */
.look-card.film .ov { display: flex; align-items: center; gap: 9px; }
.look-card.film .ov .can-btn2 {
flex: 0 0 auto; line-height: 0;
filter: drop-shadow(0 1px 3px rgba(0,0,0,.6));
transition: transform .12s;
}
.look-card.film .ov .can-btn2:hover { transform: scale(1.15); }
/* process button: hover = shine sweep + aperture turn; click = shutter snap */
#processBtn {
display: inline-flex; align-items: center; gap: 10px;
position: relative; overflow: hidden;
}
#processBtn .ap {
flex: 0 0 auto;
transition: transform .6s cubic-bezier(.3,1.4,.4,1);
}
#processBtn .ap .bl {
transform-box: view-box;
transform-origin: 13.88px 1.36px;
transition: transform .6s cubic-bezier(.4,1.2,.4,1);
}
#processBtn:hover:not(:disabled) .ap { transform: rotate(38deg); }
#processBtn:hover:not(:disabled) .ap .bl { transform: rotate(-28deg) scale(1.35); }
#processBtn .pdot {
display: none; flex: 0 0 auto;
width: 9px; height: 9px; border-radius: 50%;
background: #17110a;
animation: dotpulse 1.15s ease-in-out infinite;
}
@keyframes dotpulse {
0%, 100% { background: #17110a; transform: scale(1); }
50% { background: #ffffff; transform: scale(1.18); }
}
#processBtn.busy .ap { display: none; }
#processBtn.busy .pdot { display: inline-block; }
#processBtn::before {
content: ""; position: absolute; top: -4px; bottom: -4px;
width: 42%; left: -60%;
background: linear-gradient(105deg, transparent, rgba(255,255,255,.55), transparent);
transform: skewX(-18deg);
pointer-events: none;
}
#processBtn:hover:not(:disabled)::before { animation: shine .75s ease; }
@keyframes shine { to { left: 135%; } }
#processBtn.snap { animation: pressScale .45s cubic-bezier(.2,.7,.2,1); }
@keyframes pressScale { 0% { transform: scale(1); } 30% { transform: scale(.93); } 100% { transform: scale(1); } }
#processBtn.snap::after {
content: ""; position: absolute; inset: 0;
background: #fff; opacity: 0; pointer-events: none;
animation: btnflash .45s ease-out;
}
@keyframes btnflash { 0% { opacity: 0; } 28% { opacity: .8; } 100% { opacity: 0; } }
/* extracted parameters panel (anatomy, under the 3D LUT) */
.param-scroll { max-height: 330px; overflow-y: auto; padding-right: 6px; margin-top: 12px; }
.param-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.psec {
grid-column: 1 / -1;
font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent); margin: 8px 0 0; padding-bottom: 3px;
border-bottom: 1px dashed var(--line);
}
.psec:first-child { margin-top: 0; }
.pchip {
background: var(--bg-3); border: 1px solid var(--line); border-radius: 9px;
padding: 8px 11px;
}
.pchip .k { font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-dim); }
.pchip .v { font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--text); margin-top: 3px; display: flex; align-items: center; gap: 6px; }
.pchip .sw { width: 13px; height: 13px; border-radius: 4px; border: 1px solid rgba(255,255,255,.15); display: inline-block; }
/* per-reference look downloads (playground result column) */
.look-dl { margin-top: 18px; border-top: 1px dashed var(--line); padding-top: 14px; }
.look-dl .t { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 10px; }
.look-dl .t b { color: var(--accent); text-transform: none; letter-spacing: 0; }
.look-dl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.dl-btn.mini { padding: 8px 11px; font-size: 12px; gap: 7px; justify-content: flex-start; }
.dl-btn.mini .kind { font-size: 10px; }
/* look anatomy: recipe + 3D LUT in one module */
.anatomy {
border: 1px solid var(--line); border-radius: 12px;
background: rgba(255,255,255,.015);
padding: 18px 20px 20px; margin-top: 26px;
}
.anatomy-grid { display: grid; grid-template-columns: 1.15fr 1fr; gap: 22px; align-items: start; }
@media (max-width: 820px) { .anatomy-grid { grid-template-columns: 1fr; } }
.anatomy .flow-wrap, .anatomy .viz-wrap { margin-top: 0; }
.anatomy .flow-grid { grid-template-columns: 1fr; }
.anatomy .viz-canvas { height: 300px; }
.anatomy .flow-canvas { min-height: 230px; }
.dl-btn {
display: inline-flex; align-items: center; gap: 9px;
background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px;
color: var(--text); font-size: 13.5px; font-weight: 600;
padding: 11px 18px; cursor: pointer; text-decoration: none;
transition: border-color .2s;
}
.dl-btn:hover { border-color: var(--accent); }
.dl-btn .kind { color: var(--accent); font-size: 11px; letter-spacing: .08em; }
/* ---------- 3D LUT visualization ---------- */
.viz-wrap { margin-top: 20px; }
.viz-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.viz-head .t { font-size: 11.5px; color: var(--text-dim); letter-spacing: .12em; text-transform: uppercase; }
.viz-head .hint { font-size: 11px; color: var(--text-dim); }
.viz-canvas {
width: 100%; height: 420px; display: block;
border-radius: 10px; border: 1px solid var(--line);
background: radial-gradient(600px 380px at 50% 45%, #17171c, #0a0a0c 80%);
cursor: grab; touch-action: none;
}
.viz-canvas:active { cursor: grabbing; }
.viz-controls { display: flex; align-items: center; gap: 14px; margin-top: 12px; }
.viz-controls .vlbl { font-size: 11px; letter-spacing: .1em; color: var(--text-dim); text-transform: uppercase; white-space: nowrap; }
.viz-controls input[type=range] { flex: 1; }
.viz-play {
background: var(--bg-3); border: 1px solid var(--line); border-radius: 8px;
color: var(--accent); font-size: 12px; padding: 6px 12px; cursor: pointer;
}
.viz-play:hover { border-color: var(--accent); }
/* ---------- help tooltip ---------- */
.help {
position: relative; display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; margin-left: 8px;
border: 1px solid var(--text-dim); border-radius: 50%;
color: var(--text-dim); font-size: 10.5px; font-weight: 600;
cursor: help; vertical-align: middle; user-select: none;
}
.help:hover { border-color: var(--accent); color: var(--accent); }
.help .tip {
position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%);
width: min(320px, 76vw);
background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 14px;
font-size: 12.5px; font-weight: 400; line-height: 1.6; color: #c9c9cf;
text-transform: none; letter-spacing: normal; text-align: left;
opacity: 0; pointer-events: none; transition: opacity .15s;
z-index: 30;
}
.help:hover .tip { opacity: 1; }
/* ---------- look recipe (color mapping) ---------- */
.flow-wrap { margin-top: 26px; }
.flow-grid { display: grid; grid-template-columns: 210px 1fr; gap: 18px; align-items: stretch; }
@media (max-width: 720px) { .flow-grid { grid-template-columns: 1fr; } }
.flow-ref { display: flex; flex-direction: column; gap: 8px; }
.flow-ref img { width: 100%; border-radius: 8px; display: block; border: 1px solid var(--line); }
.flow-ref .pal { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; }
.flow-ref .pal span { display: block; aspect-ratio: 1; border-radius: 5px; border: 1px solid rgba(255,255,255,.08); }
.flow-ref .cap { font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-dim); }
.flow-canvas {
width: 100%; height: 100%; min-height: 250px; display: block;
border-radius: 10px; border: 1px solid var(--line);
background: radial-gradient(500px 300px at 50% 50%, #17171c, #0a0a0c 85%);
}
/* ---------- demo section ---------- */
.demo { margin-top: 72px; }
.demo > h2 { font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 18px; }
.demo-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; }
.demo-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; align-items: start; }
@media (max-width: 760px) { .demo-grid { grid-template-columns: 1fr; } }
.demo-step { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 8px; }
.demo-step b { color: var(--accent); font-weight: 700; }
.demo-ref img { width: 100%; border-radius: 8px; display: block; }
.demo-note { font-size: 11px; color: var(--text-dim); margin-top: 8px; line-height: 1.5; }
.demo-actions { margin-top: 16px; display: flex; align-items: center; gap: 14px; }
.try-btn {
background: var(--accent-soft); color: var(--accent);
border: 1px solid var(--accent); border-radius: 10px;
font-size: 13.5px; font-weight: 600; padding: 10px 20px; cursor: pointer;
}
.try-btn:hover { background: var(--accent); color: #17110a; }
/* ---------- movie looks ---------- */
.section-sub { font-size: 13px; color: var(--text-dim); margin: -8px 0 18px; line-height: 1.55; }
.movies-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 900px) { .movies-grid { grid-template-columns: repeat(2, 1fr); } }
.movie-card {
position: relative; border-radius: 11px; overflow: hidden;
border: 1.5px solid var(--line); background: var(--bg-3);
cursor: pointer; transition: border-color .15s, transform .12s;
}
.movie-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.movie-card .still { width: 100%; height: 128px; object-fit: cover; display: block; }
.movie-card .cap { padding: 8px 11px 9px; }
.movie-card .cap .title { font-size: 12.5px; font-weight: 600; color: var(--text); font-style: italic; }
.movie-card .cap .meta { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.movie-card .use {
position: absolute; top: 8px; right: 8px;
background: var(--accent); color: #17110a;
font-size: 10.5px; font-weight: 700; letter-spacing: .04em;
padding: 4px 10px; border-radius: 20px;
opacity: 0; transform: translateY(-3px); transition: opacity .15s, transform .15s;
pointer-events: none;
}
.movie-card:hover .use { opacity: 1; transform: translateY(0); }
.movie-card.sel { border-color: var(--accent); }
.movie-card.sel .use { opacity: 1; transform: translateY(0); }
/* ---------- playground 3-column layout ---------- */
.play-grid { display: grid; grid-template-columns: 1.05fr 0.85fr 1.5fr; gap: 20px; align-items: start; }
@media (max-width: 900px) { .play-grid { grid-template-columns: 1fr; } }
.play-sticky { position: sticky; top: 16px; }
.ref-group-label {
font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
color: var(--text-dim); margin: 14px 0 8px;
}
.lab-box {
border: 1.5px dashed var(--accent); border-radius: 11px;
background: var(--accent-soft); padding: 10px; margin-top: 14px;
}
.lab-box .ref-group-label { color: var(--accent); margin: 0 0 8px; display: flex; justify-content: space-between; align-items: baseline; }
.lab-box .ref-group-label .sub { font-size: 10px; letter-spacing: .04em; text-transform: none; color: var(--text-dim); }
.ref-list { display: flex; flex-direction: column; gap: 6px; }
.ref-list.scroll { max-height: 580px; overflow-y: auto; padding-right: 4px; }
.ref-list.tall { max-height: 560px; overflow-y: auto; padding-right: 4px; }
.pick-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 9px; border: 1.5px solid var(--line); border-radius: 9px;
background: var(--bg-3); cursor: pointer;
transition: border-color .12s, background .12s;
}
.pick-row:hover { border-color: var(--text-dim); }
.pick-row.sel { border-color: var(--accent); background: var(--accent-soft); }
.pick-row .pthumb { width: 62px; height: 40px; object-fit: cover; border-radius: 6px; flex: 0 0 auto; }
.pick-row .ptext { min-width: 0; }
.pick-row .pname { font-size: 12px; font-weight: 600; color: var(--text); line-height: 1.3; }
.pick-row .pmeta { font-size: 10.5px; color: var(--text-dim); margin-top: 1px; }
.pick-row a.plink {
margin-left: auto; flex: 0 0 auto;
font-size: 10.5px; color: var(--text-dim); text-decoration: none;
border-bottom: 1px dotted var(--text-dim); white-space: nowrap;
}
.pick-row a.plink:hover { color: var(--accent); border-color: var(--accent); }
.look-card {
position: relative; border-radius: 10px; overflow: hidden;
border: 1.5px solid var(--line); cursor: pointer; flex: 0 0 auto;
transition: border-color .12s, transform .1s;
}
.look-card:hover { border-color: var(--text-dim); transform: translateY(-1px); }
.look-card.sel { border-color: var(--accent); }
.look-card img.bg { width: 100%; height: 96px; object-fit: cover; display: block; }
.look-card.film img.bg { height: 76px; }
.look-card .ov {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 20px 10px 7px;
background: linear-gradient(transparent, rgba(0,0,0,.85));
}
.look-card .ov .pname { font-size: 12px; font-weight: 600; color: #fff; line-height: 1.3; }
.look-card .ov .pmeta { font-size: 10.5px; color: rgba(255,255,255,.72); margin-top: 1px; }
.look-card .sprk {
position: absolute; top: 0; bottom: 0; width: 13px; z-index: 2;
background-color: #0b0b0e;
background-image: radial-gradient(rgba(255,255,255,.17) 2.1px, transparent 2.6px);
background-size: 13px 12px;
background-position: center;
}
.look-card .sprk.l { left: 0; border-right: 1px solid rgba(255,255,255,.05); }
.look-card .sprk.r { right: 0; border-left: 1px solid rgba(255,255,255,.05); }
.look-card .ov { padding-left: 22px; padding-right: 22px; }
.look-card a.can-btn {
position: absolute; right: 17px; bottom: 7px; z-index: 3;
display: block; line-height: 0;
filter: drop-shadow(0 1px 3px rgba(0,0,0,.6));
transition: transform .12s;
}
.look-card a.can-btn:hover { transform: scale(1.12); }
.look-card.sel::after {
content: "✓"; position: absolute; top: 7px; right: 17px;
width: 20px; height: 20px; border-radius: 50%;
background: var(--accent); color: #17110a;
font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.input-grid {
display: grid; grid-template-columns: 1fr; gap: 10px;
max-height: 640px; overflow-y: auto; padding-right: 5px;
}
.input-grid .in-card {
position: relative; border-radius: 9px; overflow: hidden;
border: 1.5px solid var(--line); cursor: pointer;
transition: border-color .12s, transform .1s;
}
.input-grid .in-card:hover { border-color: var(--text-dim); transform: translateY(-1px); }
.input-grid .in-card.sel { border-color: var(--accent); }
.input-grid .in-card img { width: 100%; height: 118px; object-fit: cover; display: block; }
.input-grid .in-card.sel::after {
content: "✓"; position: absolute; top: 5px; right: 5px;
width: 17px; height: 17px; border-radius: 50%;
background: var(--accent); color: #17110a;
font-size: 10.5px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.result-pair { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.result-pair img { height: 44px; width: 66px; object-fit: cover; border-radius: 7px; border: 1px solid var(--line); }
.live-hint {
border: 1.5px dashed var(--line); border-radius: 10px;
padding: 26px 18px; text-align: center;
font-size: 12.5px; color: var(--text-dim); line-height: 1.6;
}
/* ---------- playground tabs + pair bar ---------- */
.ref-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.tab-btn {
background: var(--bg-3); border: 1.5px solid var(--line); border-radius: 20px;
color: var(--text-dim); font-size: 12.5px; font-weight: 600;
padding: 7px 18px; cursor: pointer; transition: all .15s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.sel { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.pair-bar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--bg-3); border: 1px solid var(--line); border-radius: 11px;
padding: 12px 14px; margin-top: 22px;
}
.pair-bar img { height: 52px; width: 78px; object-fit: cover; border-radius: 7px; border: 1px solid var(--line); }
.pair-plus { color: var(--text-dim); font-size: 16px; }
.pair-info { flex: 1; min-width: 160px; font-size: 12.5px; color: var(--text-dim); line-height: 1.5; }
.pair-info b { color: var(--text); font-weight: 600; }
/* ---------- film lab ---------- */
.lab-films { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 16px; }
@media (max-width: 820px) { .lab-films { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
.film-card {
background: var(--bg-3); border: 1.5px solid var(--line); border-radius: 11px;
overflow: hidden; cursor: pointer; transition: border-color .15s, transform .1s;
}
.film-card:hover { transform: translateY(-2px); }
.film-card.sel { border-color: var(--accent); }
.film-card .thumb { width: 100%; height: 76px; object-fit: cover; display: block; }
.film-card .meta { display: flex; align-items: center; gap: 8px; padding: 8px 10px; }
.film-card .canister { flex: 0 0 auto; }
.film-card a.stock {
font-size: 11.5px; font-weight: 600; line-height: 1.35; color: var(--text);
text-decoration: none; border-bottom: 1px dotted var(--text-dim);
}
.film-card a.stock:hover { color: var(--accent); border-color: var(--accent); }
.film-card a.stock .ext { color: var(--text-dim); font-size: 10px; }
.lab-inputs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.lab-inputs img {
width: 86px; height: 58px; object-fit: cover; border-radius: 7px;
border: 1.5px solid var(--line); cursor: pointer; opacity: .75;
transition: opacity .15s, border-color .15s;
}
.lab-inputs img:hover { opacity: 1; }
.lab-inputs img.sel { border-color: var(--accent); opacity: 1; }
.lab-caption { font-size: 12px; color: var(--text-dim); margin: 0 0 10px; }
.lab-caption b { color: var(--accent); font-weight: 600; }
/* ---------- motion & ambience ---------- */
body::after {
content: ""; position: fixed; inset: -20%;
pointer-events: none; z-index: 0;
background:
radial-gradient(640px 420px at 18% 24%, rgba(232,163,61,.055), transparent 62%),
radial-gradient(760px 520px at 82% 74%, rgba(90,125,165,.05), transparent 62%);
animation: drift 42s ease-in-out infinite alternate;
}
@keyframes drift { to { transform: translate(4%, -3%) scale(1.07); } }
.wrap { position: relative; z-index: 1; }
#cursorGlow {
position: fixed; width: 360px; height: 360px; border-radius: 50%;
pointer-events: none; z-index: 2;
background: radial-gradient(circle, rgba(232,163,61,.065), transparent 65%);
transform: translate(-50%, -50%);
mix-blend-mode: screen;
}
@keyframes fadeUp { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
.hero-title {
font-size: clamp(30px, 4.6vw, 46px); font-weight: 700;
letter-spacing: -0.015em; line-height: 1.1;
margin: 30px 0 12px;
animation: fadeUp .8s cubic-bezier(.2,.7,.2,1) both;
}
.hero-title em {
font-style: italic; color: var(--accent);
text-shadow: 0 0 26px rgba(232,163,61,.4);
}
.tagline { animation: fadeUp .8s .12s cubic-bezier(.2,.7,.2,1) both; }
.zones { animation: fadeUp .8s .22s cubic-bezier(.2,.7,.2,1) both; }
.brand { animation: flicker 1.5s steps(1) both; }
@keyframes flicker {
0% { opacity: 0; } 7% { opacity: 1; } 11% { opacity: .3; } 15% { opacity: 1; }
22% { opacity: .45; } 28% { opacity: 1; } 100% { opacity: 1; }
}
.reveal { opacity: 0; transform: translateY(24px);
transition: opacity .7s ease, transform .7s cubic-bezier(.2,.7,.2,1); }
.reveal.visible { opacity: 1; transform: none; }
.demo > h2 { display: flex; align-items: center; gap: 10px; }
.demo > h2::before { content: ""; width: 20px; height: 2px; background: var(--accent); flex: 0 0 auto; }
.filmstrip {
height: 26px; margin-top: 68px; border-radius: 5px;
background: #0f0f13; position: relative; opacity: .85;
}
.filmstrip::before, .filmstrip::after {
content: ""; position: absolute; left: 10px; right: 10px; height: 6px;
background: repeating-linear-gradient(90deg, #24242b 0 13px, transparent 13px 28px);
border-radius: 2px;
}
.filmstrip::before { top: 4px; }
.filmstrip::after { bottom: 4px; }
#processBtn:hover:not(:disabled) { box-shadow: 0 4px 26px rgba(232,163,61,.38); }
.try-btn:hover { box-shadow: 0 4px 20px rgba(232,163,61,.3); }
.dl-btn:hover { box-shadow: 0 3px 16px rgba(232,163,61,.14); transform: translateY(-1px); }
.dl-btn { transition: border-color .2s, transform .12s, box-shadow .2s; }
.zone.drag { transform: scale(1.015);
box-shadow: 0 0 0 4px var(--accent-soft), 0 10px 34px rgba(232,163,61,.16); }
.zone.pop { animation: pop .4s cubic-bezier(.2,.9,.3,1.35); }
@keyframes pop { 0% { transform: scale(.982); } 55% { transform: scale(1.012); } 100% { transform: scale(1); } }
.compare .divider { box-shadow: 0 0 16px rgba(232,163,61,.55); }
.compare .base-img { transition: opacity .35s; }
#results .result-card { animation: fadeUp .55s cubic-bezier(.2,.7,.2,1) both; }
.gate-card { animation: gateIn .8s cubic-bezier(.2,.7,.2,1) both; }
@keyframes gateIn { from { opacity: 0; transform: translateY(16px) scale(.975); } }
.filmloader { display: none; gap: 3px; }
.status.busy .filmloader { display: flex; }
.filmloader span {
width: 12px; height: 17px; border-radius: 2.5px;
background: var(--bg-3); border: 1px solid var(--line);
animation: frameblink 1.05s infinite;
}
.filmloader span:nth-child(2) { animation-delay: .15s; }
.filmloader span:nth-child(3) { animation-delay: .3s; }
.filmloader span:nth-child(4) { animation-delay: .45s; }
.filmloader span:nth-child(5) { animation-delay: .6s; }
@keyframes frameblink {
0%, 55%, 100% { background: var(--bg-3); border-color: var(--line); }
25% { background: var(--accent); border-color: var(--accent); }
}
body.projector::before { opacity: .22; }
body.projector .wrap { animation: projJitter 1.3s steps(3); }
@keyframes projJitter {
0% { transform: translateY(0); filter: brightness(1); }
15% { transform: translateY(-2px); filter: brightness(1.45); }
30% { transform: translateY(1px); filter: brightness(.7); }
45% { transform: translateY(-1px); filter: brightness(1.3); }
60% { transform: translateY(2px); filter: brightness(.85); }
80% { transform: translateY(-1px); filter: brightness(1.15); }
100% { transform: none; filter: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
.reveal { opacity: 1; transform: none; }
}
/* ---------- how-it-works ---------- */
.head-right {
display: flex; flex-direction: column; align-items: flex-end; gap: 7px;
}
.head-nav { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; justify-content: flex-end; }
.privacy-promise {
display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; color: var(--text-dim); cursor: pointer;
border-top: 1px solid var(--line); padding-top: 7px;
text-decoration: none; transition: color .15s;
}
.privacy-promise:hover { color: var(--text); }
@media (max-width: 720px) {
header { flex-direction: column; align-items: flex-start; gap: 16px; }
.head-right { align-items: flex-start; }
.head-nav { justify-content: flex-start; }
}
.head-link {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12.5px; font-weight: 600; color: var(--text-dim);
text-decoration: underline;
text-decoration-color: var(--text-dim);
text-underline-offset: 3px;
cursor: pointer; transition: color .15s;
}
.head-link:hover { color: var(--text); text-decoration-color: var(--accent); }
.head-link svg { flex: 0 0 auto; }
.how-link {
display: inline-flex; align-items: center; gap: 8px;
background: var(--bg-2); border: 1px solid var(--line); border-radius: 20px;
color: var(--text); font-size: 12.5px; font-weight: 600;
padding: 7px 16px; cursor: pointer;
transition: border-color .15s, box-shadow .2s;
}
.how-link:hover { border-color: var(--accent); box-shadow: 0 3px 18px rgba(232,163,61,.18); }
.how-link .q { color: var(--accent); }
.modal-card.wide { width: min(860px, 100%); }
.modal-card img.figure {
width: 100%; border-radius: 10px; display: block;
margin: 10px 0 4px; border: 1px solid var(--line);
background: #fff;
}
.modal-card .figcap { font-size: 11px; color: var(--text-dim); margin: 6px 0 0; }
.modal-card .step-n {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
background: var(--accent); color: #17110a;
font-size: 11.5px; font-weight: 700; margin-right: 8px;
}
.patent-note { color: var(--text-dim); font-size: 11px; margin-top: 6px; }
/* privacy modal illustrations */
.priv-flow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 18px 0 6px;
}
.priv-flow .pf-node {
flex: 1; min-width: 130px;
background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 12px; text-align: center;
}
.priv-flow .pf-node .e { font-size: 20px; display: block; margin-bottom: 5px; }
.priv-flow .pf-node .n { font-size: 12px; font-weight: 600; color: var(--text); }
.priv-flow .pf-node .s { font-size: 10.5px; color: var(--text-dim); margin-top: 2px; }
.priv-flow .pf-arrow { color: var(--accent); font-size: 15px; flex: 0 0 auto; text-align: center; }
.priv-flow .pf-arrow .lbl { display: block; font-size: 9px; color: var(--text-dim); letter-spacing: .06em; }
.priv-nots { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 4px; }
.priv-nots span {
font-size: 11.5px; color: var(--text-dim);
border: 1px dashed var(--line); border-radius: 16px; padding: 5px 12px;
}
.priv-nots span b { color: var(--danger); font-weight: 700; }
/* ---------- privacy modal ---------- */
#privacyModal, .modal-bg {
position: fixed; inset: 0; z-index: 950;
background: rgba(0,0,0,.72); backdrop-filter: blur(4px);
display: none; align-items: center; justify-content: center; padding: 24px;
}
#privacyModal.open, .modal-bg.open { display: flex; }
.modal-card {
width: min(640px, 100%); max-height: 84vh; overflow-y: auto;
background: var(--bg-2); border: 1px solid var(--line);
border-radius: var(--radius); padding: 34px 36px;
}
.modal-card h2 { font-size: 19px; margin-bottom: 6px; }
.modal-card .sub { color: var(--text-dim); font-size: 13px; margin-bottom: 22px; }
.modal-card h4 { font-size: 14px; margin: 20px 0 6px; color: var(--accent); }
.modal-card p { font-size: 13.5px; line-height: 1.65; color: #c9c9cf; }
.modal-card .close-btn {
margin-top: 26px; background: var(--bg-3); color: var(--text);
border: 1px solid var(--line); border-radius: 10px;
padding: 10px 22px; font-size: 13.5px; cursor: pointer;
}
footer { margin-top: 70px; color: var(--text-dim); font-size: 12px; line-height: 1.7; border-top: 1px solid var(--line); padding-top: 22px; }
</style>
</head>
<body>
<!-- ======================= ACCESS GATE ======================= -->
<div id="gate" style="display:none">
<div class="gate-card">
<h1>DEEP <span style="color:var(--accent)">ANALOG</span></h1>
<p>This service is in private testing.<br>Enter your access code to continue.</p>
<input id="gateInput" type="password" maxlength="32" autocomplete="off" placeholder="····">
<button id="gateBtn">Enter</button>
<div class="gate-err" id="gateErr"></div>
</div>
</div>
<!-- ======================= APP ======================= -->
<div class="wrap" id="appRoot" style="display:none">
<header>
<div>
<div class="brand">DEEP <span>ANALOG</span></div>
<div class="brand-sub">machine learning powered color rebuild</div>
</div>
<div class="head-right">
<nav class="head-nav">
<a class="head-link" href="https://arxiv.org/abs/2608.14702" target="_blank" rel="noopener"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z"/><path d="M14 2v5h5"/><path d="M9 13h6M9 17h6"/></svg><span>Paper</span></a>
<a class="head-link" href="https://github.com/EtonMu/deep-analog/blob/main/README.md" target="_blank" rel="noopener"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.4-.4-4.1 1.6a12.3 12.3 0 0 0-6.5 0C5.3 1.7 3.9 2.1 3.9 2.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 2.5 8.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V20"/></svg><span>GitHub</span></a>
<a class="head-link" href="static/visualization.html" target="_blank" rel="noopener"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2.5"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/><circle cx="12" cy="12" r="2.4"/></svg><span>Mechanisms</span></a>
<a class="head-link" id="privacyOpen"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5 20 6v6c0 4.6-3.4 8.2-8 9.5-4.6-1.3-8-4.9-8-9.5V6z"/><path d="m9 12 2 2 4-4"/></svg><span>Privacy</span></a>
</nav>
<a class="privacy-promise" id="privacyOpen2Top"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5 20 6v6c0 4.6-3.4 8.2-8 9.5-4.6-1.3-8-4.9-8-9.5V6z"/><path d="m9 12 2 2 4-4"/></svg><span>Processed in memory · never stored · no tracking</span></a>
</div>
</header>
<h1 class="hero-title">Extract the look of <em>any film and frame</em>.</h1>
<p class="tagline">
Upload a film scan or any image you love — the model predicts a 3D&nbsp;LUT that captures its color character,
ready for Photoshop, Lightroom, DaVinci&nbsp;Resolve, or any editor.
Optionally add one of your own photos to see the full film rendering applied.
</p>
<div class="zones">
<div class="zone" id="refZone">
<span class="z-tag"><span class="z-req"></span>&nbsp; REFERENCE — required</span>
<div class="z-icon">🎞</div>
<div class="z-title">Drop a reference image</div>
<div class="z-sub">The film look you want to extract.<br>
JPEG · PNG · TIFF · WebP · HEIC/HEIF · AVIF · RAW (CR2/CR3, NEF, ARW, RAF, DNG, …)</div>
<input type="file" id="refInput"
accept="image/*,.tif,.tiff,.heif,.heic,.hif,.avif,.raf,.cr2,.cr3,.nef,.nrw,.arw,.srf,.sr2,.dng,.orf,.rw2,.pef,.srw,.x3f,.erf,.mrw,.3fr,.mos,.mef,.iiq,.rwl,.kdc,.dcr">
</div>
<div class="zone" id="tgtZone">
<span class="z-tag">&nbsp; YOUR PHOTO — optional</span>
<div class="z-icon">📷</div>
<div class="z-title">Drop your photo</div>
<div class="z-sub">Add a photo to get it rendered in the reference look —
color, tone, grain and halation. Leave empty to just download the LUT.</div>
<input type="file" id="tgtInput"
accept="image/*,.tif,.tiff,.heif,.heic,.hif,.avif,.raf,.cr2,.cr3,.nef,.nrw,.arw,.srf,.sr2,.dng,.orf,.rw2,.pef,.srw,.x3f,.erf,.mrw,.3fr,.mos,.mef,.iiq,.rwl,.kdc,.dcr">
</div>
</div>
<div class="controls" id="controls">
<h3>Rendering controls</h3>
<div class="sliders">
<div class="slider">
<label><span>Tone match<span class="help">?<span class="tip">Matches your photo’s
brightness distribution to the reference — shadows, mids and highlights are moved
to where the film puts them. Left: off · right: full strength.
<img src="static/help/tone.jpg" alt="Tone match comparison"></span></span></span>
<output id="toneOut">0.70</output></label>
<input type="range" id="toneRange" min="0" max="1" step="0.05" value="0.7">
</div>
<div class="slider">
<label><span>Film tone<span class="help">?<span class="tip">Film’s tonal signature:
lifted blacks (no pure black), a soft highlight roll-off, and the subtle color
tint film leaves in shadows and highlights. Left: off · right: full strength.
<img src="static/help/filmtone.jpg" alt="Film tone comparison"></span></span></span>
<output id="filmOut">0.70</output></label>
<input type="range" id="filmRange" min="0" max="1" step="0.05" value="0.7">
</div>
<div class="slider">
<label><span>Grain<span class="help">?<span class="tip">How much film grain is
layered onto the result. 0 = clean, 2 = gritty high-ISO texture.
Left: 0 · right: 2×.
<img src="static/help/grain.jpg" alt="Grain comparison"></span></span></span>
<output id="grainOut">1.0×</output></label>
<input type="range" id="grainRange" min="0" max="2" step="0.1" value="1.0">
</div>
<div class="slider">
<label><span>Halation<span class="help">?<span class="tip">The red glow film paints
around bright highlights: light scatters off the film base and re-exposes the
red-sensitive layer. Slide up to push your highlights toward this signature
red film quality — like the skyline edges in this 35mm scan.
The glow Wong Kar-wai taught us to crave.
<img src="static/help/halation.jpg" alt="Red film halation on a skyline, 35mm scan"></span></span></span>
<output id="halOut">1.0×</output></label>
<input type="range" id="halRange" min="0" max="3" step="0.1" value="1.0">
</div>
</div>
</div>
<div class="action">
<button id="processBtn" disabled><svg class="ap" width="19" height="19" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><defs><clipPath id="apClip"><circle cx="12" cy="12" r="9.6"/></clipPath></defs><g clip-path="url(#apClip)"><g transform="rotate(0 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(45 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(90 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(135 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(180 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(225 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(270 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g><g transform="rotate(315 12 12)"><path class="bl" d="M10 0.67 A11.5 11.5 0 0 1 17.75 2.04 L7.91 9.34 Z"/></g></g></svg><span class="pdot" aria-hidden="true"></span><span id="processLabel">Extract the look</span></button>
<div class="status" id="status"><div class="filmloader" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div><span id="statusText"></span></div>
</div>
<div id="results">
<h2 id="resultsTitle">Results</h2>
<div class="result-card" id="resultBody"></div>
</div>
<div class="filmstrip" aria-hidden="true"></div>
<section class="demo" id="demoSection">
<h2>See it in action</h2>
<div class="demo-card">
<div class="demo-grid">
<div class="demo-ref">
<div class="demo-step"><b>1</b>&nbsp; Pick a reference — any film look you love</div>
<img id="demoRefImg" alt="Reference film still">
<div class="demo-note">Still from <i>Happy Together</i> (1997), dir. Wong Kar-wai — shown for demonstration.</div>
<div class="demo-step" style="margin-top:18px"><b>2</b>&nbsp; Add your own photo — the input</div>
<img id="demoInputImg" alt="Input photo" style="width:100%;border-radius:8px;display:block">
</div>
<div>
<div class="demo-step"><b>3</b>&nbsp; Get it rendered in that look. Drag the divider to compare.</div>
<div class="compare" id="demoCompare">
<img class="base-img" id="demoResultImg" alt="Rendered result">
<img class="top-img" id="demoTargetImg" alt="Original photo">
<div class="divider"></div>
<span class="cmp-label" style="left:12px">RENDERED</span>
<span class="cmp-label" style="right:12px">ORIGINAL</span>
</div>
<div class="demo-actions">
<button class="try-btn" id="tryDemoBtn">Try this example yourself →</button>
<span style="font-size:12px;color:var(--text-dim)">loads both images into the app above</span>
</div>
</div>
</div>
</div>
</section>
<div class="filmstrip" aria-hidden="true"></div>
<section class="demo" id="playSection">
<h2>Try it yourself — pick a pair</h2>
<p class="section-sub">Choose a reference look and an input photo, then run the pair through the model.
Film-stock pairs with the seven test photos also show an instant pre-rendered preview.
All test photographs shot by <b style="color:var(--text)">Eton Mu</b>.</p>
<div class="demo-card">
<div class="play-grid">
<div>
<div class="demo-step" style="margin-bottom:10px"><b>1</b>&nbsp; Choose a reference</div>
<div class="ref-tabs2" id="refTabs">
<span class="tab-ind" id="tabInd" aria-hidden="true"></span>
<button class="rtab sel" id="rtabMovies">🎬&nbsp; Movie looks</button>
<button class="rtab" id="rtabFilms">🎞&nbsp; Film lab</button>
</div>
<div class="pane" id="moviePane">
<div class="ref-list scroll" id="movieList"></div>
</div>
<div class="pane" id="filmPane" style="display:none">
<div class="ref-list" id="filmList"></div>
<p class="film-hint">5 real film stocks, scanned from Eton's negatives.
Click a canister for the full story of that emulsion.</p>
</div>
</div>
<div>
<div class="demo-step" style="margin-bottom:10px"><b>2</b>&nbsp; Choose an input photo</div>
<div class="input-grid" id="inputList"></div>
</div>
<div>
<div class="play-sticky">
<div class="demo-step" style="margin-bottom:10px"><b>3</b>&nbsp; Result</div>
<div class="result-pair">
<img id="pairRef" alt="Selected reference">
<span class="pair-plus"></span>
<img id="pairInput" alt="Selected input">
</div>
<p class="lab-caption" id="pairInfo" style="margin-bottom:12px"></p>
<div id="instantWrap" style="display:none">
<div class="compare" id="labCompare">
<img class="base-img" alt="Rendered">
<img class="top-img" alt="Original">
<div class="divider"></div>
<span class="cmp-label" style="left:12px">RENDERED</span>
<span class="cmp-label" style="right:12px">ORIGINAL</span>
</div>
<p class="lab-caption" id="labCaption" style="margin-top:8px">
Pre-rendered preview · drag the divider to compare</p>
</div>
<div id="liveHint" class="live-hint" style="display:none">
No pre-rendered preview for this pair.<br>
Run it live — the model predicts the LUT from this reference in seconds.
</div>
<button class="try-btn" id="runPairBtn" style="margin-top:14px;width:100%">
Run this pair in the app →</button>
<div class="look-dl">
<div class="t">Download the <b id="lookDlName"></b> look</div>
<div class="look-dl-grid">
<a class="dl-btn mini" id="lkCube"><span>LUT</span><span class="kind">.CUBE</span></a>
<a class="dl-btn mini" id="lkXmp"><span>Adobe</span><span class="kind">.XMP</span></a>
<a class="dl-btn mini" id="lkCo"><span>Capture One</span><span class="kind">.COSTYLE</span></a>
<a class="dl-btn mini" id="lkJson"><span>Parameters</span><span class="kind">.JSON</span></a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<footer>
Deep Analog — open-set film emulation · research preview · <b style="color:var(--text)">patent pending</b>.<br>
Created by <a class="privacy-link" href="https://etonmu.com" target="_blank" rel="noopener" style="color:var(--accent)">Eton Mu ↗</a> ·
Your images are processed in memory and never stored. <a class="privacy-link" id="privacyOpen2">How privacy works here</a>.
</footer>
</div>
<!-- ======================= PRIVACY MODAL ======================= -->
<div id="privacyModal">
<div class="modal-card">
<h2>Privacy by Design</h2>
<div class="sub">How this service handles your images</div>
<div class="priv-flow">
<div class="pf-node"><span class="e">📱</span>
<div class="n">Your device</div><div class="s">you pick the images</div></div>
<div class="pf-arrow"><span class="lbl">HTTPS 🔒</span></div>
<div class="pf-node"><span class="e"></span>
<div class="n">Server RAM only</div><div class="s">processed in memory,<br>discarded instantly</div></div>
<div class="pf-arrow"><span class="lbl">same response</span></div>
<div class="pf-node"><span class="e">🎞</span>
<div class="n">Back to you</div><div class="s">LUT + rendered photo,<br>metadata stripped</div></div>
</div>
<div class="priv-nots">
<span><b></b> disk / database</span>
<span><b></b> logs of your files</span>
<span><b></b> cookies &amp; analytics</span>
<span><b></b> third-party requests</span>
<span><b></b> EXIF / GPS in outputs</span>
</div>
<h4>💾 Nothing is stored</h4>
<p>Your images are processed entirely in server memory (RAM). They are never written to disk,
never saved to any database, and are discarded the moment your result is sent back.
There is no way for us to retrieve an image after your request completes.</p>
<h4>🗒 Nothing is logged</h4>
<p>Server access logs are disabled. We do not record filenames, image contents,
IP-address logs, or the results generated for you. Error handling logs only the type of failure,
never your data.</p>
<h4>🍪 No tracking</h4>
<p>This page sets no cookies, runs no analytics, and loads nothing from third-party servers —
every byte comes from this app itself. Your access code is kept only in your browser's session
storage and vanishes when you close the tab.</p>
<h4>📍 Metadata is stripped</h4>
<p>Rendered images are re-encoded from raw pixels, which removes all embedded metadata
(EXIF, GPS location, camera serial numbers) from anything you download.</p>
<h4>🔒 What leaves your device</h4>
<p>Only the image files you explicitly upload, over an encrypted HTTPS connection.
The predicted LUT and rendered image are returned in the same response and belong to you.</p>
<h4>🖥 Hosting</h4>
<p>The service runs on Hugging Face infrastructure; transport is encrypted end-to-end.
No third party is granted access to request contents by this application.</p>
<p class="patent-note" style="margin-top:22px;font-style:italic">
What happens in RAM, stays in RAM.</p>
<button class="close-btn" id="privacyClose">Close</button>
</div>
</div>
<script>
(function () {
'use strict';
const $ = id => document.getElementById(id);
let accessRequired = false;
let refFile = null, tgtFile = null;
// ------------------------------------------------ config + gate
fetch('api/config').then(r => r.json()).then(cfg => {
accessRequired = cfg.access_required;
if (accessRequired && !sessionStorage.getItem('da_code')) {
$('gate').style.display = 'flex';
} else if (accessRequired) {
// validate remembered code silently
authTry(sessionStorage.getItem('da_code'), ok => {
if (ok) showApp(); else { sessionStorage.removeItem('da_code'); $('gate').style.display = 'flex'; }
});
} else {
showApp();
}
}).catch(() => { showApp(); });
function showApp() {
$('gate').style.display = 'none';
$('appRoot').style.display = 'block';
}
function authTry(code, cb) {
fetch('api/auth', { method: 'POST', headers: { 'X-Access-Code': code || '' } })
.then(r => cb(r.ok)).catch(() => cb(false));
}
$('gateBtn').addEventListener('click', submitGate);
$('gateInput').addEventListener('keydown', e => { if (e.key === 'Enter') submitGate(); });
function submitGate() {
const code = $('gateInput').value.trim();
if (!code) return;
authTry(code, ok => {
if (ok) { sessionStorage.setItem('da_code', code); showApp(); }
else { $('gateErr').textContent = 'Incorrect code. Please try again.'; $('gateInput').value = ''; }
});
}
// ------------------------------------------------ dropzones
const PREVIEWABLE = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/bmp', 'image/avif'];
function setupZone(zoneId, inputId, onFile) {
const zone = $(zoneId), input = $(inputId);
zone.addEventListener('click', e => {
if (e.target.closest('.clear')) return;
if (!zone.classList.contains('filled')) input.click();
});
input.addEventListener('change', () => { if (input.files[0]) onFile(input.files[0]); input.value = ''; });
['dragenter', 'dragover'].forEach(ev => zone.addEventListener(ev, e => {
e.preventDefault(); zone.classList.add('drag');
}));
['dragleave', 'drop'].forEach(ev => zone.addEventListener(ev, e => {
e.preventDefault(); zone.classList.remove('drag');
}));
zone.addEventListener('drop', e => {
const f = e.dataTransfer.files[0];
if (f) onFile(f);
});
}
function fillZone(zoneId, file, clearFn) {
const zone = $(zoneId);
zone.classList.add('filled');
zone.classList.remove('pop');
void zone.offsetWidth; // restart the pop animation
zone.classList.add('pop');
zone.querySelectorAll('img.preview, .file-chip').forEach(n => n.remove());
if (PREVIEWABLE.includes(file.type)) {
const img = document.createElement('img');
img.className = 'preview';
img.src = URL.createObjectURL(file);
zone.appendChild(img);
}
const chip = document.createElement('div');
chip.className = 'file-chip';
chip.innerHTML = '<span class="name mono"></span><button class="clear" title="Remove">✕</button>';
chip.querySelector('.name').textContent = file.name + ' · ' + (file.size / 1048576).toFixed(1) + ' MB';
chip.querySelector('.clear').addEventListener('click', e => { e.stopPropagation(); clearFn(); });
zone.appendChild(chip);
}
function clearZone(zoneId) {
const zone = $(zoneId);
zone.classList.remove('filled');
zone.querySelectorAll('img.preview, .file-chip').forEach(n => n.remove());
}
setupZone('refZone', 'refInput', f => { refFile = f; fillZone('refZone', f, () => { refFile = null; clearZone('refZone'); update(); }); update(); });
setupZone('tgtZone', 'tgtInput', f => { tgtFile = f; fillZone('tgtZone', f, () => { tgtFile = null; clearZone('tgtZone'); update(); }); update(); });
function update() {
$('processBtn').disabled = !refFile;
$('controls').classList.toggle('visible', !!tgtFile);
$('processLabel').textContent = tgtFile ? 'Render my photo' : 'Extract the look';
}
// ------------------------------------------------ sliders
[['toneRange', 'toneOut', v => (+v).toFixed(2)],
['filmRange', 'filmOut', v => (+v).toFixed(2)],
['grainRange', 'grainOut', v => {
const n = +v;
if (n === 0) return '0× · digitally sterile';
if (n === 2) return '2.0× · pushed like Tri-X';
return n.toFixed(1) + '×';
}],
['halRange', 'halOut', v => {
const n = +v;
return n === 3 ? '3.0× · neon noir' : n.toFixed(1) + '×';
}]]
.forEach(([r, o, fmt]) => {
$(r).addEventListener('input', () => { $(o).textContent = fmt($(r).value); });
});
// ------------------------------------------------ process
function shutterSnap() {
const btn = $('processBtn');
btn.classList.remove('snap');
void btn.offsetWidth;
btn.classList.add('snap');
setTimeout(() => btn.classList.remove('snap'), 520);
}
$('processBtn').addEventListener('click', () => {
shutterSnap();
const fd = new FormData();
fd.append('reference', refFile);
if (tgtFile) {
fd.append('target', tgtFile);
fd.append('tone_strength', $('toneRange').value);
fd.append('film_tone_strength', $('filmRange').value);
fd.append('grain_mult', $('grainRange').value);
fd.append('halation_mult', $('halRange').value);
}
setBusy(true, tgtFile ? 'Rendering — this can take up to a minute for large photos…'
: 'Predicting LUT…');
$('results').style.display = 'none';
fetch('api/process', {
method: 'POST',
headers: { 'X-Access-Code': sessionStorage.getItem('da_code') || '' },
body: fd,
})
.then(async r => {
const data = await r.json().catch(() => ({ error: 'Unexpected server response.' }));
if (!r.ok) throw new Error(data.error || ('Server error (' + r.status + ')'));
return data;
})
.then(data => { setBusy(false, ''); renderResults(data); })
.catch(err => {
const generic = !err || !err.message ||
/Failed to fetch|NetworkError|Unexpected server/.test(err.message);
setBusy(false, generic
? 'Cut! Something went wrong with this reel — check the connection and roll again.'
: err.message, true);
});
});
const WAIT_LINES = [
'Developing in the dark — no light leaks, promise.',
'Kubrick would demand 127 more takes.',
'Pushing one stop for mood.',
'Timing the print, frame by frame.',
'Waiting for the lab like it’s 1999.',
];
let waitTimer = null;
function setBusy(busy, msg, isErr) {
$('processBtn').disabled = busy || !refFile;
$('processBtn').classList.toggle('busy', !!busy);
$('status').classList.toggle('busy', !!busy);
if (waitTimer) { clearInterval(waitTimer); waitTimer = null; }
if (busy && msg && msg.indexOf('Rendering') === 0) {
const t0 = Date.now();
let i = 0;
waitTimer = setInterval(() => {
const quip = (Date.now() - t0 > 20000)
? '“The waiting is the hardest part.” — every film lab, ever'
: WAIT_LINES[i++ % WAIT_LINES.length];
$('statusText').textContent = 'Rendering… ' + quip;
}, 5000);
}
$('statusText').textContent = msg || '';
$('statusText').className = isErr ? 'err' : '';
}
// ------------------------------------------------ results
const ICO = {
photo: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>',
cube: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 21 7v10l-9 5-9-5V7z"/><path d="M12 22V12"/><path d="m3 7 9 5 9-5"/></svg>',
curve: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 20c7 0 11-16 18-16"/><circle cx="7" cy="17" r="1.6"/><circle cx="17" cy="6.5" r="1.6"/></svg>',
aperture: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m14.31 8 5.74 9.94M9.69 8h11.48M7.38 12l5.74-9.94M9.69 16 3.95 6.06M14.31 16H2.83m13.79-4-5.74 9.94"/></svg>',
braces: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg>',
};
function dlButton(label, kind, hrefFn, filename, icon, tip, primary) {
const wrap = document.createElement('span');
wrap.className = 'dl-wrap';
const a = document.createElement('a');
a.className = 'dl-btn' + (primary ? ' primary' : '');
a.innerHTML = (icon || '') + '<span></span><span class="kind"></span>';
a.querySelector('span:not(.kind)').textContent = label;
a.querySelector('.kind').textContent = kind;
a.href = hrefFn();
a.download = filename;
wrap.appendChild(a);
const dlm = document.createElement('span');
dlm.className = 'dlm';
dlm.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>';
a.appendChild(dlm);
if (tip) {
const h = document.createElement('span');
h.className = 'help';
h.innerHTML = '?<span class="tip"></span>';
h.querySelector('.tip').textContent = tip;
wrap.appendChild(h);
}
return wrap;
}
function renderResults(data) {
const body = $('resultBody');
body.innerHTML = '';
const stamp = new Date().toISOString().slice(0, 10);
if (data.mode === 'lut_only') {
$('resultsTitle').textContent = 'Predicted LUT';
const pair = document.createElement('div');
pair.className = 'chart-pair';
pair.innerHTML =
'<figure><figcaption>Neutral chart — before</figcaption><img alt="before"></figure>' +
'<figure><figcaption>With your predicted LUT</figcaption><img alt="after"></figure>';
pair.querySelectorAll('img')[0].src = 'data:image/jpeg;base64,' + data.chart_before;
pair.querySelectorAll('img')[1].src = 'data:image/jpeg;base64,' + data.chart_after;
body.appendChild(pair);
} else {
$('resultsTitle').textContent = 'Your photo, rendered';
const cmp = document.createElement('div');
cmp.className = 'compare';
cmp.innerHTML =
'<img class="base-img" alt="rendered">' +
'<img class="top-img" alt="original">' +
'<div class="divider"></div>' +
'<span class="cmp-label" style="left:12px">RENDERED</span>' +
'<span class="cmp-label" style="right:12px">ORIGINAL</span>';
cmp.querySelector('.base-img').src = 'data:image/jpeg;base64,' + data.final;
cmp.querySelector('.top-img').src = URL.createObjectURL(tgtFile && PREVIEWABLE.includes(tgtFile.type)
? tgtFile : b64ToBlob(data.graded, 'image/jpeg'));
body.appendChild(cmp);
initCompare(cmp);
}
// downloads — directly under the rendered result, photo first
const dls = document.createElement('div');
dls.className = 'downloads';
if (data.mode === 'full') {
dls.appendChild(dlButton('Rendered photo', 'JPEG · metadata-free',
() => URL.createObjectURL(b64ToBlob(data.final, 'image/jpeg')),
'deep-analog-render-' + stamp + '.jpg', ICO.photo, null, true));
}
dls.appendChild(dlButton('LUT', '.CUBE 33³',
() => URL.createObjectURL(new Blob([data.cube_lut], { type: 'text/plain' })),
'deep-analog-' + stamp + '.cube', ICO.cube,
'The exact 3D color transform of this look. Load it in Photoshop ' +
'(Color Lookup), DaVinci Resolve, Premiere or Final Cut and apply ' +
'the same grade to any photo or video. What’s in the box? Every ' +
'color, that’s what.'));
if (data.xmp) {
dls.appendChild(dlButton('Adobe preset', '.XMP · LR/ACR',
() => URL.createObjectURL(new Blob([data.xmp], { type: 'application/xml' })),
'deep-analog-' + stamp + '.xmp', ICO.curve,
'A Lightroom / Camera Raw preset with this look’s per-channel ' +
'tone curves and matching grain. Import via Presets ▸ Import ' +
'Presets. Note: XMP cannot hold a full 3D LUT — for exact ' +
'color use the .cube.'));
}
if (data.costyle) {
dls.appendChild(dlButton('Capture One style', '.COSTYLE · beta',
() => URL.createObjectURL(new Blob([data.costyle], { type: 'application/xml' })),
'deep-analog-' + stamp + '.costyle', ICO.aperture,
'A Capture One style carrying the same tone curves and film grain ' +
'(beta). Approximation — the exact color transform lives in ' +
'the .cube LUT.'));
}
dls.appendChild(dlButton('Film parameters', 'JSON preset',
() => URL.createObjectURL(new Blob([JSON.stringify(data.params, null, 2)], { type: 'application/json' })),
'deep-analog-params-' + stamp + '.json', ICO.braces,
'Every measured parameter — grain, halation, tone-transfer ' +
'curves — as JSON. For scripting, or reproducing this exact ' +
'render later.'));
body.appendChild(dls);
// Look anatomy: recipe ribbons + 3D LUT share one module
const anatomy = document.createElement('div');
anatomy.className = 'anatomy';
const ag = document.createElement('div');
ag.className = 'anatomy-grid';
anatomy.appendChild(ag);
const aL = document.createElement('div');
const aR = document.createElement('div');
ag.appendChild(aL); ag.appendChild(aR);
try { aL.appendChild(makeFlowCard(data.cube_lut, refFile)); }
catch (e) { /* canvas unavailable — skip silently */ }
try { aR.appendChild(makeVizCard(data.cube_lut)); }
catch (e) { /* WebGL unavailable — skip viz silently */ }
try { aR.appendChild(makeParamsPanel(data.params, data.cube_lut)); }
catch (e) { /* skip silently */ }
if (aL.children.length || aR.children.length) body.appendChild(anatomy);
$('results').style.display = 'block';
$('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function makeParamsPanel(p, cubeText) {
const el = document.createElement('div');
el.innerHTML =
'<div class="viz-head" style="margin-top:16px"><span class="t">Extracted parameters' +
'<span class="help">?<span class="tip">Everything the model measured: the geometry ' +
'of the predicted LUT, grain statistics, halation optics, and the tonal fingerprint ' +
'of the reference. Scroll for the full list — the raw data is in the Film ' +
'parameters JSON.</span></span></span></div>' +
'<div class="param-scroll"><div class="param-grid"></div></div>';
const grid = el.querySelector('.param-grid');
function hex(c) {
return '#' + c.map(v => Math.round(Math.min(Math.max(v, 0), 1) * 255)
.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function sec(t) {
const s = document.createElement('div');
s.className = 'psec';
s.textContent = t;
grid.appendChild(s);
}
function chip(k, v, swatch) {
const d = document.createElement('div');
d.className = 'pchip';
d.innerHTML = '<div class="k"></div><div class="v"></div>';
d.querySelector('.k').textContent = k;
const vv = d.querySelector('.v');
if (swatch) {
const s = document.createElement('span');
s.className = 'sw';
s.style.background = swatch;
vv.appendChild(s);
}
vv.appendChild(document.createTextNode(v));
grid.appendChild(d);
}
// --- LUT geometry (computed from the actual cube) ---
sec('Predicted 3D LUT');
try {
const { N, out } = parseCube(cubeText);
const n3 = N * N * N;
let sum = 0, maxd = 0;
const chSum = [0, 0, 0];
for (let i = 0; i < n3; i++) {
const r = (i % N) / (N - 1);
const g = (Math.floor(i / N) % N) / (N - 1);
const b = Math.floor(i / (N * N)) / (N - 1);
const dr = out[i * 3] - r, dg = out[i * 3 + 1] - g, db = out[i * 3 + 2] - b;
const d = Math.sqrt(dr * dr + dg * dg + db * db);
sum += d; if (d > maxd) maxd = d;
chSum[0] += dr; chSum[1] += dg; chSum[2] += db;
}
chip('Lattice', N + '×' + N + '×' + N + ' · ' + n3.toLocaleString() + ' nodes');
chip('Mean shift ‖Δrgb‖', (sum / n3).toFixed(4));
chip('Max shift', maxd.toFixed(4));
chip('Avg Δ per channel',
'R ' + (chSum[0] / n3 >= 0 ? '+' : '') + (chSum[0] / n3).toFixed(3) +
' · G ' + (chSum[1] / n3 >= 0 ? '+' : '') + (chSum[1] / n3).toFixed(3) +
' · B ' + (chSum[2] / n3 >= 0 ? '+' : '') + (chSum[2] / n3).toFixed(3));
} catch (e) { chip('Lattice', '33×33×33'); }
chip('Residual scale', '×' + p.residual_scale);
sec('Grain model');
chip('Sigma σ', String(p.grain_sigma));
chip('Grain size', String(p.grain_size));
if (p.grain_mult !== undefined) chip('User multiplier', p.grain_mult + '×');
chip('Luminance response', 'a ' + p.lum_a + ' · b ' + p.lum_b + ' · c ' + p.lum_c);
sec('Halation optics');
chip('Threshold', String(p.h_threshold));
chip('Scatter radius', String(p.h_radius));
chip('Intensity', String(p.h_intensity));
if (p.h_color_bias) {
const sig = p.h_color_bias.map(v => 1 / (1 + Math.exp(-v)));
chip('Glow color', p.h_color_bias.map(v => v.toFixed(2)).join(' / '), hex(sig));
}
if (p.halation_mult !== undefined) chip('User multiplier', p.halation_mult + '×');
sec('Reference tonality');
chip('Black point', String(p.ref_black_point));
chip('White point', String(p.ref_white_point));
chip('Contrast range', String(p.ref_contrast_range));
chip('Shadow tint', hex(p.ref_shadow_color), hex(p.ref_shadow_color));
chip('Highlight tint', hex(p.ref_highlight_color), hex(p.ref_highlight_color));
if (p.tone_strength !== undefined) {
sec('Render settings');
chip('Tone match', String(p.tone_strength));
chip('Film tone', String(p.film_tone_strength));
if (p.tone_transfer_rgb) {
chip('Tone-transfer curves', '3 × ' + p.tone_transfer_rgb[0].length + ' samples');
}
}
return el;
}
function b64ToBlob(b64, type) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type });
}
function initCompare(cmp) {
const divider = cmp.querySelector('.divider');
const top = cmp.querySelector('.top-img');
function setPos(clientX) {
const rect = cmp.getBoundingClientRect();
let p = (clientX - rect.left) / rect.width;
p = Math.max(0.02, Math.min(0.98, p));
divider.style.left = (p * 100) + '%';
top.style.clipPath = 'inset(0 0 0 ' + (p * 100) + '%)';
}
let dragging = false;
cmp.addEventListener('pointerdown', e => { dragging = true; setPos(e.clientX); cmp.setPointerCapture(e.pointerId); });
cmp.addEventListener('pointermove', e => { if (dragging) setPos(e.clientX); });
cmp.addEventListener('pointerup', () => { dragging = false; });
// cinematic sweep-in: divider glides from the right edge to center once
let userTouched = false;
cmp.addEventListener('pointerdown', () => { userTouched = true; }, { once: true });
const t0 = performance.now();
(function sweep(t) {
if (userTouched) return;
const p = Math.min((t - t0) / 950, 1);
const e = 1 - Math.pow(1 - p, 3);
const pos = 0.92 - 0.42 * e;
divider.style.left = (pos * 100) + '%';
top.style.clipPath = 'inset(0 0 0 ' + (pos * 100) + '%)';
if (p < 1) requestAnimationFrame(sweep);
})(t0);
}
// ------------------------------------------------ look recipe (color mapping)
// Canvas ribbons: everyday input colors (top) flow into what the
// predicted LUT turns them into (bottom). Left: the reference image
// and its dominant palette — the source the model learned from.
function lutSampler(out, N) {
const f = N - 1;
function at(i, j, k, ch) { return out[((k * N + j) * N + i) * 3 + ch]; }
return function (r, g, b) {
const x = Math.min(Math.max(r, 0), 1) * f;
const y = Math.min(Math.max(g, 0), 1) * f;
const z = Math.min(Math.max(b, 0), 1) * f;
const x0 = Math.min(Math.floor(x), N - 2);
const y0 = Math.min(Math.floor(y), N - 2);
const z0 = Math.min(Math.floor(z), N - 2);
const dx = x - x0, dy = y - y0, dz = z - z0;
const res = [0, 0, 0];
for (let ch = 0; ch < 3; ch++) {
const c00 = at(x0,y0,z0,ch)*(1-dx) + at(x0+1,y0,z0,ch)*dx;
const c10 = at(x0,y0+1,z0,ch)*(1-dx) + at(x0+1,y0+1,z0,ch)*dx;
const c01 = at(x0,y0,z0+1,ch)*(1-dx) + at(x0+1,y0,z0+1,ch)*dx;
const c11 = at(x0,y0+1,z0+1,ch)*(1-dx) + at(x0+1,y0+1,z0+1,ch)*dx;
res[ch] = (c00*(1-dy) + c10*dy)*(1-dz) + (c01*(1-dy) + c11*dy)*dz;
}
return res;
};
}
function hsv2rgb(h, s, v) {
const i = Math.floor(h * 6), fr = h * 6 - i;
const p = v * (1 - s), q = v * (1 - fr * s), t = v * (1 - (1 - fr) * s);
const m = [[v,t,p],[q,v,p],[p,v,t],[p,q,v],[t,p,v],[v,p,q]][i % 6];
return m;
}
function rgbHex(c) {
return '#' + c.map(v => Math.round(Math.min(Math.max(v,0),1) * 255)
.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function extractPalette(file, done) {
createImageBitmap(file).then(bmp => {
const S = 48;
const cv = document.createElement('canvas');
cv.width = S; cv.height = S;
const cx = cv.getContext('2d');
cx.drawImage(bmp, 0, 0, S, S);
const d = cx.getImageData(0, 0, S, S).data;
const bins = new Map();
for (let i = 0; i < d.length; i += 4) {
const key = ((d[i] >> 4) << 8) | ((d[i+1] >> 4) << 4) | (d[i+2] >> 4);
let b2 = bins.get(key);
if (!b2) { b2 = [0, 0, 0, 0]; bins.set(key, b2); }
b2[0] += d[i]; b2[1] += d[i+1]; b2[2] += d[i+2]; b2[3]++;
}
const sorted = [...bins.values()].sort((a, b2) => b2[3] - a[3]);
const picks = [];
for (const b2 of sorted) {
const c = [b2[0]/b2[3]/255, b2[1]/b2[3]/255, b2[2]/b2[3]/255];
if (picks.every(p =>
Math.hypot(p[0]-c[0], p[1]-c[1], p[2]-c[2]) > 0.16)) picks.push(c);
if (picks.length >= 6) break;
}
done(picks);
}).catch(() => done(null));
}
function makeFlowCard(cubeText, refFileMaybe) {
const { N, out } = parseCube(cubeText);
if (!N) throw new Error('bad cube');
const sample = lutSampler(out, N);
const wrap = document.createElement('div');
wrap.className = 'flow-wrap';
wrap.innerHTML =
'<div class="viz-head"><span class="t">Look recipe — reference → output' +
'<span class="help">?<span class="tip">The model reads the reference’s palette ' +
'(left) and distills a recipe from it. Each ribbon takes one everyday color (top) ' +
'and shows what this look turns it into (bottom) — hover any ribbon to inspect ' +
'the exact values. Together, the ribbons are the DNA of the look.</span></span></span>' +
'<span class="hint">hover a ribbon</span></div>' +
'<div class="flow-grid">' +
'<div class="flow-ref" style="display:none">' +
'<span class="cap">Reference — what the model saw</span>' +
'<img alt="Reference"><div class="pal"></div></div>' +
'<canvas class="flow-canvas"></canvas></div>';
const refBox = wrap.querySelector('.flow-ref');
const grid = wrap.querySelector('.flow-grid');
const canvas = wrap.querySelector('canvas');
// left column: reference thumb + extracted palette (when decodable)
if (refFileMaybe && PREVIEWABLE.includes(refFileMaybe.type)) {
wrap.querySelector('.flow-ref img').src = URL.createObjectURL(refFileMaybe);
extractPalette(refFileMaybe, colors => {
if (!colors || colors.length < 3) return;
const pal = wrap.querySelector('.pal');
colors.forEach(c => {
const s = document.createElement('span');
s.style.background = rgbHex(c);
s.title = rgbHex(c);
pal.appendChild(s);
});
refBox.style.display = 'flex';
});
} else {
grid.style.gridTemplateColumns = '1fr';
}
// ribbon data: 10 hues + 3 grays + skin tone
const inputs = [];
for (let i = 0; i < 10; i++) inputs.push(hsv2rgb(i / 10, 0.62, 0.85));
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]);
const pairs = inputs.map(c => ({ cin: c, cout: sample(c[0], c[1], c[2]) }));
const ctx = canvas.getContext('2d');
let hoverIdx = -1, reveal = 0, raf = null;
function draw() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.clientWidth, h = Math.max(canvas.clientHeight, 250);
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr; canvas.height = h * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const n = pairs.length, mx = 34;
const step = (w - mx * 2) / (n - 1);
const topY = 34, botY = h - 34, sw = 20;
ctx.font = '10px ui-monospace, Menlo, monospace';
ctx.fillStyle = 'rgba(139,139,147,.9)';
ctx.fillText('ANY PHOTO — original colors', mx - 10, 14);
ctx.fillText('AFTER THIS LOOK', mx - 10, h - 8);
pairs.forEach((p, i) => {
const x = mx + step * i;
const prog = Math.min(Math.max(reveal * n * 1.6 - i, 0), 1);
const dim = hoverIdx >= 0 && hoverIdx !== i;
ctx.globalAlpha = (dim ? 0.18 : 0.95) * prog;
// ribbon
const grad = ctx.createLinearGradient(0, topY, 0, botY);
grad.addColorStop(0, rgbHex(p.cin));
grad.addColorStop(1, rgbHex(p.cout));
ctx.strokeStyle = grad;
ctx.lineWidth = hoverIdx === i ? 15 : 11;
ctx.lineCap = 'round';
const sway = Math.sin(i * 1.7) * 10;
ctx.beginPath();
ctx.moveTo(x, topY + sw / 2 + 4);
const endY = topY + sw / 2 + 4 + (botY - topY - sw - 8) * prog;
ctx.bezierCurveTo(x + sway, topY + (botY - topY) * 0.4,
x - sway, topY + (botY - topY) * 0.6,
x, endY);
ctx.stroke();
// swatches
ctx.globalAlpha = dim ? 0.3 : 1;
ctx.fillStyle = rgbHex(p.cin);
rr(ctx, x - sw/2, topY - sw/2, sw, sw, 5); ctx.fill();
ctx.globalAlpha = (dim ? 0.3 : 1) * prog;
ctx.fillStyle = rgbHex(p.cout);
rr(ctx, x - sw/2, botY - sw/2, sw, sw, 5); ctx.fill();
ctx.globalAlpha = 1;
});
// hover readout
if (hoverIdx >= 0) {
const p = pairs[hoverIdx];
const x = mx + step * hoverIdx;
ctx.font = '11px ui-monospace, Menlo, monospace';
ctx.textAlign = x > w - 90 ? 'right' : 'left';
const tx = x > w - 90 ? x - 16 : x + 16;
ctx.fillStyle = 'rgba(236,236,236,.95)';
ctx.fillText(rgbHex(p.cin), tx, topY + 4);
ctx.fillText(rgbHex(p.cout), tx, botY + 4);
ctx.textAlign = 'left';
}
}
function rr(c, x, y, w2, h2, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w2, y, x + w2, y + h2, r);
c.arcTo(x + w2, y + h2, x, y + h2, r);
c.arcTo(x, y + h2, x, y, r);
c.arcTo(x, y, x + w2, y, r);
c.closePath();
}
function animate() {
reveal = Math.min(reveal + 0.02, 1);
draw();
if (reveal < 1) raf = requestAnimationFrame(animate);
}
// start when it enters the viewport
const io = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { io.disconnect(); animate(); }
}, { threshold: 0.25 });
io.observe(canvas);
canvas.addEventListener('pointermove', e => {
const rect = canvas.getBoundingClientRect();
const n = pairs.length, mx = 34;
const step = (rect.width - mx * 2) / (n - 1);
const idx = Math.round((e.clientX - rect.left - mx) / step);
hoverIdx = (idx >= 0 && idx < n) ? idx : -1;
if (reveal >= 1) draw();
});
canvas.addEventListener('pointerleave', () => {
hoverIdx = -1;
if (reveal >= 1) draw();
});
window.addEventListener('resize', () => { if (reveal > 0) draw(); });
return wrap;
}
// ------------------------------------------------ 3D LUT visualization
// Self-contained WebGL point cloud — no libraries, no external requests.
// Shows the LUT lattice morphing from identity (perfect cube) to the
// predicted film transform. Drag to rotate, scroll to zoom.
function parseCube(text) {
let N = 0; const vals = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t[0] === 'T' || t[0] === 'D') continue; // TITLE / DOMAIN_*
if (t[0] === 'L') { N = parseInt(t.split(/\s+/)[1], 10); continue; }
const p = t.split(/\s+/);
if (p.length === 3) vals.push(+p[0], +p[1], +p[2]);
}
return { N, out: new Float32Array(vals) };
}
function makeVizCard(cubeText) {
const wrap = document.createElement('div');
wrap.className = 'viz-wrap';
wrap.innerHTML =
'<div class="viz-head"><span class="t">Color space — 3D LUT' +
'<span class="help">?<span class="tip">A 3D LUT (look-up table) is a color recipe: ' +
'for every color a photo can contain, it stores the color the film would turn it into. ' +
'Each glowing dot below is one color — press play to watch the film’s recipe bend ' +
'the entire color space. The .cube file you download is exactly this transform, ready ' +
'for Photoshop, Lightroom, or DaVinci Resolve.</span></span></span>' +
'<span class="hint">drag to rotate · scroll to zoom</span></div>' +
'<canvas class="viz-canvas"></canvas>' +
'<div class="viz-controls">' +
'<button class="viz-play">❚❚</button>' +
'<span class="vlbl">Original</span>' +
'<input type="range" min="0" max="1" step="0.001" value="0">' +
'<span class="vlbl" style="color:var(--accent)">Film</span></div>';
const canvas = wrap.querySelector('canvas');
const slider = wrap.querySelector('input[type=range]');
const playBtn = wrap.querySelector('.viz-play');
const { N, out } = parseCube(cubeText);
if (!N || out.length !== N * N * N * 3) throw new Error('bad cube');
// identity lattice: red varies fastest (.cube convention)
const n3 = N * N * N;
const idn = new Float32Array(n3 * 3);
for (let i = 0; i < n3; i++) {
idn[i * 3] = (i % N) / (N - 1);
idn[i * 3 + 1] = (Math.floor(i / N) % N) / (N - 1);
idn[i * 3 + 2] = Math.floor(i / (N * N)) / (N - 1);
}
const gl = canvas.getContext('webgl', { antialias: true, alpha: true });
if (!gl) throw new Error('no webgl');
function compile(vs, fs) {
function sh(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(s));
return s;
}
const p = gl.createProgram();
gl.attachShader(p, sh(gl.VERTEX_SHADER, vs));
gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(p));
return p;
}
const ptProg = compile(
'attribute vec3 aId; attribute vec3 aOut;' +
'uniform mat4 uMVP; uniform float uMorph; uniform float uSize;' +
'varying vec3 vC;' +
'void main(){' +
' vec3 p = mix(aId, aOut, uMorph);' +
' vC = p;' +
' gl_Position = uMVP * vec4(p - 0.5, 1.0);' +
' gl_PointSize = uSize / gl_Position.w;' +
'}',
'precision mediump float; varying vec3 vC;' +
'void main(){' +
' vec2 d = gl_PointCoord - 0.5;' +
' float r = length(d);' +
' if (r > 0.5) discard;' +
' float a = smoothstep(0.5, 0.1, r);' +
' gl_FragColor = vec4(vC * (0.35 + 0.65 * a), a * 0.9);' +
'}');
const lnProg = compile(
'attribute vec3 aPos; uniform mat4 uMVP; varying vec3 vC;' +
'void main(){ vC = aPos; gl_Position = uMVP * vec4(aPos - 0.5, 1.0); }',
'precision mediump float; varying vec3 vC;' +
'void main(){ gl_FragColor = vec4(vC * 0.85 + 0.15, 0.22); }');
// buffers
function buf(data) {
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
return b;
}
const bId = buf(idn), bOut = buf(out);
// reference cube wireframe (12 edges)
const E = [];
const c = [[0,0,0],[1,0,0],[0,1,0],[0,0,1],[1,1,0],[1,0,1],[0,1,1],[1,1,1]];
[[0,1],[0,2],[0,3],[1,4],[1,5],[2,4],[2,6],[3,5],[3,6],[4,7],[5,7],[6,7]]
.forEach(([a,b2]) => { E.push(...c[a], ...c[b2]); });
const bEdge = buf(new Float32Array(E));
// mat4 helpers (column-major)
function mul(a, b) {
const o = new Float32Array(16);
for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k * 4 + j] * b[i * 4 + k];
o[i * 4 + j] = s;
}
return o;
}
function persp(fov, asp, n2, f) {
const t = 1 / Math.tan(fov / 2);
return new Float32Array([t / asp,0,0,0, 0,t,0,0, 0,0,(f+n2)/(n2-f),-1, 0,0,2*f*n2/(n2-f),0]);
}
function rotY(a) { const s=Math.sin(a),co=Math.cos(a);
return new Float32Array([co,0,-s,0, 0,1,0,0, s,0,co,0, 0,0,0,1]); }
function rotX(a) { const s=Math.sin(a),co=Math.cos(a);
return new Float32Array([1,0,0,0, 0,co,s,0, 0,-s,co,0, 0,0,0,1]); }
function trans(z) {
return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,z,1]); }
// state
let yaw = 0.7, pitch = 0.45, dist = 2.6;
let dragging = false, lastX = 0, lastY = 0, lastInteract = 0;
let morph = 0, playing = true, phase = 0;
canvas.addEventListener('pointerdown', e => {
dragging = true; lastX = e.clientX; lastY = e.clientY;
lastInteract = performance.now();
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
if (!dragging) return;
yaw += (e.clientX - lastX) * 0.008;
pitch = Math.max(-1.45, Math.min(1.45, pitch + (e.clientY - lastY) * 0.008));
lastX = e.clientX; lastY = e.clientY;
lastInteract = performance.now();
});
canvas.addEventListener('pointerup', () => { dragging = false; });
canvas.addEventListener('wheel', e => {
e.preventDefault();
dist = Math.max(1.5, Math.min(4.5, dist + e.deltaY * 0.002));
lastInteract = performance.now();
}, { passive: false });
slider.addEventListener('input', () => {
playing = false; playBtn.textContent = '▶';
morph = +slider.value;
});
playBtn.addEventListener('click', () => {
playing = !playing;
playBtn.textContent = playing ? '❚❚' : '▶';
});
const aId = gl.getAttribLocation(ptProg, 'aId');
const aOut = gl.getAttribLocation(ptProg, 'aOut');
const aPos = gl.getAttribLocation(lnProg, 'aPos');
let alive = true;
const obs = new MutationObserver(() => {
if (!document.body.contains(canvas)) { alive = false; obs.disconnect(); }
});
obs.observe(document.body, { childList: true, subtree: true });
function frame(t) {
if (!alive) return;
// resize
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.clientWidth, h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr; canvas.height = h * dpr;
}
gl.viewport(0, 0, canvas.width, canvas.height);
// auto-rotate when idle
if (!dragging && t - lastInteract > 2500) yaw += 0.0035;
// morph ping-pong with holds at each end
if (playing) {
phase = (phase + 1 / 360) % 2; // 6 s full cycle
const p = phase < 1 ? phase : 2 - phase; // 0→1→0
const q = Math.max(0, Math.min(1, (p - 0.15) / 0.7));
morph = q * q * (3 - 2 * q); // smoothstep + end holds
slider.value = morph;
}
const mvp = mul(persp(0.72, w / h, 0.1, 20),
mul(trans(-dist), mul(rotX(pitch), rotY(yaw))));
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // additive glow
// wireframe
gl.useProgram(lnProg);
gl.uniformMatrix4fv(gl.getUniformLocation(lnProg, 'uMVP'), false, mvp);
gl.bindBuffer(gl.ARRAY_BUFFER, bEdge);
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.LINES, 0, 24);
// points
gl.useProgram(ptProg);
gl.uniformMatrix4fv(gl.getUniformLocation(ptProg, 'uMVP'), false, mvp);
gl.uniform1f(gl.getUniformLocation(ptProg, 'uMorph'), morph);
gl.uniform1f(gl.getUniformLocation(ptProg, 'uSize'), 9.0 * dpr);
gl.bindBuffer(gl.ARRAY_BUFFER, bId);
gl.enableVertexAttribArray(aId);
gl.vertexAttribPointer(aId, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, bOut);
gl.enableVertexAttribArray(aOut);
gl.vertexAttribPointer(aOut, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, n3);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
return wrap;
}
// ------------------------------------------------ demo section (Happy Together walkthrough)
(function initDemo() {
const paths = {
ref: 'static/demo/ref.jpg',
target: 'static/demo/target.jpg',
result: 'static/demo/result.jpg',
};
$('demoRefImg').src = paths.ref;
$('demoInputImg').src = paths.target;
$('demoTargetImg').src = paths.target;
$('demoResultImg').src = paths.result;
initCompare($('demoCompare'));
$('tryDemoBtn').addEventListener('click', async () => {
try {
$('tryDemoBtn').textContent = 'Loading…';
const [rb, tb] = await Promise.all([
fetch(paths.ref).then(r => r.blob()),
fetch(paths.target).then(r => r.blob()),
]);
refFile = new File([rb], 'demo-reference.jpg', { type: 'image/jpeg' });
tgtFile = new File([tb], 'demo-photo.jpg', { type: 'image/jpeg' });
fillZone('refZone', refFile, () => { refFile = null; clearZone('refZone'); update(); });
fillZone('tgtZone', tgtFile, () => { tgtFile = null; clearZone('tgtZone'); update(); });
update();
window.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
$('tryDemoBtn').textContent = 'Try this example yourself →';
}
});
})();
// ------------------------------------------------ demo playground
// Reference (movie stills OR film stocks) × input photo picker.
(function initPlayground() {
const FILMS = [
{ id: 'vision3', name: 'Kodak Vision3 250D 5207', short: '250D',
label: '#E8B23A', text: '#181205',
url: 'https://www.kodak.com/en/motion/product/camera-films/250d-5207-7207/' },
{ id: 'gold400', name: 'Kodak Gold 400', short: 'GOLD 400',
label: '#F2C230', text: '#181205',
url: 'https://en.wikipedia.org/wiki/Kodak_Gold' },
{ id: 'fuji400', name: 'Fujifilm 400', short: 'FUJI 400',
label: '#1A9E60', text: '#04160c',
url: 'https://www.fujifilm.com/us/en/consumer/films/consumer-film/fujifilm-400' },
{ id: 'edo400', name: 'Escura Edo Ukiyo-e 400', short: 'EDO 400',
label: '#2C3D5C', text: '#dfe6f2',
url: 'https://www.pandacamera.com/products/escura-%E6%B1%9F%E6%88%B6-%E6%B5%AE%E4%B8%96%E7%B9%AA-edo-ukiyo-e-400-iso-24exp-135-35mm-color-negative-film' },
{ id: 'showa', name: 'Escura Showa 400', short: 'SHOWA',
label: '#A8503A', text: '#f7e8e2',
url: 'https://www.pandacamera.com/products/escura-showa-400-iso-30exp-135-35mm-color-negative-film' },
];
const MOVIES = [
{ id: 'hap', file: 'static/demo/ref.jpg', title: 'Happy Together', meta: '1997 · Wong Kar-wai' },
{ id: 'm1', file: 'static/movies/mov_01.jpg', title: 'In the Mood for Love', meta: '2000 · Wong Kar-wai' },
{ id: 'm2', file: 'static/movies/mov_02.jpg', title: 'Days of Being Wild', meta: '1990 · Wong Kar-wai' },
{ id: 'm3', file: 'static/movies/mov_03.jpg', title: 'Close-Up', meta: '1990 · Abbas Kiarostami' },
{ id: 'm4', file: 'static/movies/mov_04.jpg', title: 'The Grand Budapest Hotel', meta: '2014 · Wes Anderson' },
{ id: 'm5', file: 'static/movies/mov_05.jpg', title: 'Barry Lyndon', meta: '1975 · Stanley Kubrick' },
{ id: 'm6', file: 'static/movies/mov_06.jpg', title: 'The Good, the Bad and the Ugly', meta: '1966 · Sergio Leone' },
{ id: 'm7', file: 'static/movies/mov_07.jpg', title: 'A Touch of Zen', meta: '1971 · King Hu' },
{ id: 'm8', file: 'static/movies/mov_08.jpg', title: 'Kagemusha', meta: '1980 · Akira Kurosawa' },
];
const INPUTS = [
['01', 'Tokyo alley', true], ['02', 'Bicycle corridor', true],
['03', 'Asakusa & Skytree', true], ['04', 'Rainy night, NYC', true],
['05', 'Window still life', true], ['06', 'Portrait at the bar', true],
['07', 'Pond reflection', true],
['t1', 'Hilltop monastery, Tibet', false],
['t4', 'Hanging garden', false],
['t6', 'Cypress swamp', false],
].map(a => ({
id: a[0], name: a[1], lab: a[2],
src: a[2] ? 'static/lab/input_' + a[0] + '.jpg'
: 'static/lab/tar_' + a[0].slice(1) + '.jpg',
}));
let refType = 'movie', movie = MOVIES[0], film = FILMS[0], input = INPUTS[0];
function canisterSvg(f) {
return '<svg class="canister" width="34" height="46" viewBox="0 0 34 46" aria-hidden="true">' +
'<rect x="11" y="1" width="12" height="5" rx="1.5" fill="#4a4a52"/>' +
'<rect x="8" y="5" width="18" height="3" rx="1" fill="#333338"/>' +
'<rect x="3" y="8" width="28" height="36" rx="4" fill="#232328"/>' +
'<rect x="3" y="14" width="28" height="22" fill="' + f.label + '"/>' +
'<text x="17" y="27" text-anchor="middle" font-size="6.5" font-weight="700" ' +
'font-family="ui-monospace,Menlo,monospace" fill="' + f.text + '">' + f.short + '</text>' +
'<text x="17" y="33.5" text-anchor="middle" font-size="4.6" ' +
'font-family="ui-monospace,Menlo,monospace" fill="' + f.text + '" opacity="0.75">35mm · C-41</text>' +
'<rect x="3" y="40" width="28" height="4" rx="2" fill="#333338"/></svg>';
}
function refSrc() { return refType === 'movie' ? movie.file : 'static/lab/ref_' + film.id + '.jpg'; }
function refName() { return refType === 'movie' ? movie.title : film.name; }
function refId() { return refType === 'movie' ? movie.id : film.id; }
function refresh() {
document.querySelectorAll('#movieList .look-card').forEach((el, i) =>
el.classList.toggle('sel', refType === 'movie' && MOVIES[i] === movie));
document.querySelectorAll('#filmList .look-card').forEach((el, i) =>
el.classList.toggle('sel', refType === 'film' && FILMS[i] === film));
document.querySelectorAll('#inputList .in-card').forEach((el, i) =>
el.classList.toggle('sel', INPUTS[i] === input));
$('pairRef').src = refSrc();
$('pairInput').src = input.src;
$('pairInfo').innerHTML = '<b>' + refName() + '</b> &nbsp;→&nbsp; <b>' + input.name + '</b>';
// per-reference look downloads (pre-generated on the server)
const slug = refName().replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '');
$('lookDlName').textContent = refName();
[['lkCube', 'cube'], ['lkXmp', 'xmp'], ['lkCo', 'costyle'], ['lkJson', 'json']]
.forEach(([id, ext]) => {
const a = $(id);
a.href = 'static/looks/' + refId() + '.' + ext;
a.download = 'DeepAnalog-' + slug + '.' + ext;
});
// every pair has a pre-render; onerror falls back to the live hint
$('instantWrap').style.display = 'block';
$('liveHint').style.display = 'none';
const cmp = $('labCompare');
const base = cmp.querySelector('.base-img');
base.style.opacity = '.25'; // crossfade while the new render loads
base.src = 'static/lab/' + input.id + '_' + refId() + '.jpg';
cmp.querySelector('.top-img').src = input.src;
}
function lookCard(imgSrc, name, meta) {
const card = document.createElement('div');
card.className = 'look-card';
card.innerHTML =
'<img class="bg" loading="lazy" alt="">' +
'<span class="sprk l" aria-hidden="true"></span>' +
'<span class="sprk r" aria-hidden="true"></span>' +
'<div class="ov"><div class="pname"></div><div class="pmeta"></div></div>';
card.querySelector('.bg').src = imgSrc;
card.querySelector('.bg').alt = name;
card.querySelector('.pname').textContent = name;
card.querySelector('.pmeta').textContent = meta;
return card;
}
MOVIES.forEach(m => {
const card = lookCard(m.file, m.title, m.meta);
card.addEventListener('click', () => { refType = 'movie'; movie = m; refresh(); });
$('movieList').appendChild(card);
});
FILMS.forEach(f => {
const card = lookCard('static/lab/ref_' + f.id + '.jpg', f.name, '35mm · C-41');
card.classList.add('film');
// canister icon sits left of the caption text; clicking it opens
// the full introduction of that film stock
const a = document.createElement('a');
a.className = 'can-btn2';
a.href = f.url;
a.target = '_blank'; a.rel = 'noopener';
a.title = 'About ' + f.name + ' ↗';
a.innerHTML = canisterSvg(f);
a.querySelector('.canister').setAttribute('width', '22');
a.querySelector('.canister').setAttribute('height', '30');
a.addEventListener('click', e => e.stopPropagation());
const ov = card.querySelector('.ov');
const txt = document.createElement('div');
txt.appendChild(ov.querySelector('.pname'));
txt.appendChild(ov.querySelector('.pmeta'));
ov.textContent = '';
ov.appendChild(a);
ov.appendChild(txt);
card.addEventListener('click', () => { refType = 'film'; film = f; refresh(); });
$('filmList').appendChild(card);
});
// reference tabs: sliding indicator + pane transition
function setRefTab(t) {
$('rtabMovies').classList.toggle('sel', t === 'movie');
$('rtabFilms').classList.toggle('sel', t === 'film');
$('tabInd').style.transform = t === 'movie' ? 'translateX(0)' : 'translateX(100%)';
const showPane = t === 'movie' ? 'moviePane' : 'filmPane';
const hidePane = t === 'movie' ? 'filmPane' : 'moviePane';
$(hidePane).style.display = 'none';
const p = $(showPane);
p.style.display = 'block';
p.classList.remove('enter');
void p.offsetWidth;
p.classList.add('enter');
refType = t;
refresh();
}
$('rtabMovies').addEventListener('click', () => setRefTab('movie'));
$('rtabFilms').addEventListener('click', () => setRefTab('film'));
INPUTS.forEach(inp => {
const card = document.createElement('div');
card.className = 'in-card';
card.innerHTML = '<img loading="lazy" alt="">';
card.querySelector('img').src = inp.src;
card.querySelector('img').alt = inp.name;
card.title = inp.name;
card.addEventListener('click', () => { input = inp; refresh(); });
$('inputList').appendChild(card);
});
// if a pre-render is ever missing, degrade gracefully to the live hint
const baseImg = $('labCompare').querySelector('.base-img');
baseImg.addEventListener('error', () => {
$('instantWrap').style.display = 'none';
$('liveHint').style.display = 'block';
});
baseImg.addEventListener('load', () => { baseImg.style.opacity = '1'; });
initCompare($('labCompare'));
setRefTab('movie');
$('runPairBtn').addEventListener('click', async () => {
try {
$('runPairBtn').textContent = 'Loading…';
const [rb, tb] = await Promise.all([
fetch(refSrc()).then(r => r.blob()),
fetch(input.src).then(r => r.blob()),
]);
refFile = new File([rb], 'reference.jpg', { type: 'image/jpeg' });
tgtFile = new File([tb], 'photo.jpg', { type: 'image/jpeg' });
fillZone('refZone', refFile, () => { refFile = null; clearZone('refZone'); update(); });
fillZone('tgtZone', tgtFile, () => { tgtFile = null; clearZone('tgtZone'); update(); });
update();
window.scrollTo({ top: 0, behavior: 'smooth' });
} finally {
$('runPairBtn').textContent = 'Run this pair in the app →';
}
});
})();
// ------------------------------------------------ projectionist mode
// click the wordmark 5 times: one second of sputtering projector
(function () {
const brand = document.querySelector('.brand');
let clicks = 0, last = 0;
brand.addEventListener('click', () => {
const now = Date.now();
if (now - last > 2500) clicks = 0;
last = now;
if (++clicks >= 5) {
clicks = 0;
document.body.classList.add('projector');
console.log('Projectionist mode: acknowledged.');
setTimeout(() => document.body.classList.remove('projector'), 1300);
}
});
})();
// ------------------------------------------------ ambience
// Scroll-reveal for below-the-fold sections
document.querySelectorAll('section.demo, .filmstrip, footer').forEach(el =>
el.classList.add('reveal'));
const ro = new IntersectionObserver(entries => entries.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('visible'); ro.unobserve(e.target); }
}), { threshold: 0.07 });
document.querySelectorAll('.reveal').forEach(el => ro.observe(el));
// Cursor halation glow (desktop pointers only)
if (window.matchMedia('(pointer:fine)').matches) {
const glow = document.createElement('div');
glow.id = 'cursorGlow';
glow.style.opacity = '0';
document.body.appendChild(glow);
let gx = 0, gy = 0, pending = false;
window.addEventListener('pointermove', e => {
gx = e.clientX; gy = e.clientY;
glow.style.opacity = '1';
if (!pending) {
pending = true;
requestAnimationFrame(() => {
glow.style.left = gx + 'px';
glow.style.top = gy + 'px';
pending = false;
});
}
}, { passive: true });
}
// ------------------------------------------------ privacy modal
['privacyOpen', 'privacyOpen2', 'privacyOpen2Top'].forEach(id => {
const el = $(id);
if (el) el.addEventListener('click', () => $('privacyModal').classList.add('open'));
});
$('privacyClose').addEventListener('click', () => $('privacyModal').classList.remove('open'));
$('privacyModal').addEventListener('click', e => {
if (e.target === $('privacyModal')) $('privacyModal').classList.remove('open');
});
})();
</script>
</body>
</html>