Spaces:
Running
Running
| <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 ; transition: none ; } | |
| .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 LUT that captures its color character, | |
| ready for Photoshop, Lightroom, DaVinci 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> 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">○ 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> 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> 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> 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> 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">🎬 Movie looks</button> | |
| <button class="rtab" id="rtabFilms">🎞 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> 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> 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 & 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> → <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> | |