feat: portfolio-led redesign with Shootings umbrella, original logo and motion layer

This commit is contained in:
desfoto automation
2026-09-19 17:11:25 +02:00
parent 2ccb39a69b
commit 447b909d05
177 changed files with 1853 additions and 773 deletions

View File

@@ -12,7 +12,9 @@ Steps
from __future__ import annotations
import base64
import datetime as dt
import io
import json
import shutil
import sys
@@ -27,6 +29,7 @@ SITE = ROOT / "site"
IMG_OUT = SITE / "assets" / "img"
FONT_OUT = SITE / "assets" / "fonts"
TTF = ASSETS / "fonts-ttf"
LOGO_SRC = ASSETS / "brand" / "logo.png"
sys.path.insert(0, str(SRC))
@@ -35,7 +38,7 @@ import pages # noqa: E402
from content import LEGAL, SITE as SITE_META, VIDEO # noqa: E402
from theme import CSS, TOKENS # noqa: E402
WIDTHS = (480, 768, 1200, 1600, 2000)
WIDTHS = (480, 768, 1200, 1600)
WEBP_QUALITY = 84
INK = (23, 20, 15)
@@ -44,16 +47,16 @@ ACCENT = (192, 67, 28)
# OpenGraph image per route, built from the page's own key visual.
ROUTE_OG = {
"/": ("hero-studio", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
"/fotografie/": ("editorial-earring", "og-fotografie.jpg", "Fotografie aus Neumünster"),
"/": ("music-vocal-blue", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
"/shootings/": ("business-portrait-studio", "og-shootings.jpg", "Shootings aus Neumünster"),
"/businessfotografie/": ("business-portrait-studio", "og-businessfotografie.jpg", "Businessfotografie"),
"/portrait-und-model/": ("editorial-colour", "og-portrait.jpg", "Portrait & Model"),
"/familien-und-paare/": ("people-joy", "og-familien.jpg", "Familien & Paare"),
"/minishootings/": ("portrait-natural", "og-minishootings.jpg", "Minishootings"),
"/video/": ("studio-lens", "og-video.jpg", "Videoprojekte"),
"/video/": ("music-guitar-silhouette", "og-video.jpg", "Videoprojekte"),
"/social-media/": ("event-motion", "og-social-media.jpg", "Social-Media-Inhalte"),
"/projekte/": ("travel-norway-pano", "og-projekte.jpg", "Eigene Projekte"),
"/ueber/": ("business-portrait-dark", "og-ueber.jpg", "Über desfoto"),
"/projekte/": ("free-mountain-view", "og-projekte.jpg", "Eigene Projekte"),
"/ueber/": ("studio-setup", "og-ueber.jpg", "Über desfoto"),
"/kontakt/": ("event-venue", "og-kontakt.jpg", "Kontakt"),
"/impressum/": ("gear-cases", "og-impressum.jpg", "Impressum"),
"/datenschutz/": ("event-venue", "og-datenschutz.jpg", "Datenschutz"),
@@ -159,59 +162,38 @@ def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(TTF / name), size)
def draw_mark(size: int, background: bool = True) -> Image.Image:
"""The desfoto aperture mark, drawn deterministically with PIL."""
scale = 8
s = size * scale
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
if background:
d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * 0.22), fill=INK + (255,))
cx = cy = s / 2
ring_w = max(2, int(s * 0.055))
d.ellipse(
(cx - s * 0.27, cy - s * 0.27, cx + s * 0.27, cy + s * 0.27),
outline=PAPER + (255,),
width=ring_w,
)
d.ellipse(
(cx - s * 0.105, cy - s * 0.105, cx + s * 0.105, cy + s * 0.105),
outline=PAPER + (255,),
width=ring_w,
)
blade_w = max(2, int(s * 0.058))
import math
for angle in (270, 30, 150):
rad = math.radians(angle)
x1 = cx + math.cos(rad) * s * 0.27
y1 = cy + math.sin(rad) * s * 0.27
x2 = cx + math.cos(rad) * s * 0.45
y2 = cy + math.sin(rad) * s * 0.45
d.line((x1, y1, x2, y2), fill=ACCENT + (255,), width=blade_w)
for (px, py) in ((x1, y1), (x2, y2)):
r = blade_w / 2
d.ellipse((px - r, py - r, px + r, py + r), fill=ACCENT + (255,))
return img.resize((size, size), Image.Resampling.LANCZOS)
def load_logo() -> Image.Image:
"""The operator's own logo (as published on dennyschulz.de)."""
if not LOGO_SRC.exists():
raise SystemExit(f"missing brand asset {LOGO_SRC} - run scripts/fetch-assets.py")
return Image.open(LOGO_SRC).convert("RGBA")
def build_brand_assets(table: dict[str, dict]) -> None:
logo = load_logo()
# 1:1 copy of the operator's original logo for the header/footer mark.
logo.save(IMG_OUT / "logo-160.png", "PNG", optimize=True)
logo.resize((180, 180), Image.Resampling.LANCZOS).save(
IMG_OUT / "apple-touch-icon.png", "PNG", optimize=True
)
logo.resize((32, 32), Image.Resampling.LANCZOS).save(
IMG_OUT / "favicon-32.png", "PNG", optimize=True
)
logo.resize((16, 16), Image.Resampling.LANCZOS).save(
IMG_OUT / "favicon-16.png", "PNG", optimize=True
)
# Self-contained SVG favicon; the operator's own site uses the same trick.
buffer = io.BytesIO()
logo.save(buffer, "PNG", optimize=True)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
(SITE / "favicon.svg").write_text(
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="desfoto">
<rect width="64" height="64" rx="14" fill="#17140F"/>
<circle cx="32" cy="32" r="17" fill="none" stroke="#F6F2EA" stroke-width="3.4"/>
<circle cx="32" cy="32" r="6.6" fill="none" stroke="#F6F2EA" stroke-width="2.6"/>
<g stroke="#C0431C" stroke-width="3.6" stroke-linecap="round">
<path d="M32 15V6"/><path d="M46.7 40.5 54.4 45"/><path d="M17.3 40.5 9.6 45"/>
</g>
</svg>
""",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-label="desfoto">\n'
f' <image width="160" height="160" href="data:image/png;base64,{encoded}"/>\n'
"</svg>\n",
encoding="utf-8",
)
draw_mark(180).save(IMG_OUT / "apple-touch-icon.png")
draw_mark(64).save(IMG_OUT / "favicon-32.png")
draw_mark(64).resize((16, 16), Image.Resampling.LANCZOS).save(IMG_OUT / "favicon-16.png")
print(" [brand] favicon.svg, apple-touch-icon.png, favicon-32.png")
print(" [brand] logo-160.png, favicon.svg, apple-touch-icon.png, favicon-32.png")
og_done = set()
for slug, filename, headline in ROUTE_OG.values():
@@ -219,6 +201,11 @@ def build_brand_assets(table: dict[str, dict]) -> None:
continue
og_done.add(filename)
build_og(table, slug, filename, headline)
# A renamed route would otherwise leave its old card behind forever.
for stale in sorted(IMG_OUT.glob("og-*.jpg")):
if stale.name not in og_done:
stale.unlink()
print(f" [brand] removed stale {stale.name}")
def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None:
@@ -249,8 +236,10 @@ def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) ->
brand_font = _font("fraunces-600.ttf", 92)
head_font = _font("fraunces-400.ttf", 54)
small_font = _font("manrope-600.ttf", 26)
d.text((72, 74), "desfoto", font=brand_font, fill=PAPER)
d.text((76, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132))
mark = load_logo().resize((102, 102), Image.Resampling.LANCZOS)
source.paste(mark, (76, 62), mark)
d.text((198, 72), "desfoto", font=brand_font, fill=PAPER)
d.text((202, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132))
lines = wrap_text(headline, head_font, target_w - 150)
y = target_h - 90 - len(lines) * 64
for line in lines:
@@ -421,22 +410,108 @@ def build_script() -> None:
});
}
/* Soft reveal on scroll, disabled for reduced motion */
/* Motion layer: reveals, scroll progress, parallax and pointer spotlights.
Everything degrades to a static page and is disabled for reduced motion. */
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Anything inside [data-stagger] reveals one item after another. */
Array.prototype.forEach.call(document.querySelectorAll('[data-stagger]'), function (group) {
var step = parseFloat(group.getAttribute('data-stagger')) || 0.08;
Array.prototype.forEach.call(group.children, function (child, index) {
if (!child.classList.contains('reveal')) {
child.classList.add('reveal');
}
child.style.setProperty('--d', (index * step).toFixed(2) + 's');
});
});
var items = document.querySelectorAll('.reveal');
var revealAll = function () {
Array.prototype.forEach.call(items, function (item) { item.classList.add('is-in'); });
};
if (reduce || !('IntersectionObserver' in window)) {
items.forEach(function (item) { item.classList.add('is-in'); });
revealAll();
} else {
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-in');
observer.unobserve(entry.target);
try {
var fired = 0;
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
fired = 1;
entry.target.classList.add('is-in');
observer.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.06 });
Array.prototype.forEach.call(items, function (item) { observer.observe(item); });
/* Safety net: if the browser never reports a box that is on screen, drop the
effect entirely instead of leaving content hidden. */
window.setTimeout(function () {
if (fired) { return; }
var onScreen = false;
Array.prototype.forEach.call(items, function (item) {
var box = item.getBoundingClientRect();
if (box.top < window.innerHeight && box.bottom > 0) { onScreen = true; }
});
if (onScreen) {
observer.disconnect();
revealAll();
}
});
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });
items.forEach(function (item) { observer.observe(item); });
}, 1600);
} catch (error) {
revealAll();
}
}
if (reduce) {
return;
}
var progress = document.querySelector('[data-progress]');
var parallax = Array.prototype.slice.call(document.querySelectorAll('[data-parallax]'));
var ticking = false;
function onScroll() {
var doc = document.documentElement;
var max = doc.scrollHeight - window.innerHeight;
var ratio = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
if (progress) {
progress.style.setProperty('--scroll', ratio.toFixed(4));
}
if (parallax.length) {
var view = window.innerHeight;
parallax.forEach(function (element) {
var rect = element.getBoundingClientRect();
if (rect.bottom < -240 || rect.top > view + 240) {
return;
}
var speed = parseFloat(element.getAttribute('data-parallax')) || 0.12;
var offset = (rect.top + rect.height / 2 - view / 2) * speed;
element.style.setProperty('--py', offset.toFixed(1) + 'px');
});
}
ticking = false;
}
function requestScroll() {
if (!ticking) {
ticking = true;
window.requestAnimationFrame(onScroll);
}
}
window.addEventListener('scroll', requestScroll, { passive: true });
window.addEventListener('resize', requestScroll);
onScroll();
/* Pointer-following highlight on the media cards. */
Array.prototype.forEach.call(document.querySelectorAll('.spot'), function (card) {
card.addEventListener('pointermove', function (event) {
var rect = card.getBoundingClientRect();
card.style.setProperty('--mx', ((event.clientX - rect.left) / rect.width * 100).toFixed(1) + '%');
card.style.setProperty('--my', ((event.clientY - rect.top) / rect.height * 100).toFixed(1) + '%');
});
});
}());
""",
encoding="utf-8",

View File

@@ -7,6 +7,10 @@ Sources
We pull the pre-derived `web` variant (long edge 2560 px) instead of the
20-60 MB camera originals. Only assets we are allowed to publish as the
rights holder are listed in src/images.json.
* Own portfolio files of the existing site (www.dennyschulz.de/legacy/<file>).
These carry the concert, band and free-work photographs that are missing from
the pool; they are fetched as-is and downscaled by the build. Also used for
the operator's original logo (www.dennyschulz.de/logo.png).
* Google Fonts (open licensed, OFL) - downloaded once, then self-hosted.
Licences are written to docs/.
* YouTube poster frame for the own music video "Thjodroerir - Skogamor".
@@ -26,10 +30,13 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "assets-src"
IMG_OUT = SRC / "images"
BRAND_OUT = SRC / "brand"
FONT_OUT = SRC / "fonts"
TTF_OUT = SRC / "fonts-ttf"
DOCS = ROOT / "docs"
LOGO_URL = "https://www.dennyschulz.de/logo.png"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
# Old Safari advertises truetype-only support, so Google serves real .ttf
# files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read).
@@ -81,22 +88,26 @@ def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
def fetch_images() -> None:
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
base = manifest["pool_base"].rstrip("/")
legacy_base = manifest["legacy_base"].rstrip("/")
suffixes = (".webp", ".src", ".jpg", ".jpeg", ".png")
ok = skipped = 0
for entry in manifest["images"]:
slug, raw = entry["slug"], entry["raw"]
rel = raw.replace("\\", "/")
target = IMG_OUT / f"{slug}.webp"
source = IMG_OUT / f"{slug}.src"
if target.exists() or source.exists():
if any((IMG_OUT / f"{slug}{suffix}").exists() for suffix in suffixes):
skipped += 1
continue
data = try_fetch(f"{base}/_derived/web/{rel}.webp")
if data is None:
print(f" [!] web variant missing, falling back to original: {rel}")
data = fetch(f"{base}/{rel}")
source.write_bytes(data)
if entry.get("source") == "legacy":
data = fetch(f"{legacy_base}/{rel}")
(IMG_OUT / f"{slug}.jpg").write_bytes(data)
else:
target.write_bytes(data)
data = try_fetch(f"{base}/_derived/web/{rel}.webp")
if data is None:
print(f" [!] web variant missing, falling back to original: {rel}")
data = fetch(f"{base}/{rel}")
(IMG_OUT / f"{slug}.src").write_bytes(data)
else:
(IMG_OUT / f"{slug}.webp").write_bytes(data)
ok += 1
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
print(f"images: {ok} fetched, {skipped} already present")
@@ -110,6 +121,18 @@ def fetch_images() -> None:
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
def fetch_brand() -> None:
"""The operator's original logo (used for the brand mark and all icons)."""
BRAND_OUT.mkdir(parents=True, exist_ok=True)
target = BRAND_OUT / "logo.png"
if target.exists():
print(f"brand: logo already present ({target.stat().st_size / 1024:.0f} KiB)")
return
data = fetch(LOGO_URL)
target.write_bytes(data)
print(f" [ok] logo ({len(data) / 1024:.0f} KiB)")
def fetch_fonts() -> None:
FONT_OUT.mkdir(parents=True, exist_ok=True)
for family, spec in FAMILIES.items():
@@ -160,8 +183,10 @@ def fetch_fonts() -> None:
def main() -> int:
for path in (IMG_OUT, FONT_OUT, DOCS):
for path in (IMG_OUT, BRAND_OUT, FONT_OUT, DOCS):
path.mkdir(parents=True, exist_ok=True)
print("== brand ==")
fetch_brand()
print("== images ==")
fetch_images()
print("== fonts ==")