Complete Milestone 7 playground application
This commit is contained in:
parent
1503cbca8c
commit
8b7b897347
36 changed files with 3225 additions and 10 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
web/node_modules
|
||||
web/dist
|
||||
data/raw
|
||||
data/interim
|
||||
data/processed
|
||||
data/reports
|
||||
data/cache
|
||||
*.log
|
||||
|
|
@ -11,3 +11,6 @@ SMK_EN_FILE=./data/raw/smk_all_en.json
|
|||
# They are NOT the primary bulk-ingest path for v1.
|
||||
SMK_API_BASE=https://api.smk.dk/api/v1
|
||||
SMK_ENRICHMENT_BASE=https://enrichment.api.smk.dk/api/enrichment
|
||||
|
||||
# Docker Compose publishes the single application service here.
|
||||
HOST_PORT=8135
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,6 +12,8 @@ venv/
|
|||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
|
|
@ -41,6 +43,8 @@ data/reports/*
|
|||
*.duckdb.wal
|
||||
*.db
|
||||
.cache/
|
||||
data/cache/*
|
||||
!data/cache/.gitkeep
|
||||
|
||||
# Generated columnar/export formats
|
||||
*.parquet
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ Milestone 6 keeps enrichment as a separate append-only source/cache. Weighted pa
|
|||
must never overwrite bulk uniform v1. Corpus membership remains unchanged when enrichment is
|
||||
missing. Palette transport uses exact OKLab min-cost flow, and weighted similarity is a new profile.
|
||||
|
||||
Milestone 7 serves the frozen analytical artifacts through one FastAPI + React application.
|
||||
Processed data is read-only at runtime. Browser audio is user-triggered and uses versioned,
|
||||
deterministic sonification mappings. Weighted similarity is an explicit deep action and may write
|
||||
only profile-hash-keyed results under `data/cache/`.
|
||||
|
||||
Never overwrite source data. Never silently invent missing values.
|
||||
|
||||
## Primary bulk source
|
||||
|
|
@ -397,7 +402,8 @@ Use fast feature-vector candidate retrieval, then optionally rerank top candidat
|
|||
|
||||
Sonification is a separate mapping layer, never an objective artwork feature.
|
||||
|
||||
Initial profile: `art-tone-v1`.
|
||||
Initial profile set: `weighted-color-tone-v1`, `physical-artwork-tone-v1`, and
|
||||
`palette-arpeggio-v1`.
|
||||
|
||||
Tentative mapping:
|
||||
|
||||
|
|
|
|||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ ./src/
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --from=frontend /web/dist ./web/dist
|
||||
|
||||
ENV SMK_ROOT=/app \
|
||||
PYTHONUNBUFFERED=1
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "smk_playground.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
32
README.md
32
README.md
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
A small, reproducible analysis pipeline for visual and colour features in artworks from **Statens Museum for Kunst (SMK)**.
|
||||
|
||||
The first goal is deliberately not a web app. The goal is to turn the locally downloaded SMK dataset into a trustworthy, versioned analytical foundation that can be reused for colour search, similarity, clustering, timelines, mosaics, visual experiments and data sonification.
|
||||
The project first established a trustworthy, versioned analytical foundation from the locally
|
||||
downloaded SMK dataset. It now includes a small exploratory web application for colour search,
|
||||
similarity, weighted palettes, and deterministic data sonification.
|
||||
|
||||
## Local source files
|
||||
|
||||
|
|
@ -121,14 +123,16 @@ See:
|
|||
|
||||
## Current status
|
||||
|
||||
Milestones 0–6 complete: source structure and audits are manifested, the language snapshots have
|
||||
Milestones 0–7 complete: source structure and audits are manifested, the language snapshots have
|
||||
bounded-memory source Parquets, DA/EN rows are paired into stable SMK entities, and the exact
|
||||
`smk-public-visual-v1` membership rule is frozen. Deterministic raw visual features and analytical
|
||||
palettes now exist. Versioned corpus statistics, normalized scalar features and three unweighted
|
||||
candidate feature families are materialized. Three deterministic, explainable exact-distance
|
||||
profiles now support color, visual-character and specialist geometry queries; ANN is not used.
|
||||
Separately cached SMK enrichment responses add weighted palettes, v2 weighted features and an
|
||||
exact palette-transport similarity profile without mutating the bulk-derived v1 artifacts.
|
||||
exact palette-transport similarity profile without mutating the bulk-derived v1 artifacts. A
|
||||
single-service FastAPI + React application now exposes those frozen artifacts, explicit deep
|
||||
weighted similarity, and deterministic browser sonification.
|
||||
|
||||
Audit results are documented in:
|
||||
|
||||
|
|
@ -148,6 +152,8 @@ Audit results are documented in:
|
|||
- [`docs/ENRICHMENT_V1.md`](docs/ENRICHMENT_V1.md)
|
||||
- [`docs/WEIGHTED_PALETTES_V1.md`](docs/WEIGHTED_PALETTES_V1.md)
|
||||
- [`docs/WEIGHTED_SIMILARITY_V1.md`](docs/WEIGHTED_SIMILARITY_V1.md)
|
||||
- [`docs/SONIFICATION_V1.md`](docs/SONIFICATION_V1.md)
|
||||
- [`docs/WEB_UI_V1.md`](docs/WEB_UI_V1.md)
|
||||
|
||||
Run the audited ingest with:
|
||||
|
||||
|
|
@ -160,4 +166,24 @@ smk milestone5
|
|||
smk similar KMS8892 --profile color --limit 10
|
||||
smk enrichment status
|
||||
smk similar KMS8892 --profile weighted-color --limit 10
|
||||
smk web-catalog
|
||||
```
|
||||
|
||||
## Run the application
|
||||
|
||||
The existing Milestones 1–6 processed artifacts must be present locally. Build the compact app
|
||||
catalog once after those artifacts change:
|
||||
|
||||
```bash
|
||||
smk web-catalog
|
||||
```
|
||||
|
||||
Then start the single application service:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open [http://localhost:8135](http://localhost:8135). The processed directory is mounted read-only;
|
||||
only `data/cache/` is writable. Set `HOST_PORT` in `.env` to publish another host port.
|
||||
|
|
|
|||
1
data/cache/.gitkeep
vendored
Normal file
1
data/cache/.gitkeep
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "${HOST_PORT:-8135}:8000"
|
||||
environment:
|
||||
SMK_ROOT: /app
|
||||
volumes:
|
||||
- ./data/processed:/app/data/processed:ro
|
||||
- ./data/cache:/app/data/cache
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
|
@ -81,13 +81,11 @@ resumable append-only source cache, weighted palettes/features v2, bulk/enrichme
|
|||
an exact palette-transport similarity profile. See `ENRICHMENT_V1.md`, `WEIGHTED_PALETTES_V1.md`
|
||||
and `WEIGHTED_SIMILARITY_V1.md`.
|
||||
|
||||
## Milestone 7 — Full dataset build
|
||||
## Milestone 7 — First usable application + sonification
|
||||
|
||||
Run the complete local source snapshots.
|
||||
|
||||
Definition of done:
|
||||
|
||||
Every canonical artwork has valid applicable features or explicit nulls/flags explaining missing/invalid prerequisites. Every normalization result references a frozen profile and exact source manifest.
|
||||
Status: complete. A compact read-only web catalog feeds one FastAPI + React service with search,
|
||||
artwork detail, existing exact similarity profiles, explicit cached weighted similarity, and three
|
||||
versioned deterministic browser-audio mappings. See `SONIFICATION_V1.md` and `WEB_UI_V1.md`.
|
||||
|
||||
## Milestone 8 — Similarity
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ select/freeze normalization corpus
|
|||
normalized features
|
||||
↓
|
||||
similarity / sonification experiments
|
||||
↓
|
||||
compact web catalog + read-only application
|
||||
```
|
||||
|
||||
## Recommended CLI stages
|
||||
|
|
@ -99,6 +101,14 @@ separate source manifest, parse repeated palette tokens into explicit weights, e
|
|||
palette/features v2, and add exact OKLab palette transport under a new similarity profile. Existing
|
||||
bulk v1 corpus, features, normalization and profiles remain byte-independent inputs.
|
||||
|
||||
## Stage 7 — exploratory application
|
||||
|
||||
Join only the fields needed for interactive browsing into `web-catalog-v1.parquet`, freeze the
|
||||
sonification profile manifest, and serve the built React client and FastAPI routes from one
|
||||
container. Runtime startup validates artifacts but never triggers ingestion, enrichment, or
|
||||
normalization. Processed inputs remain read-only; deep weighted-similarity responses use the
|
||||
separate invalidatable cache.
|
||||
|
||||
## Ingest performance rule
|
||||
|
||||
The source files are large. Use bounded-memory streaming where practical. A one-time conversion to Parquet/Arrow as an interim representation is encouraged if it simplifies all later stages.
|
||||
|
|
|
|||
38
docs/SONIFICATION_V1.md
Normal file
38
docs/SONIFICATION_V1.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Sonification v1
|
||||
|
||||
Sonification is an interpretive presentation layer, not an objective artwork feature. The API
|
||||
returns deterministic synthesis parameters; sound is produced only after a user action by the
|
||||
browser Web Audio API. The frozen profile set is `1.0.0` and is materialized as
|
||||
`data/processed/sonification-profiles-v1.json`.
|
||||
|
||||
## Weighted color tone
|
||||
|
||||
`weighted-color-tone-v1` maps the enrichment-weighted OKLab centroid to one sustained tone:
|
||||
|
||||
- hue uses twelve equal 30-degree bins, `floor(hue / 30)`, mapped C through B;
|
||||
- undefined achromatic hue maps explicitly to A and reports `achromatic_neutral`;
|
||||
- centroid OKLab lightness sets a target register from MIDI 36 through 84, rounded to the nearest
|
||||
MIDI note in the selected pitch class;
|
||||
- centroid chroma controls velocity, palette spread controls harmonic richness, normalized
|
||||
palette entropy controls a small noise component, hue concentration controls tonal purity, and
|
||||
dominant weight controls attack and release.
|
||||
|
||||
All controls are clamped to documented safe ranges in `sonification/profiles.py`.
|
||||
|
||||
## Physical artwork tone
|
||||
|
||||
`physical-artwork-tone-v1` uses the same hue and timbre mapping, but inverse log-area percentile
|
||||
sets register: larger physical works sound lower. Signed log aspect ratio produces subtle stereo
|
||||
pan via `0.6 * tanh(log_aspect)`. The profile is explicitly unavailable unless both a weighted
|
||||
palette and reliable Netto width/height-derived normalized geometry exist. Pixel dimensions never
|
||||
substitute for physical measurements.
|
||||
|
||||
## Palette arpeggio
|
||||
|
||||
`palette-arpeggio-v1` plays weighted palette entries in descending weight, with palette rank as a
|
||||
stable tie-break. Each color maps hue to pitch class and lightness to octave; its weight controls
|
||||
duration and velocity. The API includes the color, weight, note, MIDI number, frequency, duration,
|
||||
and pitch-source explanation for every step.
|
||||
|
||||
These mappings are deliberately simple, explainable, and deterministic. They do not claim that an
|
||||
artwork has an inherent sound or that musical distance is visual similarity.
|
||||
36
docs/WEB_UI_V1.md
Normal file
36
docs/WEB_UI_V1.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Web UI v1
|
||||
|
||||
Milestone 7 is one FastAPI application serving both a built React/TypeScript frontend and a JSON
|
||||
API. It reads existing processed artifacts without mutating them. Only weighted-similarity query
|
||||
results are written, under `data/cache/weighted-similarity/`.
|
||||
|
||||
## User flows
|
||||
|
||||
- `/` searches title, artist, classification, and object number; it also offers a deterministic
|
||||
seeded random entry point and corpus coverage figures.
|
||||
- `/artwork/:id` presents the image and bilingual metadata, a proportional weighted-palette strip,
|
||||
weighted feature values, user-triggered sonification, and similarity tabs.
|
||||
- `/about` explains provenance, interpretation, missingness, and the distinction between source,
|
||||
derived features, and artistic mappings.
|
||||
|
||||
Color similarity is the default. Visual-character and geometry profiles remain separate. The
|
||||
weighted-color profile is labelled as a slower, explicit deep comparison; it is never launched by
|
||||
page load or tab hover. Its exact result is cached by canonical entity ID and the SHA-256 of
|
||||
`similarity-profiles-v2.json`, so profile changes invalidate old cache entries.
|
||||
|
||||
Audio never autoplays. Play and stop controls are keyboard accessible, palette values have visible
|
||||
text labels, focus indicators are explicit, and reduced-motion preferences disable decorative
|
||||
motion. Missing physical geometry is shown as unavailable rather than inferred from pixels.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/health` and `/api/stats`
|
||||
- `GET /api/artworks?q=&limit=&offset=&classification=&has_physical=&chromatic=`
|
||||
- `GET /api/artworks/random?seed=`
|
||||
- `GET /api/resolve/{object_number}` (retains ambiguous object-number groups)
|
||||
- `GET /api/artworks/{smk_entity_id}`
|
||||
- `GET /api/artworks/{smk_entity_id}/similar?profile=color|visual|geometry|weighted-color`
|
||||
|
||||
The compact web catalog is reproducibly built with `smk web-catalog`. Application startup fails
|
||||
with an explicit artifact list when required processed files are absent; it never rebuilds or
|
||||
downloads analytical data automatically.
|
||||
|
|
@ -10,16 +10,19 @@ readme = "README.md"
|
|||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"duckdb>=1",
|
||||
"fastapi>=0.115",
|
||||
"ijson>=3.3",
|
||||
"numpy>=2",
|
||||
"polars>=1",
|
||||
"pyarrow>=18",
|
||||
"typer>=0.12",
|
||||
"uvicorn>=0.34",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"httpx>=0.28",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
|
|
|
|||
1
src/smk_playground/api/__init__.py
Normal file
1
src/smk_playground/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""HTTP application package."""
|
||||
97
src/smk_playground/api/app.py
Normal file
97
src/smk_playground/api/app.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""FastAPI application serving the API and built React frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from smk_playground.api.catalog import Catalog
|
||||
|
||||
|
||||
def repository_root() -> Path:
|
||||
configured = os.environ.get("SMK_ROOT")
|
||||
return Path(configured).resolve() if configured else Path.cwd().resolve()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.catalog = Catalog(repository_root())
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="SMK Playground API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
|
||||
def catalog() -> Catalog:
|
||||
return app.state.catalog
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {"status": "ok", "catalog_rows": len(catalog().rows)}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
def stats() -> dict[str, Any]:
|
||||
return catalog().stats()
|
||||
|
||||
|
||||
@app.get("/api/artworks")
|
||||
def artworks(
|
||||
q: str = "", limit: int = Query(24, ge=1, le=100), offset: int = Query(0, ge=0),
|
||||
classification: str | None = None, has_physical: bool | None = None,
|
||||
chromatic: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return catalog().search(q, limit, offset, classification, has_physical, chromatic)
|
||||
|
||||
|
||||
@app.get("/api/artworks/random")
|
||||
def random_artwork(seed: int | None = None) -> dict[str, Any]:
|
||||
return catalog().random(seed)
|
||||
|
||||
|
||||
@app.get("/api/resolve/{object_number}")
|
||||
def resolve_object_number(object_number: str) -> dict[str, Any]:
|
||||
matches = catalog().resolve(object_number)
|
||||
if not matches:
|
||||
raise HTTPException(404, "Object number not found in corpus")
|
||||
return {"object_number": object_number, "ambiguous": len(matches) > 1, "items": matches}
|
||||
|
||||
|
||||
@app.get("/api/artworks/{entity_id}/similar")
|
||||
def similar_artworks(entity_id: str, profile: str = "color",
|
||||
limit: int = Query(10, ge=1, le=50)) -> dict[str, Any]:
|
||||
if profile not in {"color", "visual", "geometry", "weighted-color"}:
|
||||
raise HTTPException(400, "Unknown similarity profile")
|
||||
try:
|
||||
return catalog().similar(entity_id, profile, limit)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Artwork not found") from None
|
||||
except ValueError as error:
|
||||
raise HTTPException(422, str(error)) from error
|
||||
|
||||
|
||||
@app.get("/api/artworks/{entity_id}")
|
||||
def artwork(entity_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return catalog().artwork(entity_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Artwork not found") from None
|
||||
|
||||
|
||||
frontend = repository_root() / "web" / "dist"
|
||||
if frontend.exists():
|
||||
app.mount("/assets", StaticFiles(directory=frontend / "assets"), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
def frontend_route(full_path: str) -> FileResponse:
|
||||
candidate = frontend / full_path
|
||||
if full_path and candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(frontend / "index.html")
|
||||
173
src/smk_playground/api/catalog.py
Normal file
173
src/smk_playground/api/catalog.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Read-only in-memory catalog over frozen compact Parquet artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from smk_playground.enrichment.milestone6 import WeightedSimilarityEngine
|
||||
from smk_playground.normalize.milestone4 import sha256_file
|
||||
from smk_playground.similarity.engine import SimilarityEngine
|
||||
from smk_playground.sonification.profiles import (
|
||||
palette_arpeggio,
|
||||
physical_artwork_tone,
|
||||
weighted_color_tone,
|
||||
)
|
||||
|
||||
|
||||
class Catalog:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
required = [
|
||||
root / "data" / "processed" / "web-catalog-v1.parquet",
|
||||
root / "data" / "processed" / "artwork-weighted-palettes-v1.parquet",
|
||||
root / "data" / "processed" / "similarity-profiles-v1.json",
|
||||
root / "data" / "processed" / "similarity-profiles-v2.json",
|
||||
root / "data" / "processed" / "sonification-profiles-v1.json",
|
||||
]
|
||||
missing = [str(path) for path in required if not path.exists()]
|
||||
if missing:
|
||||
raise RuntimeError("Missing required processed artifacts: " + ", ".join(missing))
|
||||
self.rows = pq.read_table(required[0]).to_pylist()
|
||||
self.by_id = {row["smk_entity_id"]: row for row in self.rows}
|
||||
self.by_number: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in self.rows:
|
||||
self.by_number.setdefault(row["object_number"], []).append(row)
|
||||
palette_rows = pq.read_table(required[1]).to_pylist()
|
||||
self.palettes: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in palette_rows:
|
||||
self.palettes.setdefault(row["smk_entity_id"], []).append(row)
|
||||
for values in self.palettes.values():
|
||||
values.sort(key=lambda row: row["palette_rank"])
|
||||
self.fast_similarity = SimilarityEngine(root)
|
||||
self._weighted_similarity: WeightedSimilarityEngine | None = None
|
||||
self.weighted_profile_hash = sha256_file(required[3])
|
||||
self.cache_dir = root / "data" / "cache" / "weighted-similarity"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def weighted_similarity(self) -> WeightedSimilarityEngine:
|
||||
if self._weighted_similarity is None:
|
||||
self._weighted_similarity = WeightedSimilarityEngine(self.root)
|
||||
return self._weighted_similarity
|
||||
|
||||
def public_card(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: row.get(key) for key in (
|
||||
"smk_entity_id", "object_number", "display_title", "artist_display", "date_text",
|
||||
"classification", "image_thumbnail", "centroid_chroma", "dominant_color_hex",
|
||||
)}
|
||||
|
||||
def search(self, query: str, limit: int, offset: int, classification: str | None,
|
||||
has_physical: bool | None, chromatic: bool | None) -> dict[str, Any]:
|
||||
needle = query.casefold().strip()
|
||||
matches = []
|
||||
for row in self.rows:
|
||||
if needle and needle not in row["search_text"]:
|
||||
continue
|
||||
if classification and classification.casefold() not in (row.get("classification") or "").casefold():
|
||||
continue
|
||||
if has_physical is not None:
|
||||
present = row.get("net_width_cm") is not None and row.get("net_height_cm") is not None
|
||||
if present != has_physical:
|
||||
continue
|
||||
if chromatic is not None:
|
||||
present = row.get("centroid_hue_deg") is not None
|
||||
if present != chromatic:
|
||||
continue
|
||||
matches.append(row)
|
||||
return {"total": len(matches), "limit": limit, "offset": offset,
|
||||
"items": [self.public_card(row) for row in matches[offset:offset + limit]]}
|
||||
|
||||
def artwork(self, entity_id: str) -> dict[str, Any]:
|
||||
if entity_id not in self.by_id:
|
||||
raise KeyError(entity_id)
|
||||
row = self.by_id[entity_id]
|
||||
palette = self.palettes.get(entity_id, [])
|
||||
feature = row
|
||||
normalized = row
|
||||
return {
|
||||
**{key: row.get(key) for key in (
|
||||
"smk_entity_id", "object_number", "title_da", "title_en", "display_title",
|
||||
"artist_display", "date_text", "classification", "image_iiif_id",
|
||||
"image_thumbnail", "image_native", "image_width", "image_height",
|
||||
"rights_semantic", "net_width_cm", "net_height_cm", "quality_flags",
|
||||
)},
|
||||
"weighted_palette": [{key: item.get(key) for key in (
|
||||
"palette_rank", "hex", "token_count", "weight", "oklab_l", "oklab_a",
|
||||
"oklab_b", "oklch_c", "oklch_h_deg")}
|
||||
for item in palette],
|
||||
"weighted_features": {key: row.get(key) for key in (
|
||||
"centroid_l", "centroid_a", "centroid_b", "centroid_chroma",
|
||||
"centroid_hue_deg", "palette_entropy", "palette_entropy_01",
|
||||
"effective_palette_size", "palette_spread_oklab", "chroma_stddev",
|
||||
"hue_concentration_01", "dominant_color_hex", "dominant_color_weight",
|
||||
"weighted_palette_status")},
|
||||
"sonification": {
|
||||
"weighted_color": weighted_color_tone(feature),
|
||||
"physical": physical_artwork_tone(feature, normalized),
|
||||
"palette_arpeggio": palette_arpeggio(palette),
|
||||
},
|
||||
"provenance": {
|
||||
"metadata": "SMK bulk open data",
|
||||
"palette_weights": "SMK enrichment colorextractor token frequencies",
|
||||
"features": "SMK Playground derived weighted features v2",
|
||||
"sonification": "SMK Playground deterministic artistic mappings v1",
|
||||
},
|
||||
}
|
||||
|
||||
def resolve(self, object_number: str) -> list[dict[str, Any]]:
|
||||
return [self.public_card(row) for row in self.by_number.get(object_number, [])]
|
||||
|
||||
def random(self, seed: int | None) -> dict[str, Any]:
|
||||
rng = random.Random(seed) if seed is not None else random.SystemRandom()
|
||||
return self.public_card(self.rows[rng.randrange(len(self.rows))])
|
||||
|
||||
def similar(self, entity_id: str, profile: str, limit: int) -> dict[str, Any]:
|
||||
if entity_id not in self.by_id:
|
||||
raise KeyError(entity_id)
|
||||
if profile == "weighted-color":
|
||||
return self._weighted_cached(entity_id, limit)
|
||||
query = self.fast_similarity.resolve(smk_entity_id=entity_id)
|
||||
results = self.fast_similarity.search_row(query, profile, limit)
|
||||
return {"profile": profile, "cached": False,
|
||||
"items": [self._similar_item(item) for item in results]}
|
||||
|
||||
def _similar_item(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||
catalog = self.by_id[result["smk_entity_id"]]
|
||||
return {**self.public_card(catalog), "rank": result["rank"],
|
||||
"distance": result["total_distance"],
|
||||
"active_components": result["active_components"],
|
||||
"components": result["components"]}
|
||||
|
||||
def _weighted_cached(self, entity_id: str, limit: int) -> dict[str, Any]:
|
||||
cache_limit = max(50, limit)
|
||||
cache_path = self.cache_dir / f"{entity_id}.json"
|
||||
if cache_path.exists():
|
||||
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
if (payload.get("profile_hash") == self.weighted_profile_hash
|
||||
and payload.get("max_results", 0) >= limit):
|
||||
return {"profile": "weighted-color", "cached": True,
|
||||
"items": payload["items"][:limit]}
|
||||
query = self.weighted_similarity.resolve(smk_entity_id=entity_id)
|
||||
results = self.weighted_similarity.search_row(query, cache_limit)
|
||||
items = [self._similar_item(item) for item in results]
|
||||
payload = {"profile": "weighted-color", "profile_hash": self.weighted_profile_hash,
|
||||
"max_results": cache_limit, "items": items}
|
||||
temporary = cache_path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
temporary.replace(cache_path)
|
||||
return {"profile": "weighted-color", "cached": False, "items": items[:limit]}
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
return {
|
||||
"artwork_count": len(self.rows),
|
||||
"weighted_palette_count": sum(bool(row["has_weighted_palette_v1"]) for row in self.rows),
|
||||
"physical_geometry_count": sum(
|
||||
row.get("net_width_cm") is not None and row.get("net_height_cm") is not None
|
||||
for row in self.rows
|
||||
),
|
||||
}
|
||||
|
|
@ -76,6 +76,15 @@ def milestone6(root: Path | None = None) -> None:
|
|||
typer.echo(f"Milestone 6 complete: {summary['weighted_palette_entity_count']} weighted entities")
|
||||
|
||||
|
||||
@app.command("web-catalog")
|
||||
def web_catalog(root: Path | None = None) -> None:
|
||||
"""Build the compact read-only catalog and sonification manifest for the app."""
|
||||
from smk_playground.web_catalog.build import run
|
||||
|
||||
summary = run((root or Path.cwd()).resolve())
|
||||
typer.echo(f"Web catalog complete: {summary['rows']} rows")
|
||||
|
||||
|
||||
@enrichment_app.command("status")
|
||||
def enrichment_status(root: Path | None = None) -> None:
|
||||
"""Report resumable enrichment-cache status."""
|
||||
|
|
|
|||
122
src/smk_playground/sonification/profiles.py
Normal file
122
src/smk_playground/sonification/profiles.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Deterministic artistic mappings from artwork features to browser-audio parameters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
PROFILE_VERSION = "1.0.0"
|
||||
PITCH_CLASSES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
|
||||
|
||||
|
||||
def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
|
||||
return min(high, max(low, value))
|
||||
|
||||
|
||||
def hue_pitch_class(hue_degrees: float | None) -> tuple[int, str, str]:
|
||||
if hue_degrees is None:
|
||||
return 9, "A", "achromatic_neutral"
|
||||
index = int((hue_degrees % 360.0) // 30.0)
|
||||
return index, PITCH_CLASSES[index], "weighted_centroid_hue"
|
||||
|
||||
|
||||
def nearest_midi(target: float, pitch_class: int) -> int:
|
||||
candidates = [midi for midi in range(36, 85) if midi % 12 == pitch_class]
|
||||
return min(candidates, key=lambda midi: (abs(midi - target), midi))
|
||||
|
||||
|
||||
def midi_frequency(midi: int) -> float:
|
||||
return 440.0 * (2.0 ** ((midi - 69) / 12.0))
|
||||
|
||||
|
||||
def _shared_timbre(feature: dict[str, Any]) -> dict[str, float]:
|
||||
return {
|
||||
"velocity": clamp(0.28 + 4.2 * float(feature.get("centroid_chroma") or 0.0), 0.25, 0.92),
|
||||
"attack": 0.08 + 0.18 * (1.0 - float(feature.get("dominant_color_weight") or 0.0)),
|
||||
"release": 0.65 + 1.35 * float(feature.get("dominant_color_weight") or 0.0),
|
||||
"harmonic_richness": clamp(float(feature.get("palette_spread_oklab") or 0.0) / 0.35),
|
||||
"noise_amount": 0.10 * clamp(float(feature.get("palette_entropy_01") or 0.0)),
|
||||
"tonal_purity": clamp(float(feature.get("hue_concentration_01") or 0.0)),
|
||||
}
|
||||
|
||||
|
||||
def weighted_color_tone(feature: dict[str, Any]) -> dict[str, Any]:
|
||||
available = bool(feature.get("has_weighted_palette_v1"))
|
||||
if not available:
|
||||
return {"profile": "weighted-color-tone-v1", "version": PROFILE_VERSION,
|
||||
"available": False, "reason": "weighted palette unavailable"}
|
||||
pitch_class, note, pitch_source = hue_pitch_class(feature.get("centroid_hue_deg"))
|
||||
lightness = clamp(float(feature["centroid_l"]))
|
||||
midi = nearest_midi(36.0 + 48.0 * lightness, pitch_class)
|
||||
return {
|
||||
"profile": "weighted-color-tone-v1", "version": PROFILE_VERSION, "available": True,
|
||||
"note": note, "octave": midi // 12 - 1, "midi": midi,
|
||||
"frequency_hz": midi_frequency(midi), "pitch_source": pitch_source,
|
||||
**_shared_timbre(feature),
|
||||
"stereo_pan": 0.0,
|
||||
"explanation": {
|
||||
"pitch": "weighted centroid hue; A is the explicit achromatic neutral",
|
||||
"register": "weighted centroid OKLab lightness mapped to MIDI 36–84",
|
||||
"timbre": "weighted chroma, spread, entropy, hue concentration, and dominant weight",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def physical_artwork_tone(feature: dict[str, Any], normalized: dict[str, Any]) -> dict[str, Any]:
|
||||
area_percentile = normalized.get("log_net_area_cm2_percentile_01")
|
||||
log_aspect = normalized.get("log_net_aspect_ratio")
|
||||
if not feature.get("has_weighted_palette_v1") or area_percentile is None or log_aspect is None:
|
||||
return {"profile": "physical-artwork-tone-v1", "version": PROFILE_VERSION,
|
||||
"available": False, "reason": "reliable physical Netto geometry unavailable"}
|
||||
pitch_class, note, pitch_source = hue_pitch_class(feature.get("centroid_hue_deg"))
|
||||
midi = nearest_midi(84.0 - 48.0 * clamp(float(area_percentile)), pitch_class)
|
||||
return {
|
||||
"profile": "physical-artwork-tone-v1", "version": PROFILE_VERSION, "available": True,
|
||||
"note": note, "octave": midi // 12 - 1, "midi": midi,
|
||||
"frequency_hz": midi_frequency(midi), "pitch_source": pitch_source,
|
||||
**_shared_timbre(feature),
|
||||
"stereo_pan": 0.6 * math.tanh(float(log_aspect)),
|
||||
"explanation": {
|
||||
"pitch": "weighted centroid hue; A is the explicit achromatic neutral",
|
||||
"register": "inverse physical log-area percentile: larger works sound lower",
|
||||
"stereo_pan": "subtle signed physical log-aspect mapping",
|
||||
"timbre": "weighted chroma, spread, entropy, hue concentration, and dominant weight",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def palette_arpeggio(palette: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
notes = []
|
||||
for row in sorted(palette, key=lambda item: (-item["weight"], item["palette_rank"])):
|
||||
pitch_class, note, pitch_source = hue_pitch_class(row.get("oklch_h_deg"))
|
||||
midi = nearest_midi(48.0 + 24.0 * clamp(float(row["oklab_l"])), pitch_class)
|
||||
notes.append({
|
||||
"hex": row["hex"], "weight": row["weight"], "note": note,
|
||||
"octave": midi // 12 - 1, "midi": midi, "frequency_hz": midi_frequency(midi),
|
||||
"velocity": clamp(0.30 + 0.70 * row["weight"], 0.3, 0.9),
|
||||
"duration_seconds": 0.18 + 1.15 * row["weight"], "pitch_source": pitch_source,
|
||||
})
|
||||
return {"profile": "palette-arpeggio-v1", "version": PROFILE_VERSION,
|
||||
"available": bool(notes), "notes": notes,
|
||||
"explanation": "descending palette weight; hue→pitch, lightness→octave, weight→duration/intensity"}
|
||||
|
||||
|
||||
def profile_manifest(input_hash: str) -> dict[str, Any]:
|
||||
return {
|
||||
"profile_set_version": PROFILE_VERSION, "input_feature_sha256": input_hash,
|
||||
"profiles": {
|
||||
"weighted-color-tone-v1": {
|
||||
"version": PROFILE_VERSION, "pitch": "floor(hue_deg/30) over C..B; achromatic=A",
|
||||
"register": "nearest matching pitch class to 36 + 48*centroid_l",
|
||||
},
|
||||
"physical-artwork-tone-v1": {
|
||||
"version": PROFILE_VERSION, "required": ["weighted palette", "physical Netto geometry"],
|
||||
"register": "nearest matching pitch class to 84 - 48*log-area percentile",
|
||||
"aspect": "stereo_pan = 0.6*tanh(log_net_aspect_ratio)",
|
||||
},
|
||||
"palette-arpeggio-v1": {
|
||||
"version": PROFILE_VERSION, "order": "weight descending, palette rank ascending",
|
||||
"mapping": "hue→pitch; lightness→octave; weight→duration and velocity",
|
||||
},
|
||||
},
|
||||
}
|
||||
1
src/smk_playground/web_catalog/__init__.py
Normal file
1
src/smk_playground/web_catalog/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Compact web catalog generation."""
|
||||
108
src/smk_playground/web_catalog/build.py
Normal file
108
src/smk_playground/web_catalog/build.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Build deterministic compact catalog and sonification profile manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from smk_playground.normalize.milestone4 import sha256_file
|
||||
from smk_playground.sonification.profiles import profile_manifest
|
||||
|
||||
|
||||
def _json(value: str | None) -> Any:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _first_text(value: str | None, *keys: str) -> str | None:
|
||||
parsed = _json(value)
|
||||
if isinstance(parsed, list):
|
||||
for item in parsed:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
if isinstance(item, dict):
|
||||
for key in keys:
|
||||
candidate = item.get(key)
|
||||
if candidate:
|
||||
return str(candidate).strip()
|
||||
return None
|
||||
|
||||
|
||||
def run(root: Path) -> dict[str, Any]:
|
||||
artworks_path = root / "data" / "processed" / "artworks.parquet"
|
||||
features_path = root / "data" / "processed" / "artwork-visual-features-v2.parquet"
|
||||
normalized_path = root / "data" / "processed" / "artwork-normalized-features-v1.parquet"
|
||||
artwork_columns = [
|
||||
"smk_entity_id", "object_number", "titles_da_json", "titles_en_json",
|
||||
"artists_da_json", "artists_en_json", "production_dates_da_json",
|
||||
"object_names_da_json", "object_names_en_json", "classifications_da_json",
|
||||
"classifications_en_json", "image_iiif_id", "image_thumbnail", "image_native",
|
||||
"image_width", "image_height", "rights_semantic", "net_width_cm", "net_height_cm",
|
||||
"quality_flags", "corpus_smk_public_visual_v1",
|
||||
]
|
||||
canonical = {
|
||||
row["smk_entity_id"]: row for row in pq.read_table(
|
||||
artworks_path, columns=artwork_columns,
|
||||
filters=[("corpus_smk_public_visual_v1", "=", True)],
|
||||
).to_pylist()
|
||||
}
|
||||
feature_columns = [
|
||||
"smk_entity_id", "centroid_l", "centroid_a", "centroid_b", "centroid_chroma",
|
||||
"centroid_hue_deg", "palette_entropy", "palette_entropy_01", "effective_palette_size",
|
||||
"palette_spread_oklab", "chroma_stddev", "hue_concentration_01",
|
||||
"dominant_color_hex", "dominant_color_weight", "has_weighted_palette_v1",
|
||||
"weighted_palette_status",
|
||||
]
|
||||
features = {row["smk_entity_id"]: row for row in pq.read_table(
|
||||
features_path, columns=feature_columns
|
||||
).to_pylist()}
|
||||
normalized = {row["smk_entity_id"]: row for row in pq.read_table(
|
||||
normalized_path, columns=["smk_entity_id", "log_net_area_cm2_percentile_01",
|
||||
"log_net_aspect_ratio"]
|
||||
).to_pylist()}
|
||||
rows = []
|
||||
for entity_id in sorted(canonical):
|
||||
source, feature, norm = canonical[entity_id], features[entity_id], normalized[entity_id]
|
||||
title_da = _first_text(source["titles_da_json"], "title")
|
||||
title_en = _first_text(source["titles_en_json"], "title")
|
||||
artist = _first_text(source["artists_da_json"]) or _first_text(source["artists_en_json"])
|
||||
date_text = _first_text(source["production_dates_da_json"], "period", "start")
|
||||
classification = (
|
||||
_first_text(source["classifications_da_json"], "name", "classification")
|
||||
or _first_text(source["object_names_da_json"], "name")
|
||||
or _first_text(source["classifications_en_json"], "name", "classification")
|
||||
or _first_text(source["object_names_en_json"], "name")
|
||||
)
|
||||
rows.append({
|
||||
"smk_entity_id": entity_id, "object_number": source["object_number"],
|
||||
"title_da": title_da, "title_en": title_en, "display_title": title_da or title_en or source["object_number"],
|
||||
"artist_display": artist, "date_text": date_text, "classification": classification,
|
||||
"search_text": " ".join(filter(None, (source["object_number"], title_da, title_en, artist, classification))).casefold(),
|
||||
"image_iiif_id": source["image_iiif_id"], "image_thumbnail": source["image_thumbnail"],
|
||||
"image_native": source["image_native"], "image_width": source["image_width"],
|
||||
"image_height": source["image_height"], "rights_semantic": source["rights_semantic"],
|
||||
"net_width_cm": source["net_width_cm"], "net_height_cm": source["net_height_cm"],
|
||||
"quality_flags": source["quality_flags"], **feature,
|
||||
"log_net_area_cm2_percentile_01": norm["log_net_area_cm2_percentile_01"],
|
||||
"log_net_aspect_ratio": norm["log_net_aspect_ratio"],
|
||||
})
|
||||
metadata = {
|
||||
b"smk.web_catalog_version": b"1.0.0",
|
||||
b"smk.artworks_sha256": sha256_file(artworks_path).encode(),
|
||||
b"smk.weighted_features_sha256": sha256_file(features_path).encode(),
|
||||
}
|
||||
output = root / "data" / "processed" / "web-catalog-v1.parquet"
|
||||
table = pa.Table.from_pylist(rows).replace_schema_metadata(metadata)
|
||||
pq.write_table(table, output, compression="zstd", version="2.6", write_statistics=True)
|
||||
profile_path = root / "data" / "processed" / "sonification-profiles-v1.json"
|
||||
profile_path.write_text(json.dumps(profile_manifest(sha256_file(features_path)), indent=2) + "\n", encoding="utf-8")
|
||||
return {"rows": len(rows), "catalog_sha256": sha256_file(output),
|
||||
"sonification_profiles_sha256": sha256_file(profile_path)}
|
||||
31
tests/test_api.py
Normal file
31
tests/test_api.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Focused API acceptance tests over the frozen processed artifacts."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from smk_playground.api.app import app
|
||||
|
||||
|
||||
def test_health_search_and_kms8892_detail():
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/api/health")
|
||||
assert health.status_code == 200
|
||||
assert health.json()["catalog_rows"] > 0
|
||||
|
||||
search = client.get("/api/artworks", params={"q": "KMS8892", "limit": 5}).json()
|
||||
assert search["total"] >= 1
|
||||
match = next(item for item in search["items"] if item["object_number"] == "KMS8892")
|
||||
|
||||
detail = client.get(f"/api/artworks/{match['smk_entity_id']}")
|
||||
assert detail.status_code == 200
|
||||
payload = detail.json()
|
||||
assert payload["weighted_palette"]
|
||||
assert sum(item["weight"] for item in payload["weighted_palette"]) == pytest.approx(1.0)
|
||||
assert payload["sonification"]["weighted_color"]["available"]
|
||||
|
||||
|
||||
def test_not_found_and_profile_validation():
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/artworks/not-an-id").status_code == 404
|
||||
response = client.get("/api/artworks/not-an-id/similar", params={"profile": "invented"})
|
||||
assert response.status_code == 400
|
||||
62
tests/test_sonification.py
Normal file
62
tests/test_sonification.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Focused invariants for deterministic sonification profiles."""
|
||||
|
||||
import pytest
|
||||
|
||||
from smk_playground.sonification.profiles import (
|
||||
hue_pitch_class,
|
||||
palette_arpeggio,
|
||||
physical_artwork_tone,
|
||||
weighted_color_tone,
|
||||
)
|
||||
|
||||
|
||||
def feature(**updates):
|
||||
values = {
|
||||
"has_weighted_palette_v1": True,
|
||||
"centroid_hue_deg": 30.0,
|
||||
"centroid_l": 0.5,
|
||||
"centroid_chroma": 0.1,
|
||||
"dominant_color_weight": 0.4,
|
||||
"palette_spread_oklab": 0.15,
|
||||
"palette_entropy_01": 0.6,
|
||||
"hue_concentration_01": 0.7,
|
||||
}
|
||||
values.update(updates)
|
||||
return values
|
||||
|
||||
|
||||
def test_hue_bins_and_explicit_achromatic_neutral():
|
||||
assert hue_pitch_class(0)[:2] == (0, "C")
|
||||
assert hue_pitch_class(30)[:2] == (1, "C#")
|
||||
assert hue_pitch_class(359)[:2] == (11, "B")
|
||||
assert hue_pitch_class(None) == (9, "A", "achromatic_neutral")
|
||||
|
||||
|
||||
def test_weighted_tone_is_deterministic_and_bounded():
|
||||
first = weighted_color_tone(feature())
|
||||
assert first == weighted_color_tone(feature())
|
||||
assert first["note"] == "C#"
|
||||
assert 36 <= first["midi"] <= 84
|
||||
assert 0 <= first["velocity"] <= 1
|
||||
|
||||
|
||||
def test_physical_profile_requires_geometry_and_large_works_sound_lower():
|
||||
assert not physical_artwork_tone(feature(), {})["available"]
|
||||
small = physical_artwork_tone(
|
||||
feature(), {"log_net_area_cm2_percentile_01": 0.1, "log_net_aspect_ratio": -0.5}
|
||||
)
|
||||
large = physical_artwork_tone(
|
||||
feature(), {"log_net_area_cm2_percentile_01": 0.9, "log_net_aspect_ratio": 0.5}
|
||||
)
|
||||
assert large["midi"] < small["midi"]
|
||||
assert small["stereo_pan"] == pytest.approx(-large["stereo_pan"])
|
||||
|
||||
|
||||
def test_palette_arpeggio_orders_by_weight_then_rank():
|
||||
palette = [
|
||||
{"palette_rank": 2, "hex": "#aaaaaa", "weight": 0.3, "oklch_h_deg": None, "oklab_l": 0.5},
|
||||
{"palette_rank": 1, "hex": "#ff0000", "weight": 0.7, "oklch_h_deg": 0.0, "oklab_l": 0.7},
|
||||
]
|
||||
notes = palette_arpeggio(palette)["notes"]
|
||||
assert [note["hex"] for note in notes] == ["#ff0000", "#aaaaaa"]
|
||||
assert notes[1]["pitch_source"] == "achromatic_neutral"
|
||||
10
web/index.html
Normal file
10
web/index.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#15140f" />
|
||||
<title>SMK Playground — Colour into sound</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
2159
web/package-lock.json
generated
Normal file
2159
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
web/package.json
Normal file
27
web/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "smk-playground-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"vite": "latest",
|
||||
"typescript": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-router-dom": "latest",
|
||||
"lucide-react": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"@tailwindcss/vite": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
50
web/src/App.tsx
Normal file
50
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, NavLink, Route, Routes, useNavigate, useParams } from "react-router-dom";
|
||||
import { ChevronLeft, Dices, Headphones, Pause, Play, Search, Sparkles } from "lucide-react";
|
||||
import { api } from "./api";
|
||||
import { playArpeggio, playTone, stopAudio } from "./audio";
|
||||
import { ArtworkTile, Loading, PaletteBar } from "./components";
|
||||
import type { Artwork, ArtworkCard, SimilarItem } from "./types";
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return <><header className="site-header"><Link to="/" className="brand"><span className="brand-mark" />SMK Playground</Link><nav><NavLink to="/">Explore</NavLink><NavLink to="/about">About</NavLink></nav></header>{children}<footer><span>SMK open data, transformed with care.</span><span>Colour → sound is an artistic mapping.</span></footer></>;
|
||||
}
|
||||
|
||||
function Home() {
|
||||
const [query, setQuery] = useState(""); const [items, setItems] = useState<ArtworkCard[]>([]);
|
||||
const [stats, setStats] = useState<{ artwork_count: number; weighted_palette_count: number; physical_geometry_count: number }>();
|
||||
const [loading, setLoading] = useState(true); const navigate = useNavigate();
|
||||
const load = (q = "") => { setLoading(true); api.search(q).then((r) => setItems(r.items)).finally(() => setLoading(false)); };
|
||||
useEffect(() => { load(); api.stats().then(setStats); }, []);
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); load(query); };
|
||||
const surprise = () => api.random().then((art) => navigate(`/artwork/${art.smk_entity_id}`));
|
||||
return <Shell><main><section className="hero"><div><p className="kicker"><Sparkles size={14} /> An experimental colour archive</p><h1>See art.<br /><em>Hear colour.</em></h1><p className="hero-copy">Explore 27,254 open artworks from SMK through weighted palettes, perceptual colour and deterministic sound.</p><form className="search-box" onSubmit={submit}><Search /><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search title, artist, object number…" aria-label="Search artworks" /><button>Search</button></form><button className="text-button" onClick={surprise}><Dices size={17} /> Surprise me</button></div><div className="hero-orbit" aria-hidden="true"><span /><span /><span /><div className="tone-dot"><Headphones /></div></div></section>{stats && <section className="stat-strip"><div><strong>{stats.artwork_count.toLocaleString()}</strong><span>open works</span></div><div><strong>{stats.weighted_palette_count.toLocaleString()}</strong><span>weighted palettes</span></div><div><strong>{stats.physical_geometry_count.toLocaleString()}</strong><span>measured works</span></div></section>}<section className="collection"><div className="section-heading"><p className="eyebrow">The collection</p><h2>{query ? `Results for “${query}”` : "A field of colour"}</h2></div>{loading ? <Loading /> : <div className="art-grid">{items.map((item) => <ArtworkTile key={item.smk_entity_id} artwork={item} />)}</div>}</section></main></Shell>;
|
||||
}
|
||||
|
||||
const profileNames: Record<string, string> = { color: "Color", visual: "Visual character", geometry: "Physical geometry", "weighted-color": "Deep weighted color" };
|
||||
function SimilarWorks({ id }: { id: string }) {
|
||||
const [profile, setProfile] = useState("color"); const [items, setItems] = useState<SimilarItem[]>([]);
|
||||
const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState<Record<string, boolean>>({});
|
||||
const load = (next: string) => { setProfile(next); if (next === "weighted-color" && !loaded[next]) return; setLoading(true); api.similar(id, next).then((r) => { setItems(r.items); setLoaded((x) => ({ ...x, [next]: true })); }).finally(() => setLoading(false)); };
|
||||
useEffect(() => load("color"), [id]);
|
||||
return <section className="similar-section"><div className="section-heading"><p className="eyebrow">Connections</p><h2>Works nearby</h2></div><div className="tabs">{Object.entries(profileNames).map(([key, label]) => <button className={profile === key ? "active" : ""} key={key} onClick={() => load(key)}>{label}</button>)}</div>{profile === "weighted-color" && !loaded[profile] ? <div className="deep-callout"><div><strong>Deep palette comparison</strong><p>Exact transport across weighted colours takes around 12 seconds the first time, then it is cached.</p></div><button onClick={() => { setLoading(true); api.similar(id, profile).then((r) => { setItems(r.items); setLoaded((x) => ({ ...x, [profile]: true })); }).finally(() => setLoading(false)); }}>Run deep comparison</button></div> : loading ? <Loading label="Comparing visual features" /> : <div className="similar-grid">{items.map((item) => <div key={item.smk_entity_id}><ArtworkTile artwork={item} /><p className="distance">#{item.rank} · distance {item.distance.toFixed(4)}</p></div>)}</div>}</section>;
|
||||
}
|
||||
|
||||
function ToneCard({ art }: { art: Artwork }) {
|
||||
const [selected, setSelected] = useState<"weighted_color" | "physical">("weighted_color");
|
||||
const tone = art.sonification[selected];
|
||||
return <section className="tone-card"><div className="tone-top"><div><p className="eyebrow">Sonification</p><h2>The artwork as tone</h2></div>{tone.available && <div className="note-display"><strong>{tone.note}{tone.octave}</strong><span>{tone.frequency_hz?.toFixed(2)} Hz</span></div>}</div><div className="tone-tabs"><button className={selected === "weighted_color" ? "active" : ""} onClick={() => setSelected("weighted_color")}>Color tone</button><button disabled={!art.sonification.physical.available} className={selected === "physical" ? "active" : ""} onClick={() => setSelected("physical")}>Physical tone</button></div>{tone.available ? <><div className="audio-actions"><button className="play-button" onClick={() => playTone(tone)}><Play fill="currentColor" /> Play tone</button><button className="icon-button" aria-label="Stop audio" onClick={stopAudio}><Pause /></button><button className="outline-button" onClick={() => playArpeggio(art.sonification.palette_arpeggio.notes)}><Play size={16} /> Play palette</button></div><div className="why"><strong>Why this sound?</strong><p>{tone.explanation?.pitch}. {tone.explanation?.register}.</p></div></> : <p className="unavailable">{tone.reason}</p>}</section>;
|
||||
}
|
||||
|
||||
function ArtworkPage() {
|
||||
const { id = "" } = useParams(); const [art, setArt] = useState<Artwork>(); const [error, setError] = useState("");
|
||||
useEffect(() => { stopAudio(); setArt(undefined); api.artwork(id).then(setArt).catch((e) => setError(String(e))); return stopAudio; }, [id]);
|
||||
if (error) return <Shell><main className="message">{error}</main></Shell>;
|
||||
if (!art) return <Shell><Loading label="Opening artwork" /></Shell>;
|
||||
const f = art.weighted_features;
|
||||
return <Shell><main className="artwork-page"><Link className="back" to="/"><ChevronLeft /> Back to collection</Link><section className="artwork-hero"><div className="artwork-image-stage"><img src={art.image_thumbnail || art.image_native} alt={`${art.display_title} by ${art.artist_display || "unknown artist"}`} /></div><div className="artwork-info"><p className="eyebrow">{art.classification || "Artwork"} · {art.object_number}</p><h1>{art.display_title}</h1><p className="artwork-byline">{art.artist_display || "Unknown artist"}</p><p className="muted">{art.date_text}{art.net_width_cm ? ` · ${art.net_width_cm} × ${art.net_height_cm} cm` : ""}</p><div className="feature-pills"><span>L {Number(f.centroid_l).toFixed(3)}</span><span>C {Number(f.centroid_chroma).toFixed(3)}</span><span>spread {Number(f.palette_spread_oklab).toFixed(3)}</span></div><PaletteBar palette={art.weighted_palette} /></div></section><ToneCard art={art} /><SimilarWorks id={id} /><section className="provenance"><p className="eyebrow">What comes from where</p><div>{Object.entries(art.provenance).map(([key, value]) => <p key={key}><strong>{key.replaceAll("_", " ")}</strong>{value}</p>)}</div></section></main></Shell>;
|
||||
}
|
||||
|
||||
function About() { return <Shell><main className="about"><p className="kicker"><Sparkles size={14} /> About the experiment</p><h1>Colour is data.<br /><em>Sound is interpretation.</em></h1><div className="about-grid"><p>SMK Playground uses open collection metadata and images from Statens Museum for Kunst. The corpus contains public-domain works with usable image and palette data.</p><p>Palette weights come from SMK’s enrichment data. Perceptual colour features, similarity distances and sonification parameters are derived here and versioned separately.</p><p>Hue-to-pitch and size-to-register are deterministic artistic conventions—not natural laws or claims about how paintings “really” sound.</p></div></main></Shell> }
|
||||
|
||||
export default function App() { return <Routes><Route path="/" element={<Home />} /><Route path="/artwork/:id" element={<ArtworkPage />} /><Route path="/about" element={<About />} /></Routes>; }
|
||||
16
web/src/api.ts
Normal file
16
web/src/api.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Artwork, ArtworkCard, SimilarItem } from "./types";
|
||||
|
||||
async function get<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error((await response.text()) || `Request failed: ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
stats: () => get<{ artwork_count: number; weighted_palette_count: number; physical_geometry_count: number }>("/api/stats"),
|
||||
search: (q = "", offset = 0) => get<{ total: number; items: ArtworkCard[] }>(`/api/artworks?q=${encodeURIComponent(q)}&limit=24&offset=${offset}`),
|
||||
random: () => get<ArtworkCard>("/api/artworks/random"),
|
||||
artwork: (id: string) => get<Artwork>(`/api/artworks/${encodeURIComponent(id)}`),
|
||||
similar: (id: string, profile: string, limit = 10) =>
|
||||
get<{ profile: string; cached: boolean; items: SimilarItem[] }>(`/api/artworks/${encodeURIComponent(id)}/similar?profile=${profile}&limit=${limit}`),
|
||||
};
|
||||
41
web/src/audio.ts
Normal file
41
web/src/audio.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { PaletteNote, Tone } from "./types";
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
let active: AudioNode[] = [];
|
||||
|
||||
function ctx() { context ??= new AudioContext(); return context; }
|
||||
export function stopAudio() { active.forEach((node) => { try { (node as OscillatorNode).stop?.(); } catch {} }); active = []; }
|
||||
|
||||
export function playTone(tone: Tone) {
|
||||
if (!tone.available || !tone.frequency_hz) return;
|
||||
stopAudio();
|
||||
const audio = ctx();
|
||||
const now = audio.currentTime;
|
||||
const master = audio.createGain();
|
||||
const pan = audio.createStereoPanner(); pan.pan.value = tone.stereo_pan ?? 0;
|
||||
master.gain.setValueAtTime(0.0001, now);
|
||||
master.gain.exponentialRampToValueAtTime(Math.max(0.02, (tone.velocity ?? .5) * .28), now + (tone.attack ?? .1));
|
||||
master.gain.exponentialRampToValueAtTime(0.0001, now + (tone.release ?? 1.2) + (tone.attack ?? .1));
|
||||
master.connect(pan).connect(audio.destination);
|
||||
const harmonics = 1 + Math.round((tone.harmonic_richness ?? 0) * 3);
|
||||
for (let harmonic = 1; harmonic <= harmonics; harmonic++) {
|
||||
const oscillator = audio.createOscillator();
|
||||
const gain = audio.createGain(); gain.gain.value = 1 / (harmonic * harmonics);
|
||||
oscillator.type = harmonic === 1 ? "sine" : "triangle";
|
||||
oscillator.frequency.value = tone.frequency_hz * harmonic;
|
||||
oscillator.connect(gain).connect(master); oscillator.start(now); oscillator.stop(now + (tone.release ?? 1.2) + .4);
|
||||
active.push(oscillator);
|
||||
}
|
||||
}
|
||||
|
||||
export function playArpeggio(notes: PaletteNote[]) {
|
||||
stopAudio(); const audio = ctx(); let cursor = audio.currentTime;
|
||||
for (const note of notes) {
|
||||
const oscillator = audio.createOscillator(); const gain = audio.createGain();
|
||||
oscillator.type = "sine"; oscillator.frequency.value = note.frequency_hz;
|
||||
gain.gain.setValueAtTime(.0001, cursor); gain.gain.exponentialRampToValueAtTime(note.velocity * .22, cursor + .04);
|
||||
gain.gain.exponentialRampToValueAtTime(.0001, cursor + note.duration_seconds);
|
||||
oscillator.connect(gain).connect(audio.destination); oscillator.start(cursor); oscillator.stop(cursor + note.duration_seconds + .05);
|
||||
active.push(oscillator); cursor += note.duration_seconds;
|
||||
}
|
||||
}
|
||||
28
web/src/components.tsx
Normal file
28
web/src/components.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { ArrowUpRight, Music2 } from "lucide-react";
|
||||
import type { ArtworkCard } from "./types";
|
||||
|
||||
export function ArtworkTile({ artwork }: { artwork: ArtworkCard }) {
|
||||
return <Link className="group artwork-tile" to={`/artwork/${artwork.smk_entity_id}`}>
|
||||
<div className="tile-image-wrap">
|
||||
{artwork.image_thumbnail ? <img className="tile-image" src={artwork.image_thumbnail} alt={artwork.display_title} loading="lazy" /> : <div className="image-fallback"><Music2 /></div>}
|
||||
<span className="tile-arrow"><ArrowUpRight size={18} /></span>
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
<p className="eyebrow">{artwork.object_number}</p>
|
||||
<h3 className="tile-title">{artwork.display_title}</h3>
|
||||
<p className="muted line-clamp-1">{artwork.artist_display || "Unknown artist"}{artwork.date_text ? ` · ${artwork.date_text}` : ""}</p>
|
||||
</div>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
export function PaletteBar({ palette }: { palette: Array<{ hex: string; weight: number }> }) {
|
||||
return <div className="palette-wrap" aria-label="Weighted colour palette">
|
||||
<div className="palette-bar">{palette.map((color) => <div key={color.hex} style={{ background: color.hex, flexGrow: Math.max(color.weight, .025) }} title={`${color.hex} ${(color.weight * 100).toFixed(1)}%`} />)}</div>
|
||||
<div className="palette-labels">{palette.map((color) => <div key={color.hex} className="palette-label"><span className="swatch" style={{ background: color.hex }} /><span>{color.hex}</span><strong>{(color.weight * 100).toFixed(1)}%</strong></div>)}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function Loading({ label = "Loading collection" }: { label?: string }) {
|
||||
return <div className="loading"><span /><p>{label}</p></div>;
|
||||
}
|
||||
7
web/src/main.tsx
Normal file
7
web/src/main.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>);
|
||||
39
web/src/styles.css
Normal file
39
web/src/styles.css
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #201f19; background: #f3f0e8; font-synthesis: none; --ink:#201f19; --paper:#f3f0e8; --acid:#dbe85b; --line:rgba(32,31,25,.17); }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button,input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible,a:focus-visible,input:focus-visible { outline: 3px solid #756dff; outline-offset: 3px; }
|
||||
.site-header { height: 74px; padding: 0 clamp(22px,4vw,70px); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); position:relative; z-index:4; }
|
||||
.brand { font-weight:750; letter-spacing:-.02em; display:flex; align-items:center; gap:10px; }
|
||||
.brand-mark { width:17px;height:17px;border-radius:50%;background:conic-gradient(#dbe85b,#f49b67,#7987ef,#dbe85b); }
|
||||
nav { display:flex; gap:28px; font-size:14px; } nav a { opacity:.55; } nav a.active,nav a:hover { opacity:1; }
|
||||
main { min-height:calc(100vh - 150px); }
|
||||
.hero { min-height:660px; padding:90px clamp(22px,7vw,120px) 70px; display:grid; grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr); align-items:center; overflow:hidden; }
|
||||
.kicker,.eyebrow { text-transform:uppercase; letter-spacing:.12em; font-weight:700; font-size:11px; display:flex; align-items:center; gap:7px; }
|
||||
.hero h1,.about h1 { font-family:Georgia,serif; font-size:clamp(64px,8.5vw,132px); line-height:.82; letter-spacing:-.065em; margin:22px 0 34px; font-weight:400; }
|
||||
.hero h1 em,.about h1 em { font-weight:400;color:#5967db; }
|
||||
.hero-copy { max-width:590px;font-size:19px;line-height:1.55;color:#545248; }
|
||||
.search-box { margin-top:38px;max-width:650px;background:#fff;border:1px solid var(--line);border-radius:2px;padding:8px 8px 8px 18px;display:flex;align-items:center;gap:12px;box-shadow:0 12px 45px rgba(42,39,25,.08); }
|
||||
.search-box input { flex:1;border:0;outline:0;background:transparent;padding:13px 0;min-width:0; }
|
||||
.search-box button,.deep-callout button { border:0;background:var(--ink);color:#fff;padding:13px 22px;border-radius:2px;font-weight:650; }
|
||||
.text-button { border:0;background:none;padding:18px 0;display:flex;align-items:center;gap:8px;font-weight:650; }
|
||||
.hero-orbit { position:relative; width:min(38vw,500px); aspect-ratio:1; margin:auto; border:1px solid var(--line); border-radius:50%; animation:float 10s ease-in-out infinite; }
|
||||
.hero-orbit:before,.hero-orbit:after { content:"";position:absolute;border:1px solid var(--line);border-radius:50%;inset:14%; }.hero-orbit:after{inset:31%;}
|
||||
.hero-orbit>span { position:absolute;width:68px;height:68px;border-radius:50%;filter:saturate(.8);box-shadow:0 8px 28px #0002; }.hero-orbit>span:nth-child(1){background:#908d41;left:8%;top:18%}.hero-orbit>span:nth-child(2){background:#aed7ea;right:4%;top:44%;width:92px;height:92px}.hero-orbit>span:nth-child(3){background:#795025;left:20%;bottom:2%;width:48px;height:48px}.tone-dot{position:absolute;inset:42%;border-radius:50%;display:grid;place-items:center;background:var(--acid)}
|
||||
@keyframes float { 50% { transform:translateY(-12px) rotate(4deg); } }
|
||||
.stat-strip { border-block:1px solid var(--line);display:grid;grid-template-columns:repeat(3,1fr);padding:28px clamp(22px,7vw,120px); }.stat-strip div{display:flex;gap:12px;align-items:baseline;border-right:1px solid var(--line);padding-left:30px}.stat-strip div:first-child{padding-left:0}.stat-strip div:last-child{border:0}.stat-strip strong{font:42px Georgia,serif}.stat-strip span{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#6c695d}
|
||||
.collection,.similar-section { padding:90px clamp(22px,5vw,80px); }.section-heading h2,.tone-card h2 { font:48px Georgia,serif;margin:8px 0 35px;letter-spacing:-.04em; }
|
||||
.art-grid { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:42px 22px; }.tile-image-wrap { aspect-ratio:4/5;background:#e4e0d5;position:relative;overflow:hidden; }.tile-image { width:100%;height:100%;object-fit:cover;transition:transform .5s cubic-bezier(.2,.7,.2,1); }.group:hover .tile-image{transform:scale(1.025)}.tile-arrow{position:absolute;right:10px;top:10px;width:34px;height:34px;background:#f7f4eddd;display:grid;place-items:center;border-radius:50%;opacity:0;transform:translateY(5px);transition:.25s}.group:hover .tile-arrow{opacity:1;transform:none}.tile-title{font:21px Georgia,serif;margin:5px 0 5px;line-height:1.18}.muted{color:#747064;font-size:14px}.image-fallback{height:100%;display:grid;place-items:center;color:#999}
|
||||
.loading { min-height:280px;display:grid;place-items:center;align-content:center;gap:16px;color:#777 }.loading span{width:30px;height:30px;border:2px solid #bbb;border-top-color:#333;border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.artwork-page { padding:42px clamp(20px,5vw,80px) 100px; }.back{display:inline-flex;align-items:center;gap:3px;font-size:13px;margin-bottom:35px}.artwork-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(350px,.85fr);gap:clamp(45px,7vw,120px);align-items:center}.artwork-image-stage{background:#ddd8cc;min-height:600px;display:grid;place-items:center;padding:40px}.artwork-image-stage img{max-width:100%;max-height:75vh;object-fit:contain;box-shadow:0 18px 60px #342d1f2b}.artwork-info h1{font:clamp(48px,5.2vw,84px)/.95 Georgia,serif;letter-spacing:-.055em;margin:20px 0}.artwork-byline{font-size:20px}.feature-pills{display:flex;flex-wrap:wrap;gap:7px;margin:25px 0}.feature-pills span{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:20px;padding:7px 10px}
|
||||
.palette-wrap{margin-top:35px}.palette-bar{height:74px;display:flex;box-shadow:0 8px 25px #0001}.palette-bar div{min-width:3px}.palette-labels{display:grid;grid-template-columns:repeat(2,1fr);gap:8px 18px;margin-top:18px}.palette-label{display:grid;grid-template-columns:13px 1fr auto;gap:8px;align-items:center;font:11px ui-monospace,monospace}.swatch{width:11px;height:11px;border-radius:50%;border:1px solid #0002}
|
||||
.tone-card{margin:100px auto 40px;max-width:1180px;background:#171812;color:#f4f1e8;padding:clamp(28px,5vw,70px);border-radius:3px}.tone-top{display:flex;justify-content:space-between;align-items:start}.tone-card .eyebrow{color:var(--acid)}.note-display{text-align:right}.note-display strong{display:block;font:76px Georgia,serif;color:var(--acid)}.note-display span{font:12px ui-monospace,monospace;color:#aaa}.tone-tabs,.tabs{display:flex;gap:7px;flex-wrap:wrap}.tone-tabs button,.tabs button{border:1px solid currentColor;background:transparent;color:inherit;padding:9px 13px;border-radius:30px;font-size:12px;opacity:.55}.tone-tabs button.active,.tabs button.active{opacity:1;background:var(--acid);color:#15150f;border-color:var(--acid)}.tone-tabs button:disabled{opacity:.2;cursor:not-allowed}.audio-actions{display:flex;align-items:center;gap:10px;margin:35px 0}.play-button,.outline-button,.icon-button{border:0;padding:14px 20px;display:flex;align-items:center;gap:8px;border-radius:2px}.play-button{background:var(--acid);color:#111;font-weight:750}.outline-button,.icon-button{background:transparent;color:#fff;border:1px solid #ffffff55}.icon-button{padding:13px}.why{border-top:1px solid #ffffff2b;padding-top:24px}.why p{color:#bcbdb3}.unavailable{color:#aaa}
|
||||
.similar-section{padding-inline:0}.tabs{margin-bottom:30px}.tabs button{color:var(--ink);border-color:var(--line)}.tabs button.active{background:var(--ink);color:#fff;border-color:var(--ink)}.similar-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:18px}.distance{font:11px ui-monospace,monospace;color:#777}.deep-callout{padding:30px;border:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}.deep-callout p{color:#777;margin-bottom:0}.provenance{margin-top:80px;border-top:1px solid var(--line);padding-top:30px}.provenance>div{display:grid;grid-template-columns:repeat(4,1fr);gap:25px}.provenance p{font-size:13px;color:#777}.provenance strong{display:block;color:#222;text-transform:capitalize;margin-bottom:6px}
|
||||
.about{padding:110px clamp(22px,8vw,140px)}.about h1{font-size:clamp(62px,8vw,120px)}.about-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:45px;max-width:1100px;font-size:18px;line-height:1.7}.message{padding:100px}footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;padding:30px clamp(22px,4vw,70px);font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:#777}
|
||||
@media(max-width:1000px){.art-grid{grid-template-columns:repeat(3,1fr)}.similar-grid{grid-template-columns:repeat(3,1fr)}.artwork-hero{grid-template-columns:1fr}.artwork-image-stage{min-height:450px}.hero{grid-template-columns:1fr}.hero-orbit{display:none}.provenance>div{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:650px){.site-header{height:62px}.hero{padding-top:60px;min-height:600px}.hero h1{font-size:60px}.art-grid{grid-template-columns:repeat(2,1fr);gap:30px 12px}.stat-strip{grid-template-columns:1fr;padding-block:18px}.stat-strip div{border-right:0;border-bottom:1px solid var(--line);padding:12px 0}.similar-grid{grid-template-columns:repeat(2,1fr)}.palette-labels{grid-template-columns:1fr}.tone-top{display:block}.note-display{text-align:left}.provenance>div,.about-grid{grid-template-columns:1fr}.deep-callout{align-items:flex-start;gap:18px;flex-direction:column}footer{flex-direction:column;gap:8px}.artwork-image-stage{padding:20px;min-height:360px}.collection{padding-top:60px}}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
29
web/src/types.ts
Normal file
29
web/src/types.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export type ArtworkCard = {
|
||||
smk_entity_id: string; object_number: string; display_title: string;
|
||||
artist_display?: string; date_text?: string; classification?: string;
|
||||
image_thumbnail?: string; centroid_chroma?: number; dominant_color_hex?: string;
|
||||
};
|
||||
|
||||
export type Tone = {
|
||||
profile: string; version: string; available: boolean; reason?: string; note?: string;
|
||||
octave?: number; midi?: number; frequency_hz?: number; velocity?: number; attack?: number;
|
||||
release?: number; harmonic_richness?: number; noise_amount?: number; tonal_purity?: number;
|
||||
stereo_pan?: number; explanation?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type PaletteNote = { hex: string; weight: number; note: string; octave: number;
|
||||
frequency_hz: number; velocity: number; duration_seconds: number };
|
||||
|
||||
export type Artwork = ArtworkCard & {
|
||||
title_da?: string; title_en?: string; image_native?: string; image_width?: number;
|
||||
image_height?: number; rights_semantic?: string; net_width_cm?: number; net_height_cm?: number;
|
||||
quality_flags: string[];
|
||||
weighted_palette: Array<{ palette_rank: number; hex: string; token_count: number; weight: number;
|
||||
oklab_l: number; oklch_c: number; oklch_h_deg?: number }>;
|
||||
weighted_features: Record<string, number | string | null>;
|
||||
sonification: { weighted_color: Tone; physical: Tone;
|
||||
palette_arpeggio: { available: boolean; notes: PaletteNote[]; explanation: string } };
|
||||
provenance: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SimilarItem = ArtworkCard & { rank: number; distance: number; active_components: string[] };
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
20
web/tsconfig.app.json
Normal file
20
web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
web/tsconfig.json
Normal file
4
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
8
web/vite.config.ts
Normal file
8
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: { proxy: { "/api": "http://localhost:8000" } },
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue