commit fccaac6ed31bc0600fa012565c1de5aadffee548 Author: Yuriy Panov Date: Tue Aug 25 14:09:16 2026 +0600 initial diff --git a/app.js b/app.js new file mode 100644 index 0000000..1f40321 --- /dev/null +++ b/app.js @@ -0,0 +1,97 @@ +/* ============================================================ + hipa — the family archive + + The sign-in form is a placeholder. It has no backend: nothing is + validated, stored, logged, or sent anywhere. Every submit ends in the + same message. Wire it to a real endpoint before treating it as a login. + ============================================================ */ + +(function () { + 'use strict'; + + var root = document.documentElement; + root.className += ' js'; + + /* -- develop sequence ------------------------------------------------- + Prints come up first, staggered along the sheet. Each date stamp + flicks on after its own print has settled. */ + + var STAGGER = 90; // ms between prints + var LEAD = 260; // ms before the sheet starts + var SETTLE = 620; // ms a print takes to settle before its stamp lights + + document.querySelectorAll('.frame').forEach(function (frame, i) { + var at = LEAD + i * STAGGER; + frame.querySelector('.frame-print').style.animationDelay = at + 'ms'; + frame.querySelectorAll('.frame-stamp, .frame-run').forEach(function (el) { + el.style.animationDelay = (at + SETTLE) + 'ms'; + }); + }); + + [['.eyebrow', 0], ['.lede-title', 60], ['.lede-sub', 160], + ['.gate', 220], ['.ticker', 320]].forEach(function (pair) { + var el = document.querySelector(pair[0]); + if (el) el.style.animationDelay = pair[1] + 'ms'; + }); + + /* -- show / hide the passphrase --------------------------------------- */ + + var pass = document.getElementById('pass'); + var reveal = document.querySelector('[data-reveal]'); + + reveal.addEventListener('click', function () { + var shown = pass.type === 'text'; + pass.type = shown ? 'password' : 'text'; + reveal.textContent = shown ? 'Show' : 'Hide'; + reveal.setAttribute('aria-pressed', String(!shown)); + pass.focus(); + }); + + /* -- the gate --------------------------------------------------------- */ + + var form = document.getElementById('signin'); + var gate = document.getElementById('gate'); + var notice = document.getElementById('notice'); + var unlock = form.querySelector('.unlock'); + var email = document.getElementById('email'); + + function refuse(message) { + notice.textContent = message; + gate.classList.remove('is-refused'); + void gate.offsetWidth; // restart the shake + gate.classList.add('is-refused'); + } + + form.addEventListener('submit', function (e) { + e.preventDefault(); + + if (!email.value.trim()) { + refuse('Enter the email address the invite was sent to.'); + email.focus(); + return; + } + if (!pass.value) { + refuse('Enter your passphrase to open the archive.'); + pass.focus(); + return; + } + + unlock.classList.remove('is-firing'); + void unlock.offsetWidth; + unlock.classList.add('is-firing'); + notice.textContent = ''; + + window.setTimeout(function () { + refuse('That passphrase does not match this archive. Check the email address too.'); + pass.value = ''; + }, 520); + }); + + unlock.addEventListener('animationend', function () { + unlock.classList.remove('is-firing'); + }); + + document.querySelector('[data-passkey]').addEventListener('click', function () { + refuse('No passkey is registered on this device. Sign in with your passphrase, then add one from Settings.'); + }); +})(); diff --git a/img/01-first-roll.jpg b/img/01-first-roll.jpg new file mode 100644 index 0000000..c88e781 Binary files /dev/null and b/img/01-first-roll.jpg differ diff --git a/img/02-grandads-8mm.jpg b/img/02-grandads-8mm.jpg new file mode 100644 index 0000000..0b97c6a Binary files /dev/null and b/img/02-grandads-8mm.jpg differ diff --git a/img/03-issyk.jpg b/img/03-issyk.jpg new file mode 100644 index 0000000..50b8097 Binary files /dev/null and b/img/03-issyk.jpg differ diff --git a/img/04-long-table.jpg b/img/04-long-table.jpg new file mode 100644 index 0000000..d7cc8ff Binary files /dev/null and b/img/04-long-table.jpg differ diff --git a/img/05-marta-and-ilya.jpg b/img/05-marta-and-ilya.jpg new file mode 100644 index 0000000..f6651cc Binary files /dev/null and b/img/05-marta-and-ilya.jpg differ diff --git a/img/06-school-concert.jpg b/img/06-school-concert.jpg new file mode 100644 index 0000000..b9c270c Binary files /dev/null and b/img/06-school-concert.jpg differ diff --git a/img/07-nine-candles.jpg b/img/07-nine-candles.jpg new file mode 100644 index 0000000..b2e446d Binary files /dev/null and b/img/07-nine-candles.jpg differ diff --git a/img/08-chimbulak.jpg b/img/08-chimbulak.jpg new file mode 100644 index 0000000..8ce32a5 Binary files /dev/null and b/img/08-chimbulak.jpg differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..e92fc86 --- /dev/null +++ b/index.html @@ -0,0 +1,178 @@ + + + + + +hipa — the family archive + + + + + + + + + + + + +
+ + + hipa + +
+ +
+ +
+

Rolls 001–612 · kept since 1974

+

Every afternoon
we bothered
to photograph.

+

+ Fifty-one years of family photographs and home films, scanned off the + shelf and stored in one place. Sign in to open it. +

+ +
+ +
+
+ Sheets on a washing line in hard afternoon sun + '74 08 +
+
FR 03 The first roll
+
+ +
+
+ The front room window, nets drawn, a plant on the sill + '81 06 + 04:17 +
+
REEL 11 Grandad's 8mm
+
+ +
+
+ The lake at Issyk, footprints leading down the wet sand + '89 07 +
+
FR 21 Issyk, every July
+
+ +
+
+ A long table laid for a meal, lit by one window + '96 12 +
+
FR 08 The long table
+
+ +
+
+ White flowers on a table + '98 09 +
+
FR 02 Marta and Ilya
+
+ +
+
+ A lit school stage seen from the back of a dark hall + '03 05 + 21:52 +
+
TAPE 04 School concert
+
+ +
+
+ Nine lit candles on a cake in a dark room + '11 03 + 02:38 +
+
CLIP 88 Nine candles
+
+ +
+
+ A ski slope with the chairlift running above it + '19 01 +
+
FR 14 Chimbulak, cold
+
+ +
+ +
+
Photographs
12,480
+
Home films
610
+
Earliest
Aug 1974
+
Last backup
18 Aug 2026
+
+
+ +
+ + + + + +
+

Household sign in

+

Open the archive

+ +
+
+ + +
+ +
+ + + +
+ +

+ + +
+ +
+ +
+ +

+ No sign in yet? Ask whoever keeps the archive to send you an invite. +
Trouble getting in — archive@hipa.troptor.com +

+
+
+ +
+ + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..959b01c --- /dev/null +++ b/styles.css @@ -0,0 +1,578 @@ +/* ============================================================ + hipa — the family archive + Ground: album card stock. Surfaces: glassine interleaf. + Warm amber belongs to the archive. Cool cyan belongs to the interface. + ============================================================ */ + +:root { + --board: #16201f; + --board-deep: #0d1413; + --board-edge: #24312f; + --glassine: #ede7dc; + --stamp: #f0a02a; + --sleeve: #7e8f86; + --flash: #9fd8e3; + + --ink: #16201f; /* text on paper */ + --ink-soft: #56635f; + + --display: "Fraunces", "Iowan Old Style", Georgia, serif; + --body: "Instrument Sans", "Helvetica Neue", Arial, sans-serif; + --mono: "Azeret Mono", ui-monospace, "SF Mono", Menlo, monospace; + + --gutter: clamp(1.25rem, 4vw, 4.5rem); + + /* Grain: a single emulsion overlay reused across surfaces. */ + --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23g)' opacity='0.42'/%3E%3C/svg%3E"); +} + +*, *::before, *::after { box-sizing: border-box; } + +/* The contact sheet is wider than the viewport by design. `clip` rather + than `hidden` so focusing the card can't scroll the page sideways. */ +html { + -webkit-text-size-adjust: 100%; + overflow-x: hidden; + overflow-x: clip; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--board); + color: var(--glassine); + font-family: var(--body); + font-size: 16px; + line-height: 1.55; + overflow-x: hidden; + overflow-x: clip; + -webkit-font-smoothing: antialiased; +} + +/* Board texture: a soft pool of light top-left, grain over everything. */ +body::before, +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; +} +body::before { + background: + radial-gradient(120% 90% at 12% -10%, #223230 0%, transparent 60%), + radial-gradient(90% 70% at 100% 110%, #1b2725 0%, transparent 55%); + z-index: 0; +} +body::after { + background-image: var(--grain); + opacity: .05; + mix-blend-mode: overlay; + z-index: 999; +} + +.skip { + position: absolute; + left: -9999px; + top: 0; + z-index: 1000; + background: var(--glassine); + color: var(--ink); + padding: .6rem 1rem; + font: 500 .8rem/1 var(--mono); +} +.skip:focus { left: .75rem; top: .75rem; } + +/* ---------------- top bar ---------------- */ + +.topbar { + position: relative; + z-index: 2; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: clamp(1.1rem, 2.4vw, 1.9rem) var(--gutter); +} + +.mark { + display: inline-flex; + align-items: center; + gap: .55rem; + color: var(--glassine); + text-decoration: none; +} +.mark-glyph { width: 20px; height: 20px; color: var(--stamp); flex: none; } +.mark-word { + font-family: var(--display); + font-variation-settings: "SOFT" 60, "WONK" 1, "opsz" 40; + font-weight: 600; + font-size: 1.32rem; + letter-spacing: -.015em; +} +/* ---------------- stage ---------------- */ + +.stage { + position: relative; + z-index: 2; + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 25.5rem); + align-items: center; + gap: clamp(2rem, 5vw, 5.5rem); + padding: clamp(1rem, 3vw, 2.5rem) var(--gutter) clamp(2rem, 4vw, 3rem); +} + +/* ---------------- lede ---------------- */ + +.lede { position: relative; z-index: 1; } + +.eyebrow { + margin: 0 0 clamp(1.1rem, 2.4vw, 1.7rem); + font: 500 .7rem/1 var(--mono); + letter-spacing: .16em; + text-transform: uppercase; + color: var(--stamp); +} + +.lede-title { + margin: 0; + font-family: var(--display); + font-variation-settings: "SOFT" 70, "WONK" 1, "opsz" 110; + font-weight: 400; + font-size: clamp(2.7rem, 6.6vw, 5.6rem); + line-height: .96; + letter-spacing: -.028em; + text-wrap: balance; +} + +.lede-sub { + margin: clamp(1.1rem, 2.4vw, 1.6rem) 0 0; + max-width: 34ch; + font-size: clamp(.98rem, 1.15vw, 1.08rem); + color: #b9c2bd; +} + +/* ---------------- contact sheet ---------------- */ +/* The real sheet runs longer than the page. It breaks out of its column + and recedes out of the light pool rather than stopping — so the crop + holds at any viewport height instead of depending on the card's edge + landing in the right place. */ + +.sheet { + display: flex; + align-items: flex-end; + gap: clamp(.6rem, 1.2vw, 1.1rem); + width: 100vw; + margin: clamp(2.2rem, 4.5vw, 3.4rem) 0 0; + padding: 1.5rem 0; + overflow: visible; + scrollbar-width: none; + -webkit-mask-image: linear-gradient(to right, #000 50%, rgba(0,0,0,.3) 76%, transparent 93%); + mask-image: linear-gradient(to right, #000 50%, rgba(0,0,0,.3) 76%, transparent 93%); +} +.sheet::-webkit-scrollbar { display: none; } + +.frame { + flex: none; + width: clamp(8.5rem, 12.5vw, 10.5rem); + margin: 0; + transform: rotate(var(--tilt)) translateY(calc(var(--lift) * -1)); + transition: transform .45s cubic-bezier(.2,.8,.25,1), filter .45s ease; +} +.frame:hover, +.frame:focus-within { + transform: rotate(0deg) translateY(calc((var(--lift) + 8px) * -1)); +} + +/* White print border — the scalloped Kodak edge, squared off. */ +.frame-print { + position: relative; + padding: 6px 6px 6px; + background: linear-gradient(#f6f2e9, #e4ddd0); + box-shadow: + 0 1px 0 rgba(255,255,255,.16) inset, + 0 14px 26px -12px rgba(0,0,0,.72), + 0 2px 5px rgba(0,0,0,.45); +} +/* Intrinsic width/height on the tag carry the aspect ratio, so the prints + hold their shape before the JPEGs land. 35mm frames are 3:2, the film + gate is 4:3 — the sheet aligns them along the bottom and lets the tops + run ragged, the way a hand-laid sheet does. */ +.frame-img { + display: block; + width: 100%; + height: auto; + background-color: var(--board-deep); +} + +/* Burned-in date stamp. Bleeds amber light into the emulsion under it. */ +.frame-stamp { + position: absolute; + right: 9px; + bottom: 9px; + z-index: 2; + font: 700 .58rem/1 var(--mono); + letter-spacing: .04em; + color: #ffca6a; + text-shadow: + 0 0 2px rgba(240,160,42,.95), + 0 0 9px rgba(240,160,42,.6), + 0 0 20px rgba(240,160,42,.28); +} + +/* Film frames: sprocket rails and a running time. */ +.frame--film .frame-print { + padding: 12px 6px; + background: + linear-gradient(#1c2523, #141c1b); +} +.frame--film .frame-print::before, +.frame--film .frame-print::after { + content: ""; + position: absolute; + left: 5px; + right: 5px; + height: 6px; + background-image: radial-gradient(circle at 3px 3px, #f6f2e9 0 1.6px, transparent 1.9px); + background-size: 11px 6px; + opacity: .5; +} +.frame--film .frame-print::before { top: 3px; } +.frame--film .frame-print::after { bottom: 3px; } + +.frame-run { + position: absolute; + left: 9px; + bottom: 15px; + z-index: 2; + font: 500 .55rem/1 var(--mono); + letter-spacing: .06em; + color: var(--glassine); + background: rgba(13,20,19,.72); + padding: 2px 4px; +} + +.frame figcaption { + margin-top: .55rem; + font: 400 .64rem/1.4 var(--mono); + color: var(--sleeve); + letter-spacing: .01em; +} +.frame-no { + display: block; + color: #9caaa4; + letter-spacing: .1em; +} + +/* ---------------- ticker ---------------- */ + +.ticker { + display: flex; + flex-wrap: wrap; + gap: 1.1rem 0; /* row gap matters the moment cells wrap */ + margin: clamp(1.6rem, 3vw, 2.4rem) 0 0; + border-top: 1px solid var(--board-edge); + padding-top: .95rem; +} +.ticker-cell { + padding-right: clamp(1.4rem, 3vw, 2.6rem); +} +.ticker dt { + font: 400 .62rem/1 var(--mono); + letter-spacing: .13em; + text-transform: uppercase; + color: var(--sleeve); +} +.ticker dd { + margin: .34rem 0 0; + font: 500 .95rem/1 var(--mono); + color: var(--glassine); +} + +/* ---------------- the gate ---------------- */ +/* A sheet of glassine held to the board by four photo corners. */ + +.gate { + position: relative; + z-index: 3; + /* Sits high enough that its lower edge crosses the prints mid-frame — + the sheet has to disappear under it, not almost clear it. */ + align-self: start; + margin-top: clamp(.5rem, 3vh, 2.75rem); + background: + linear-gradient(148deg, #f3efe6 0%, var(--glassine) 46%, #ddd5c6 100%); + color: var(--ink); + padding: clamp(1.6rem, 2.4vw, 2.3rem); + box-shadow: + 0 40px 70px -34px rgba(0,0,0,.8), + 0 4px 14px rgba(0,0,0,.35); +} +.gate::after { + content: ""; + position: absolute; + inset: 0; + background-image: var(--grain); + opacity: .1; + mix-blend-mode: multiply; + pointer-events: none; +} +.gate-inner { position: relative; z-index: 1; } + +.corner { + position: absolute; + width: 26px; + height: 26px; + z-index: 2; + background: linear-gradient(135deg, #3c4a47, #1a2423 70%); + box-shadow: 0 1px 3px rgba(0,0,0,.5); +} +.corner--tl { top: 0; left: 0; clip-path: polygon(0 0, 100% 0, 0 100%); } +.corner--tr { top: 0; right: 0; clip-path: polygon(0 0, 100% 0, 100% 100%); } +.corner--bl { bottom: 0; left: 0; clip-path: polygon(0 0, 0 100%, 100% 100%); } +.corner--br { bottom: 0; right: 0; clip-path: polygon(100% 0, 100% 100%, 0 100%); } + +.gate-eyebrow { + margin: 0; + font: 500 .66rem/1 var(--mono); + letter-spacing: .15em; + text-transform: uppercase; + color: #8a6a2b; +} +.gate-title { + margin: .6rem 0 1.5rem; + font-family: var(--display); + font-variation-settings: "SOFT" 70, "WONK" 1, "opsz" 60; + font-weight: 500; + font-size: clamp(1.6rem, 2.3vw, 1.95rem); + line-height: 1.05; + letter-spacing: -.02em; +} + +.form { display: grid; gap: 1.15rem; } + +.field { position: relative; } +.field label { + display: block; + font: 500 .68rem/1 var(--mono); + letter-spacing: .11em; + text-transform: uppercase; + color: var(--ink-soft); + margin-bottom: .5rem; +} +.field input { + width: 100%; + border: 0; + border-bottom: 1.5px solid rgba(22,32,31,.28); + background: transparent; + padding: .5rem 4.4rem .5rem 0; + font: 400 1rem/1.4 var(--body); + color: var(--ink); + border-radius: 0; + transition: border-color .2s ease, box-shadow .2s ease; +} +.field input::placeholder { color: rgba(22,32,31,.3); } +.field input:hover { border-bottom-color: rgba(22,32,31,.5); } +.field input:focus { + outline: 0; + border-bottom-color: #1c6b7d; + box-shadow: 0 2px 0 -0.5px #1c6b7d; +} + +.reveal { + position: absolute; + right: 0; + bottom: .45rem; + border: 0; + background: none; + padding: .2rem .1rem; + font: 500 .66rem/1 var(--mono); + letter-spacing: .1em; + text-transform: uppercase; + color: var(--ink-soft); + cursor: pointer; +} +.reveal:hover { color: var(--ink); } + +/* Stays in the flow whether or not it has anything to say, so the live + region is never display:none when it updates. */ +.notice { + margin: 0; + font: 400 .82rem/1.45 var(--body); + color: #8f3a1e; +} + +.unlock { + position: relative; + overflow: hidden; + border: 0; + border-radius: 0; + background: var(--stamp); + color: var(--board); + padding: .95rem 1.2rem; + font: 600 .74rem/1 var(--mono); + letter-spacing: .17em; + text-transform: uppercase; + cursor: pointer; + transition: background .2s ease, transform .12s ease; +} +.unlock:hover { background: #ffb44a; } +.unlock:active { transform: translateY(1px); } +.unlock-label { position: relative; z-index: 2; } + +/* Shutter blade — sweeps across on submit. */ +.unlock-blade { + position: absolute; + inset: 0; + z-index: 1; + background: var(--board); + transform: translateX(-101%); +} +.unlock.is-firing .unlock-blade { animation: blade .5s cubic-bezier(.5,0,.4,1); } +@keyframes blade { + 0% { transform: translateX(-101%); } + 48% { transform: translateX(0); } + 100% { transform: translateX(101%); } +} + +.gate-alt { margin-top: 1.35rem; } +.link { + border: 0; + background: none; + padding: 0; + font: 400 .87rem/1.4 var(--body); + color: #1c6b7d; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} +.link:hover { color: #12505e; } + +.gate-foot { + margin: 1.5rem 0 0; + padding-top: 1.15rem; + border-top: 1px solid rgba(22,32,31,.16); + font-size: .82rem; + line-height: 1.55; + color: var(--ink-soft); +} +.gate-foot a { color: #1c6b7d; text-underline-offset: 3px; } + +.gate.is-refused { animation: refuse .4s ease; } +@keyframes refuse { + 0%,100% { transform: translateX(0); } + 22% { transform: translateX(-7px); } + 58% { transform: translateX(5px); } + 82% { transform: translateX(-2px); } +} + +/* ---------------- foot ---------------- */ + +.footfold { + position: relative; + z-index: 2; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: .4rem 1.5rem; + padding: 1.15rem var(--gutter) 1.6rem; + border-top: 1px solid var(--board-edge); +} +.footfold p { + margin: 0; + font: 400 .68rem/1.4 var(--mono); + letter-spacing: .06em; + color: var(--sleeve); +} + +/* ---------------- focus ---------------- */ + +:focus-visible { + outline: 2px solid var(--flash); + outline-offset: 3px; +} +.gate :focus-visible { outline-color: #1c6b7d; } +.field input:focus-visible { outline: 0; } + +/* ---------------- develop sequence ---------------- */ +/* Frames rise out of the board through a contrast ramp; the date stamps + flick on last, once the print has settled. */ + +.js .frame-print { animation: develop .95s cubic-bezier(.25,.9,.3,1) backwards; } +.js .frame-stamp, +.js .frame-run { animation: flick .35s steps(2, end) backwards; } + +@keyframes develop { + from { opacity: 0; filter: brightness(.15) contrast(2.4) saturate(.2); transform: translateY(14px); } + 60% { opacity: 1; } + to { opacity: 1; filter: none; transform: translateY(0); } +} +@keyframes flick { + from { opacity: 0; } + 50% { opacity: .35; } + to { opacity: 1; } +} + +.js .lede > .eyebrow, +.js .lede-title, +.js .lede-sub, +.js .ticker, +.js .gate { animation: settle .8s cubic-bezier(.2,.8,.25,1) backwards; } + +@keyframes settle { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: none; } +} + +/* ---------------- responsive ---------------- */ + +@media (max-width: 62rem) { + /* .lede dissolves into the grid, so the row gap now does the spacing + these elements did with their own margins. Zero the duplicates. */ + .stage { + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 1.6rem; + padding-top: 1rem; + } + .lede { display: contents; } + .eyebrow { order: 1; margin-bottom: 0; } + .lede-title { order: 2; } + .lede-sub { order: 3; margin-top: 0; max-width: 46ch; } + .gate { order: 4; max-width: 27rem; margin-top: 0; } + .ticker { order: 6; margin-top: 0; } + .lede-title { font-size: clamp(2.6rem, 9vw, 4rem); } + + /* No room to lay the sheet under the card — make it a strip you drag, + and drop the fade so nothing you scroll to is dimmed. */ + .sheet { + order: 5; + width: auto; + margin: 0 calc(var(--gutter) * -1); + padding: 1.25rem var(--gutter); + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + -webkit-mask-image: none; + mask-image: none; + } +} + +@media (max-width: 34rem) { + .frame { width: 8.75rem; } + .ticker { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem 1rem; } + .ticker-cell { padding-right: 0; } +} + +@media (prefers-reduced-motion: reduce) { + /* Delays are set inline by the develop sequence, so they need clearing + too — otherwise `backwards` fill holds prints at opacity 0. */ + *, *::before, *::after { + animation-duration: .01ms !important; + animation-delay: 0s !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } + .frame:hover, .frame:focus-within { transform: rotate(var(--tilt)) translateY(calc(var(--lift) * -1)); } +} diff --git a/tools/render_roll.py b/tools/render_roll.py new file mode 100644 index 0000000..2825865 --- /dev/null +++ b/tools/render_roll.py @@ -0,0 +1,662 @@ +#!/usr/bin/env python3 +""" +Render the contact-sheet frames for hipa. + +Each frame is composed as a scene (geometry + texture), then put through a +film pipeline: lens softness, vignette, chromatic aberration, halation out +of the highlights, dye-fade colour science for its era, grain that peaks in +the midtones, and physical dust. Nothing here is a flat gradient. + +Output: /img/*.jpg +""" + +import os +import numpy as np +from PIL import Image + +OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "img") +SS = 2 # supersample factor, downsampled before grain + + +# ----------------------------------------------------------------- utilities + +def _box(a, r, axis): + """Box blur along one axis, float, edge-padded.""" + if r < 1: + return a + k = 2 * r + 1 + pad = [(0, 0)] * a.ndim + pad[axis] = (r + 1, r) + ap = np.pad(a, pad, mode="edge") + c = np.cumsum(ap, axis=axis, dtype=np.float32) + sl_hi = [slice(None)] * a.ndim + sl_lo = [slice(None)] * a.ndim + sl_hi[axis] = slice(k, None) + sl_lo[axis] = slice(None, -k) + return (c[tuple(sl_hi)] - c[tuple(sl_lo)]) / k + + +def gblur(a, r, passes=3): + """Three box passes approximate a gaussian. Works on 2D or (h,w,3).""" + if r <= 0: + return a + br = max(1, int(round(r / 1.6))) + for _ in range(passes): + a = _box(a, br, 0) + a = _box(a, br, 1) + return a + + +def fnoise(h, w, octaves=6, persistence=0.55, seed=0, aspect=True): + """Fractal value noise, built from upsampled random grids.""" + rng = np.random.default_rng(seed) + out = np.zeros((h, w), np.float32) + amp, tot = 1.0, 0.0 + for o in range(octaves): + gh = 2 ** (o + 1) + gw = max(2, int(gh * w / h)) if aspect else gh + g = rng.random((gh, gw)).astype(np.float32) + g = np.asarray( + Image.fromarray((g * 255).astype(np.uint8), "L").resize((w, h), Image.BICUBIC), + np.float32, + ) / 255.0 + out += g * amp + tot += amp + amp *= persistence + return out / tot + + +def grid(h, w): + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + return x / w, y / h + + +def radial(h, w, cx, cy, rx, ry, power=2.0): + x, y = grid(h, w) + d = ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 + return np.clip(1.0 - d, 0.0, 1.0) ** power + + +def figure(h, w, cx, cy, scale, lean=0.0): + """A soft human-ish mass: head over shoulders widening downward.""" + x, y = grid(h, w) + X = (x - cx) / scale + lean * (y - cy) / scale + Y = (y - cy) / scale + head = np.clip(1 - ((X / 0.20) ** 2 + ((Y + 0.58) / 0.24) ** 2), 0, 1) + shoulder = np.clip(0.30 + 0.30 * np.clip(Y + 0.3, 0, 1.4), 0.30, 0.62) + body = np.clip(1 - ((X / shoulder) ** 2 + ((Y - 0.34) / 0.78) ** 2), 0, 1) + return np.maximum(head ** 0.6, body ** 0.6) + + +def over(base, mask, colour, amount=1.0): + """Composite a flat colour through a mask.""" + m = (mask * amount)[..., None] + return base * (1 - m) + np.asarray(colour, np.float32) * m + + +def band(h, w, y0, y1, soft=0.01): + _, y = grid(h, w) + a = np.clip((y - y0) / max(soft, 1e-5), 0, 1) + b = np.clip((y1 - y) / max(soft, 1e-5), 0, 1) + return np.minimum(a, b) + + +# ------------------------------------------------------------ film pipeline + +def linearize(img): + return np.clip(img, 0, None) ** 2.2 + + +def halation(img, threshold=0.70, radius=26, amount=0.32, tint=(1.0, 0.52, 0.26)): + lum = linearize(img).mean(2) + hi = np.clip((lum - threshold) / max(1e-5, 1 - threshold), 0, 1) + glow = gblur(hi, radius) + return img + glow[..., None] * np.asarray(tint, np.float32) * amount + + +def vignette(img, amount=0.40, power=1.6): + h, w, _ = img.shape + x, y = grid(h, w) + r = np.sqrt(((x - 0.5) * 1.06) ** 2 + ((y - 0.5) * 1.0) ** 2) / 0.72 + v = 1.0 - amount * np.clip(r, 0, 1) ** power + return img * v[..., None] + + +def chroma_ab(img, amount=0.0016): + """Scale R up and B down about the centre — lateral colour fringing.""" + if amount <= 0: + return img + h, w, _ = img.shape + out = img.copy() + for ch, s in ((0, 1.0 + amount), (2, 1.0 - amount)): + nw, nh = max(2, int(round(w * s))), max(2, int(round(h * s))) + im = Image.fromarray((np.clip(img[..., ch], 0, 1) * 255).astype(np.uint8), "L") + im = im.resize((nw, nh), Image.BICUBIC) + a = np.asarray(im, np.float32) / 255.0 + if s >= 1.0: + oy, ox = (nh - h) // 2, (nw - w) // 2 + out[..., ch] = a[oy:oy + h, ox:ox + w] + else: + pad = np.zeros((h, w), np.float32) + oy, ox = (h - nh) // 2, (w - nw) // 2 + pad[oy:oy + nh, ox:ox + nw] = a + pad[:oy, :] = pad[oy:oy + 1, :] + pad[oy + nh:, :] = pad[oy + nh - 1:oy + nh, :] + out[..., ch] = pad + return out + + +def dye(img, matrix, lift, gain, gamma, sat): + """Channel crosstalk + per-channel lift/gain/gamma, then desaturate.""" + m = np.asarray(matrix, np.float32) + img = np.einsum("ij,hwj->hwi", m, img) + img = np.clip(img, 0, None) + img = img ** np.asarray(gamma, np.float32) + img = img * np.asarray(gain, np.float32) + np.asarray(lift, np.float32) + lum = img @ np.array([0.2126, 0.7152, 0.0722], np.float32) + return lum[..., None] + (img - lum[..., None]) * sat + + +def scurve(img, strength=0.35, pivot=0.45): + x = np.clip(img, 0, 1.6) + s = np.clip((x - pivot) / max(1e-5, 1 - pivot) * 0.5 + 0.5, 0, 1) + smooth = s * s * (3 - 2 * s) + curved = (smooth - 0.5) * 2 * (1 - pivot) + pivot + return x * (1 - strength) + curved * strength + + +def grain(img, amount=0.030, size=0.62, seed=1, mono=0.65): + h, w, _ = img.shape + rng = np.random.default_rng(seed) + n_mono = rng.normal(0, 1, (h, w)).astype(np.float32) + n_rgb = rng.normal(0, 1, (h, w, 3)).astype(np.float32) + n = n_mono[..., None] * mono + n_rgb * (1 - mono) + if size > 0: + n = gblur(n, size, passes=2) + n /= max(1e-5, n.std()) + lum = np.clip(img.mean(2), 0, 1) + # silver grain is loudest in the midtones, quiet in blacks and blown areas + weight = 1.0 - (2.0 * lum - 1.0) ** 2 + return img + n * (amount * weight[..., None]) + + +def dust(img, specks=90, hairs=2, scratches=1, seed=3): + h, w, _ = img.shape + rng = np.random.default_rng(seed) + out = img.copy() + for _ in range(specks): + cy, cx = rng.integers(0, h), rng.integers(0, w) + r = int(rng.integers(1, max(2, h // 260))) + bright = rng.random() < 0.62 + y0, y1 = max(0, cy - r), min(h, cy + r + 1) + x0, x1 = max(0, cx - r), min(w, cx + r + 1) + v = rng.uniform(0.10, 0.34) + out[y0:y1, x0:x1] += v if bright else -v + for _ in range(hairs): + y = rng.integers(int(h * 0.1), int(h * 0.9)) + x = rng.integers(0, int(w * 0.7)) + ln = int(rng.integers(w // 12, w // 4)) + drift = rng.uniform(-0.35, 0.35) + for i in range(ln): + yy = int(y + np.sin(i / 22.0) * 5 + drift * i * 0.1) + xx = x + i + if 0 <= yy < h and 0 <= xx < w: + out[yy, xx] -= 0.18 + for _ in range(scratches): + x = rng.integers(int(w * 0.15), int(w * 0.85)) + out[:, max(0, x - 1):x + 1] += rng.uniform(0.05, 0.12) + return out + + +def scanlines(img, strength=0.14, chroma_bleed=16, jitter=0.6, seed=5): + """Interlaced-tape artefacts: line structure, smeared chroma, line jitter.""" + h, w, _ = img.shape + rng = np.random.default_rng(seed) + lum = img @ np.array([0.2126, 0.7152, 0.0722], np.float32) + chroma = img - lum[..., None] + chroma = _box(chroma, max(1, chroma_bleed), 1) + chroma = _box(chroma, max(1, chroma_bleed // 2), 1) + img = lum[..., None] + chroma + rows = np.arange(h) + mod = 1.0 - strength * (rows % 2) + img = img * mod[:, None, None] + if jitter > 0: + shifts = np.round(gblur(rng.normal(0, 1, (h, 4)), 3)[:, 0] * jitter).astype(int) + for y in range(h): + s = int(shifts[y]) + if s: + img[y] = np.roll(img[y], s, axis=0) + return img + + +def save(img, name, quality=82): + os.makedirs(OUT, exist_ok=True) + a = (np.clip(img, 0, 1) ** (1 / 1.0) * 255.0 + 0.5).astype(np.uint8) + path = os.path.join(OUT, name) + Image.fromarray(a, "RGB").save(path, "JPEG", quality=quality, optimize=True, + progressive=True, subsampling=1) + return path, os.path.getsize(path) + + +def downsample(img, w, h): + a = (np.clip(img, 0, 1) * 255.0 + 0.5).astype(np.uint8) + im = Image.fromarray(a, "RGB").resize((w, h), Image.LANCZOS) + return np.asarray(im, np.float32) / 255.0 + + +# ---------------------------------------------------------------- the scenes +# Subjects are places and things. People appear only as deep-distance specks +# or as silhouettes the frame edge cuts through — a rendered human at any +# size reads as a chess pawn, and a chess pawn is not a memory. + +def rect(h, w, x0, x1, y0, y1, soft=0.005): + x, y = grid(h, w) + return (np.clip((x - x0) / soft, 0, 1) * np.clip((x1 - x) / soft, 0, 1) + * np.clip((y - y0) / soft, 0, 1) * np.clip((y1 - y) / soft, 0, 1)) + + +def sag_line(h, w, x0, y0, x1, y1, sag=0.05, thick=0.004): + x, y = grid(h, w) + t = np.clip((x - x0) / max(1e-5, x1 - x0), 0, 1) + yl = y0 + (y1 - y0) * t + sag * np.sin(np.pi * t) + inside = ((x >= x0) & (x <= x1)).astype(np.float32) + return inside * np.clip(1 - np.abs(y - yl) / thick, 0, 1) + + +def dome(h, w, cx, cy, r, squash=1.25): + return radial(h, w, cx, cy, r, r * squash, power=1.4) + + +def scene_firstroll(h, w): + """1974. Washing on the line, middle of the afternoon, sun in the lens.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.66, 0.68, 0.62)) + img = over(img, np.clip((0.62 - y) / 0.62, 0, 1) ** 0.9, (0.80, 0.82, 0.78), 0.9) + + wall = fnoise(h, w, octaves=6, seed=101) + img += ((wall - 0.5) * np.clip((0.70 - y) / 0.70, 0, 1))[..., None] * 0.07 + + img = over(img, sag_line(h, w, 0.0, 0.283, 1.0, 0.268, sag=0.018, + thick=0.006), (0.22, 0.20, 0.17), 0.75) + + sheets = ((0.075, 0.225, 0.655, 0.96), (0.265, 0.395, 0.585, 0.90), + (0.435, 0.585, 0.700, 0.99), (0.625, 0.735, 0.560, 0.92), + (0.780, 0.930, 0.630, 0.94)) + folds = fnoise(h, w, octaves=7, seed=103) + for x0, x1, y1, shade in sheets: + hem = 0.012 * np.sin((x - x0) / max(1e-5, x1 - x0) * 9.0) + m = rect(h, w, x0, x1, 0.285, y1, 0.004) * np.clip((y1 + hem - y) / 0.010, 0, 1) + img = over(img, m, (shade, shade * 0.985, shade * 0.94), 0.97) + img -= (m * np.clip(folds - 0.52, 0, 1))[..., None] * 0.42 + + hedge = np.clip((y - 0.745) / 0.05, 0, 1) + leaf = fnoise(h, w, octaves=8, seed=107) + img = over(img, hedge, (0.24, 0.27, 0.16), 0.94) + img += (hedge * (leaf - 0.5))[..., None] * np.array([0.20, 0.26, 0.12], np.float32) + + sun = radial(h, w, 0.885, 0.075, 0.42, 0.48, power=2.5) + img += sun[..., None] * np.array([1.0, 0.93, 0.72], np.float32) * 0.95 + return img + + +def scene_reel8mm(h, w): + """1981. Super 8: the window in the front room, nets drawn, a plant on the sill.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.20, 0.14, 0.08)) + + win = rect(h, w, 0.205, 0.795, 0.095, 0.615, 0.006) + img = over(img, win, (0.90, 0.88, 0.78), 1.0) + + nets = fnoise(h, w, octaves=4, seed=113) + weave = 0.5 + 0.5 * np.sin(x * 260.0) + img -= (win * (0.35 * weave + 0.30 * nets))[..., None] * 0.30 + img += gblur(win, 24 * SS)[..., None] * np.array([1.0, 0.84, 0.52], np.float32) * 0.95 + + frame_bar = (rect(h, w, 0.487, 0.513, 0.095, 0.615, 0.003) + + rect(h, w, 0.205, 0.795, 0.335, 0.358, 0.003)) + img = over(img, np.clip(frame_bar, 0, 1), (0.34, 0.24, 0.13), 0.85) + + sill = rect(h, w, 0.155, 0.845, 0.615, 0.700, 0.006) + img = over(img, sill, (0.44, 0.32, 0.17), 0.92) + + rng = np.random.default_rng(117) + plant = np.zeros((h, w), np.float32) + for _ in range(22): + a = rng.uniform(0, 6.283) + r = rng.uniform(0.0, 0.085) + plant = np.maximum(plant, radial(h, w, 0.735 + np.cos(a) * r, + 0.520 + np.sin(a) * r * 0.85, + rng.uniform(0.020, 0.040), + rng.uniform(0.012, 0.024), power=1.3)) + img = over(img, gblur(plant, 1.6 * SS), (0.16, 0.19, 0.10), 0.90) + img = over(img, rect(h, w, 0.690, 0.780, 0.600, 0.660, 0.006), (0.36, 0.22, 0.14), 0.85) + return img + + +def scene_shore(h, w): + """1989. The lake at Issyk: sky, water, wet sand, footprints going in.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + HOR = 0.400 + + img = over(img, np.ones((h, w), np.float32), (0.60, 0.70, 0.76)) + img = over(img, np.clip((HOR - y) / HOR, 0, 1) ** 1.4, (0.79, 0.85, 0.88), 0.80) + + far = np.clip((HOR - y) / 0.030, 0, 1) * np.clip((y - 0.330) / 0.030, 0, 1) + img = over(img, far, (0.50, 0.54, 0.55), 0.60) + + water = band(h, w, HOR, 0.655, soft=0.004) + deep = np.clip((0.655 - y) / 0.255, 0, 1) + img = over(img, water, (0.34, 0.46, 0.50), 0.95) + img += (water * (1 - deep))[..., None] * np.array([0.14, 0.15, 0.13], np.float32) + streak = _box(fnoise(h, w, octaves=8, seed=41), max(1, w // 70), 1) + img += (water * np.clip(streak - 0.58, 0, 1) * 1.3)[..., None] * \ + np.array([0.90, 0.93, 0.90], np.float32) + + surf = band(h, w, 0.628, 0.668, soft=0.012) + img += (surf * (0.4 + 0.6 * fnoise(h, w, octaves=7, seed=43)))[..., None] * \ + np.array([0.34, 0.35, 0.33], np.float32) + + sand = np.clip((y - 0.655) / 0.345, 0, 1) + img = over(img, sand, (0.70, 0.62, 0.45), 0.95) + wet = band(h, w, 0.655, 0.735, soft=0.030) + img -= wet[..., None] * np.array([0.13, 0.12, 0.08], np.float32) + img += (sand * (fnoise(h, w, octaves=8, seed=47) - 0.5))[..., None] * 0.15 + + prints = np.zeros((h, w), np.float32) + for i in range(8): + t = i / 7.0 + px = 0.315 + t * 0.185 + (0.026 if i % 2 else -0.026) + py = 0.985 - t * 0.290 + sc = 0.032 - t * 0.017 + prints = np.maximum(prints, radial(h, w, px, py, sc, sc * 0.52, power=1.2)) + img -= gblur(prints, 1.3 * SS)[..., None] * np.array([0.20, 0.17, 0.11], np.float32) + + for sx, sy, sr in ((0.245, 0.418, 0.007), (0.320, 0.410, 0.005), + (0.735, 0.414, 0.006)): + img = over(img, radial(h, w, sx, sy, sr, sr * 1.8, power=1.1), + (0.22, 0.20, 0.19), 0.75) + + sun = radial(h, w, 0.855, 0.105, 0.32, 0.38, power=2.8) + img += sun[..., None] * np.array([1.0, 0.96, 0.80], np.float32) * 0.88 + return img + +def scene_kitchen(h, w): + """1996. The long table laid, one window doing all the work.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.15, 0.095, 0.055)) + + win = rect(h, w, 0.055, 0.245, 0.115, 0.575, 0.008) + img = over(img, win, (0.93, 0.94, 0.89), 1.0) + img += gblur(win, 20 * SS)[..., None] * np.array([1.0, 0.86, 0.60], np.float32) * 1.25 + + lamp = radial(h, w, 0.74, 0.13, 0.20, 0.22, power=2.0) + img += lamp[..., None] * np.array([1.0, 0.72, 0.36], np.float32) * 0.72 + + tw = 0.16 + 0.62 * np.clip((y - 0.575) / 0.425, 0, 1) + top = ((np.abs(x - 0.52) < tw) * np.clip((y - 0.575) / 0.015, 0, 1)).astype(np.float32) + img = over(img, gblur(top, 1.6 * SS), (0.58, 0.42, 0.25), 0.93) + img += (top * (fnoise(h, w, octaves=7, seed=53) - 0.5))[..., None] * 0.10 + + # chair backs, cut off by the frame + for cx0, cx1 in ((0.02, 0.135), (0.865, 0.985)): + img = over(img, rect(h, w, cx0, cx1, 0.60, 1.02, 0.008), (0.20, 0.13, 0.07), 0.88) + + settings = np.zeros((h, w), np.float32) + for px, py, pr in ((0.395, 0.700, 0.042), (0.610, 0.715, 0.040), + (0.330, 0.860, 0.055), (0.700, 0.880, 0.052)): + settings += radial(h, w, px, py, pr, pr * 0.42, power=1.3) + img += np.clip(settings, 0, 1)[..., None] * np.array([1.0, 0.92, 0.76], np.float32) * 0.50 + + for gx, gy in ((0.455, 0.660), (0.560, 0.672)): + g = radial(h, w, gx, gy, 0.016, 0.038, power=1.2) + img += g[..., None] * np.array([1.0, 0.86, 0.62], np.float32) * 0.60 + return img + + +def scene_bouquet(h, w): + """1998. Marta and Ilya's day, photographed as the flowers on the table.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.235, 0.205, 0.190)) + img += radial(h, w, 0.20, 0.16, 0.60, 0.55, power=1.6)[..., None] * \ + np.array([0.62, 0.58, 0.52], np.float32) * 0.42 + img += radial(h, w, 0.86, 0.86, 0.50, 0.45, power=1.8)[..., None] * \ + np.array([0.40, 0.34, 0.28], np.float32) * 0.28 + + stem = np.clip(1 - np.abs((x - 0.505) - (y - 0.66) * 0.10) / 0.032, 0, 1) * \ + np.clip((y - 0.670) / 0.02, 0, 1) + img = over(img, gblur(stem, 2.0 * SS), (0.24, 0.22, 0.17), 0.80) + + rng = np.random.default_rng(91) + spots = [] + for _ in range(15): + a, r = rng.uniform(0, 6.283), rng.uniform(0.0, 0.175) + spots.append((0.500 + np.cos(a) * r * 1.30, 0.420 + np.sin(a) * r * 0.94, + rng.uniform(0.048, 0.078))) + spots.sort(key=lambda s: s[1]) + + for cx, cy, rr in spots: + shade = radial(h, w, cx, cy + 0.010, rr * 1.30, rr * 1.15, power=1.2) + img = over(img, gblur(shade, 2.2 * SS), (0.26, 0.22, 0.20), 0.60) + core = radial(h, w, cx, cy, rr, rr * 0.86, power=1.35) + img = over(img, gblur(core, 1.1 * SS), (0.93, 0.90, 0.85), 0.96) + img -= gblur(core * (fnoise(h, w, octaves=7, seed=int(cx * 9991) % 999) - 0.5), + 1.0 * SS)[..., None] * 0.26 + eye = radial(h, w, cx, cy, rr * 0.30, rr * 0.26, power=1.4) + img = over(img, gblur(eye, 1.0 * SS), (0.74, 0.60, 0.34), 0.72) + + leaves = np.zeros((h, w), np.float32) + for _ in range(10): + a, r = rng.uniform(0, 6.283), rng.uniform(0.155, 0.265) + leaves = np.maximum(leaves, radial(h, w, 0.50 + np.cos(a) * r * 1.30, + 0.43 + np.sin(a) * r * 0.94, + rng.uniform(0.028, 0.052), + rng.uniform(0.013, 0.024), power=1.3)) + img = over(img, gblur(leaves, 2.2 * SS), (0.30, 0.34, 0.26), 0.55) + return img + +def scene_camcorder(h, w): + """2003. The school hall from the back row: a lit stage, heads in the way.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.055, 0.060, 0.082)) + + stage = gblur(rect(h, w, 0.215, 0.785, 0.230, 0.665, 0.050), 3.0 * SS) + img = over(img, np.clip(stage, 0, 1), (0.52, 0.525, 0.495), 1.0) + img += gblur(stage, 18 * SS)[..., None] * \ + np.array([0.80, 0.79, 0.72], np.float32) * 0.34 + img -= (stage * (0.5 + 0.5 * np.sin(x * 78.0)))[..., None] * 0.06 + img += radial(h, w, 0.50, 0.375, 0.24, 0.20, power=1.8)[..., None] * \ + np.array([1.0, 0.95, 0.80], np.float32) * 0.26 + + # dark marks on a bright stage: the contrast does the work + rng = np.random.default_rng(211) + for cx in (0.330, 0.408, 0.487, 0.566, 0.648): + hh, wd = rng.uniform(0.064, 0.100), rng.uniform(0.012, 0.020) + base = 0.616 + rng.uniform(-0.008, 0.008) + img = over(img, gblur(rect(h, w, cx - wd, cx + wd, base - hh, base, 0.008), + 1.5 * SS), (0.15, 0.13, 0.13), 0.80) + img = over(img, gblur(radial(h, w, cx, base - hh, wd * 0.8, wd * 0.95, 1.3), + 1.3 * SS), (0.20, 0.16, 0.15), 0.72) + + # the floor between camera and stage catches enough light to silhouette against + img += band(h, w, 0.760, 1.02, soft=0.14)[..., None] * \ + np.array([0.150, 0.155, 0.185], np.float32) + + heads = np.zeros((h, w), np.float32) + for cx, rr in ((0.070, 0.155), (0.300, 0.128), (0.535, 0.168), + (0.775, 0.136), (0.968, 0.130)): + heads = np.maximum(heads, dome(h, w, cx, 1.020, rr)) + heads = gblur(heads, 2.4 * SS) + img = over(img, heads, (0.018, 0.020, 0.030), 0.99) + crown = np.clip(heads - gblur(heads, 6.0 * SS), 0, 1) + img += crown[..., None] * np.array([0.80, 0.80, 0.76], np.float32) * 0.38 + return img + +def scene_candles(h, w): + """2011. Nine candles, and the room going as dark as the camera can make it.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.050, 0.036, 0.030)) + + flames = np.zeros((h, w), np.float32) + glow = np.zeros((h, w), np.float32) + for i in range(9): + t = (i - 4) / 4.0 + cx = 0.50 + t * 0.205 + cy = 0.575 + (t ** 2) * 0.048 + flames = np.maximum(flames, radial(h, w, cx, cy, 0.0115, 0.027, power=1.5)) + glow += radial(h, w, cx, cy, 0.105, 0.115, power=2.0) + img = over(img, rect(h, w, cx - 0.006, cx + 0.006, cy + 0.020, + cy + 0.075, 0.004), (0.62, 0.55, 0.44), 0.55) + img += flames[..., None] * np.array([1.0, 0.90, 0.62], np.float32) * 1.55 + img += gblur(glow, 10 * SS)[..., None] * np.array([1.0, 0.62, 0.26], np.float32) * 0.44 + + cake = band(h, w, 0.655, 0.815, soft=0.012) * np.clip(1 - np.abs(x - 0.5) / 0.335, 0, 1) + img = over(img, gblur(cake, 2.0 * SS), (0.56, 0.47, 0.38), 0.60) + icing = band(h, w, 0.655, 0.685, soft=0.008) * np.clip(1 - np.abs(x - 0.5) / 0.335, 0, 1) + img += icing[..., None] * np.array([1.0, 0.72, 0.40], np.float32) * 0.22 + return img + + +def scene_snow(h, w): + """2019. Chimbulak: the slope, the lift, and nobody close to the camera.""" + x, y = grid(h, w) + img = np.zeros((h, w, 3), np.float32) + img = over(img, np.ones((h, w), np.float32), (0.60, 0.69, 0.79)) + img = over(img, np.clip((0.40 - y) / 0.40, 0, 1) ** 1.1, (0.44, 0.56, 0.72), 0.55) + + prof = _box(fnoise(h, w, octaves=5, seed=61), max(1, w // 26), 1)[0:1, :] + ridge_y = 0.330 + 0.105 * prof.repeat(h, 0) + peaks = np.clip((ridge_y - y) / 0.005, 0, 1) * np.clip((y - 0.100) / 0.045, 0, 1) + img = over(img, peaks, (0.40, 0.46, 0.57), 0.90) + rock = fnoise(h, w, octaves=8, seed=67) + img -= (peaks * np.clip(rock - 0.44, 0, 1))[..., None] * 0.52 + img += (peaks * np.clip(0.40 - rock, 0, 1))[..., None] * \ + np.array([0.34, 0.36, 0.38], np.float32) + + slope = np.clip((y - ridge_y) / 0.085, 0, 1) + img = over(img, slope, (0.86, 0.90, 0.945), 0.94) + img -= (slope * np.clip(0.5 - fnoise(h, w, octaves=8, seed=71), 0, 1))[..., None] * \ + np.array([0.34, 0.26, 0.14], np.float32) + img -= (slope * np.clip((y - 0.60) / 0.40, 0, 1))[..., None] * \ + np.array([0.10, 0.07, 0.02], np.float32) + + for off, wd, dp in ((0.0, 0.050, 0.16), (0.120, 0.032, 0.12), (-0.145, 0.026, 0.10)): + tr = np.clip(1 - np.abs((x - 0.46 - off) - (y - 0.5) * 0.42) / wd, 0, 1) * slope + img -= gblur(tr, 3 * SS)[..., None] * np.array([dp, dp * 0.72, dp * 0.30], np.float32) + + cable = sag_line(h, w, 0.0, 0.300, 1.0, 0.210, sag=0.055, thick=0.0035) + img = over(img, cable, (0.22, 0.25, 0.31), 0.85) + for px, py in ((0.185, 0.340), (0.615, 0.286)): + img = over(img, rect(h, w, px - 0.006, px + 0.006, py, py + 0.250, 0.004), + (0.19, 0.22, 0.28), 0.90) + img = over(img, rect(h, w, px - 0.040, px + 0.040, py, py + 0.014, 0.004), + (0.19, 0.22, 0.28), 0.90) + for cx, cy in ((0.075, 0.316), (0.355, 0.276), (0.520, 0.264), (0.845, 0.254)): + img = over(img, rect(h, w, cx - 0.012, cx + 0.012, cy, cy + 0.032, 0.004), + (0.14, 0.16, 0.21), 0.92) + + for sx, sy, sc, col in ((0.330, 0.760, 0.019, (0.58, 0.16, 0.13)), + (0.402, 0.700, 0.015, (0.16, 0.20, 0.34))): + img -= gblur(radial(h, w, sx + 0.012, sy + 0.016, sc * 1.5, sc * 0.7, 1.2), + 2.0 * SS)[..., None] * np.array([0.16, 0.13, 0.06], np.float32) + img = over(img, gblur(radial(h, w, sx, sy, sc * 0.62, sc * 1.45, power=1.3), + 1.0 * SS), col, 0.90) + return img + +# ------------------------------------------------------------------- recipes + +IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + +FRAMES = [ + dict(name="01-first-roll.jpg", scene=scene_firstroll, size=(720, 480), + soft=1.5, vig=0.44, ca=0.0022, halo=dict(threshold=0.62, radius=30, amount=0.40), + dye=dict(matrix=[[1.02, 0.07, 0.02], [0.03, 0.95, 0.05], [0.02, 0.12, 0.80]], + lift=(0.070, 0.052, 0.088), gain=(1.00, 0.98, 0.90), + gamma=(1.00, 1.03, 1.14), sat=0.68), + curve=0.26, grain=0.034, dust=dict(specks=110, hairs=2, scratches=1)), + + dict(name="02-grandads-8mm.jpg", scene=scene_reel8mm, size=(720, 540), + soft=2.4, vig=0.78, ca=0.0030, halo=dict(threshold=0.58, radius=26, amount=0.34), + dye=dict(matrix=[[1.06, 0.05, 0.00], [0.04, 0.98, 0.02], [0.00, 0.09, 0.74]], + lift=(0.040, 0.036, 0.040), gain=(1.04, 0.98, 0.80), + gamma=(0.96, 1.00, 1.12), sat=0.86), + curve=0.40, grain=0.058, dust=dict(specks=210, hairs=4, scratches=2)), + + dict(name="03-issyk.jpg", scene=scene_shore, size=(720, 480), + soft=1.2, vig=0.34, ca=0.0018, halo=dict(threshold=0.68, radius=28, amount=0.38), + dye=dict(matrix=[[1.00, 0.05, 0.02], [0.02, 0.98, 0.03], [0.02, 0.06, 0.92]], + lift=(0.052, 0.048, 0.062), gain=(1.02, 1.00, 0.96), + gamma=(1.00, 1.01, 1.06), sat=0.80), + curve=0.30, grain=0.028, dust=dict(specks=80, hairs=1, scratches=0)), + + dict(name="04-long-table.jpg", scene=scene_kitchen, size=(720, 480), + soft=1.6, vig=0.52, ca=0.0020, halo=dict(threshold=0.55, radius=32, amount=0.50), + dye=dict(matrix=[[1.04, 0.06, 0.01], [0.03, 0.97, 0.03], [0.01, 0.07, 0.82]], + lift=(0.048, 0.038, 0.044), gain=(1.06, 0.97, 0.84), + gamma=(0.97, 1.02, 1.10), sat=0.78), + curve=0.34, grain=0.036, dust=dict(specks=120, hairs=2, scratches=1)), + + dict(name="05-marta-and-ilya.jpg", scene=scene_bouquet, size=(720, 480), + soft=1.5, vig=0.46, ca=0.0026, halo=dict(threshold=0.60, radius=30, amount=0.30), + dye=dict(matrix=[[1.03, 0.06, 0.03], [0.03, 0.96, 0.04], [0.03, 0.08, 0.88]], + lift=(0.080, 0.062, 0.074), gain=(1.00, 0.97, 0.94), + gamma=(1.02, 1.04, 1.08), sat=0.66), + curve=0.34, grain=0.030, dust=dict(specks=95, hairs=2, scratches=0)), + + dict(name="06-school-concert.jpg", scene=scene_camcorder, size=(720, 540), + soft=2.4, vig=0.36, ca=0.0014, halo=dict(threshold=0.50, radius=30, amount=0.42), + dye=dict(matrix=[[0.98, 0.06, 0.04], [0.04, 0.96, 0.05], [0.05, 0.08, 1.00]], + lift=(0.062, 0.066, 0.086), gain=(0.98, 0.99, 1.04), + gamma=(1.04, 1.03, 0.99), sat=0.52), + curve=0.22, grain=0.044, tape=dict(strength=0.16, chroma_bleed=18, jitter=0.8), + dust=dict(specks=30, hairs=0, scratches=0)), + + dict(name="07-nine-candles.jpg", scene=scene_candles, size=(720, 540), + soft=1.8, vig=0.58, ca=0.0016, halo=dict(threshold=0.42, radius=34, amount=0.70), + dye=dict(matrix=[[1.05, 0.04, 0.01], [0.03, 0.98, 0.03], [0.01, 0.05, 0.94]], + lift=(0.052, 0.044, 0.052), gain=(1.05, 0.98, 0.90), + gamma=(0.97, 1.01, 1.05), sat=0.74), + curve=0.24, grain=0.062, dust=dict(specks=40, hairs=0, scratches=0)), + + dict(name="08-chimbulak.jpg", scene=scene_snow, size=(720, 480), + soft=0.7, vig=0.30, ca=0.0008, halo=dict(threshold=0.78, radius=22, amount=0.22), + dye=dict(matrix=IDENTITY, + lift=(0.014, 0.016, 0.022), gain=(1.00, 1.00, 1.02), + gamma=(1.00, 1.00, 0.99), sat=0.92), + curve=0.40, grain=0.013, dust=dict(specks=12, hairs=0, scratches=0)), +] + + +def build(spec, index): + w, h = spec["size"] + hs, ws = h * SS, w * SS + img = spec["scene"](hs, ws) + + img = gblur(img, spec.get("soft", 1.0) * SS) # lens softness + img = downsample(np.clip(img, 0, 1.4), w, h) + img = halation(img, **spec["halo"]) + img = vignette(img, amount=spec["vig"]) + img = chroma_ab(img, spec.get("ca", 0.0)) + img = dye(img, **spec["dye"]) + img = scurve(img, strength=spec.get("curve", 0.3)) + if "tape" in spec: + img = scanlines(img, seed=index * 7 + 5, **spec["tape"]) + img = grain(img, amount=spec["grain"], seed=index * 13 + 1) + img = dust(img, seed=index * 17 + 3, **spec["dust"]) + return np.clip(img, 0, 1) + + +if __name__ == "__main__": + total = 0 + for i, spec in enumerate(FRAMES): + img = build(spec, i) + path, size = save(img, spec["name"]) + total += size + print(f"{spec['name']:26s} {spec['size'][0]}x{spec['size'][1]} {size/1024:6.1f} KB") + print(f"{'total':26s} {'':9s} {total/1024:6.1f} KB")