Speed up timeline load with portrait thumbs, bootstrap API, and caching.

Add catalog bootstrap endpoint, portrait thumbnail pipeline, lazy queued timeline images, gzip compression, and 3D texture throttling with code-split VirtualGallery.
This commit is contained in:
Danila Khodjaef
2026-07-06 14:27:29 +03:00
parent bdddadc4d6
commit 99b8559607
117 changed files with 646 additions and 171 deletions
+44 -2
View File
@@ -86,6 +86,48 @@ Curator mutations are recorded in `curator_audit_log` (see [DB_structure.md](DB_
---
## `GET /api/catalog/bootstrap`
**Preferred for timeline first paint.** Returns bounds, eras, movements, and slim artist rows in a single response (replaces the separate `bounds` + `timeline` + `artists?timeline=1` waterfall).
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `start` | int | bounds `min_year` | Window start year |
| `end` | int | bounds `max_year` | Window end year |
**Response**
```json
{
"bounds": { "min_year": -800, "max_year": 2100 },
"eras": [ ],
"movements": [ ],
"artists": [
{
"id": 1,
"name": "Claude Monet",
"birth_year": 1840,
"death_year": 1926,
"movement_id": 12,
"portrait_path": "portraits/Claude_Monet.jpg",
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg",
"wikipedia_title": "Claude Monet",
"century": 19,
"movement_name": "Impressionism",
"movement_color": "#6B8E9F"
}
]
}
```
**Caching:** `Cache-Control: public, max-age=300` with `ETag` (304 when catalog row counts unchanged).
The React home page loads this endpoint **once** on mount. Pan and zoom filter movements and portraits **client-side** — no refetch per view change.
---
## `GET /api/bounds`
Returns the overall timeline year range used to initialise the zoomable timeline.
@@ -138,11 +180,11 @@ Artists for timeline portraits and the movement flow diagram.
| `start` | int | Only artists alive after this year |
| `end` | int | Only artists born before this year |
| `movement_id` | int | Filter by movement |
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_full` and other heavy fields) |
| `timeline` | bool | When `1` or `true`, return a lightweight row set for the home-page timeline (omits `bio_short`, `bio_full`; includes `portrait_thumb_path`) |
**Response** — array of artist objects with joined `movement_name` and `movement_color`.
The React home page loads the timeline catalog **once** on mount via `GET /api/bounds`, `GET /api/timeline?start=…&end=…` (full range), and `GET /api/artists?timeline=1`. Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
The React home page loads the timeline catalog **once** on mount via `GET /api/catalog/bootstrap` (or legacy: `GET /api/bounds` + `GET /api/timeline` + `GET /api/artists?timeline=1`). Pan and zoom filter movements and portraits **client-side** — no refetch per view change. Rapid pan/zoom is batched with `createViewChangeScheduler()` (one React update per animation frame).
---
+8 -3
View File
@@ -12,8 +12,10 @@ How catalog content, biographies, and artwork files enter the system.
```text
data/images/
├── portraits/ # Artist headshots
── Claude_Monet.jpg
├── portraits/ # Artist headshots (display ~900px wide)
── Claude_Monet.jpg
│ └── thumbs/ # Timeline thumbnails (~256px)
│ └── Claude_Monet_thumb.jpg
└── paintings/
├── Claude_Monet_Water_Lilies.jpg
└── thumbs/
@@ -47,7 +49,8 @@ Promote dev → prod files: `npm run images:sync-to-prod` (after `net use \\192.
| `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk |
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run images:sync-*` | Robocopy via SMB `\\192.168.10.122\Gallery` |
| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` |
| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
| `regenerate-portrait-thumbs.js` | `npm run regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
| `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
| `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows |
| `migrate-checkup-flags.js` | `npm run migrate:checkup-flags` | Add `checkup_checked` / `checkup_fixed` columns |
@@ -154,6 +157,8 @@ Typical result on a full clone: ~1,000+ paintings linked from ~1,000 on-disk fil
## Artist portraits
Timeline movement flow loads **`portrait_thumb_path`** (~256px JPEG under `portraits/thumbs/{Artist}_thumb.jpg`) when available; biography and 3D exit navigation use full `portrait_path`. After adding portraits, run `npm run regenerate-portrait-thumbs` to backfill thumbs on dev.
`npm run fetch-artist-images` runs `scripts/fetch-artist-images.js`:
1. For each artist, checks `data/images/portraits/{Artist}.jpg` (or other extensions) and sets `portrait_path` when a local file exists.
+25 -2
View File
@@ -1,5 +1,6 @@
import type {
TimelineData,
CatalogBootstrap,
YearBounds,
Artist,
ArtistDetail,
@@ -62,13 +63,25 @@ export function portraitUrl(path: string | null | undefined, revision?: number):
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
}
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */
export function portraitThumbUrl(
artist: {
portrait_thumb_path?: string | null;
portrait_path?: string | null;
},
revision?: number
): string {
const path = artist.portrait_thumb_path || artist.portrait_path;
return portraitUrl(path, revision);
}
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
export function galleryImageUrl(painting: {
thumbnail_path?: string | null;
image_path?: string | null;
}): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
if (painting.image_path) return `/images/${painting.image_path}`;
return null;
}
@@ -202,6 +215,14 @@ export interface PaintingCheckupData {
export const api = {
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
getCatalogBootstrap: (start?: number, end?: number) => {
const params = new URLSearchParams();
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
const qs = params.toString();
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
},
getTimeline: (start: number, end: number) =>
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
@@ -345,6 +366,8 @@ export const api = {
}
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
preloadArtistImages,
};
export function debugImageProxyUrl(
+127 -54
View File
@@ -1,6 +1,7 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState, memo } from 'react';
import type { ArtMovement, Artist } from '../types';
import { portraitUrl } from '../api/client';
import { portraitThumbUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import './MovementBands.css';
@@ -639,6 +640,108 @@ function branchTargetOnChild(layout: MovementLayout): { x: number; y: number } {
return { x, y: yOnStream(layout, x) };
}
const MovementArtistPortrait = memo(function MovementArtistPortrait({
artist,
portraitRevision,
lineLeft,
lineWidth,
portraitX,
y,
layoutHeight,
color,
colorIndex,
isHovered,
birthLabel,
deathLabel,
movementName,
viewStart,
viewEnd,
onArtistClick,
onArtistHover,
onHoverStart,
onHoverEnd,
}: {
artist: Artist;
portraitRevision?: number;
lineLeft: number;
lineWidth: number;
portraitX: number;
y: number;
layoutHeight: number;
color: string;
colorIndex: number;
isHovered: boolean;
birthLabel: string | number;
deathLabel: string | number;
movementName: string;
viewStart: number;
viewEnd: number;
onArtistClick: (id: number) => void;
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
onHoverStart: () => void;
onHoverEnd: () => void;
}) {
const thumbSrc = portraitThumbUrl(artist, portraitRevision);
const queuedSrc = useQueuedImageSrc(thumbSrc);
return (
<div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}>
<div
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + colorIndex,
['--lifespan-color' as string]: color,
}}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
</div>
<button
type="button"
className="artist-portrait"
style={{
left: `${portraitX}%`,
top: `${(y / layoutHeight) * 100}%`,
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => {
onHoverStart();
onArtistHover?.({
birthYear: artist.birth_year ?? viewStart,
deathYear: artist.death_year ?? viewEnd,
color,
});
}}
onMouseLeave={() => {
onHoverEnd();
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${movementName}`}
>
{queuedSrc ? (
<img
src={queuedSrc}
alt={artist.name}
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
) : (
<img src="/placeholder-portrait.svg" alt="" aria-hidden />
)}
<span className="artist-name">{artist.name}</span>
</button>
</div>
);
});
export default function MovementBands({
movements,
artists,
@@ -674,7 +777,7 @@ export default function MovementBands({
interactionTimer.current = window.setTimeout(() => {
interactionTimer.current = null;
setInteracting(false);
}, 120);
}, 200);
}, []);
useEffect(() => () => {
@@ -1216,60 +1319,30 @@ export default function MovementBands({
const birthLabel = artist.birth_year != null ? artist.birth_year : '?';
const deathLabel = artist.death_year != null ? artist.death_year : '?';
const artistKey = `${layout.movement.id}-${artist.id}`;
const isHovered = hoveredArtistKey === artistKey;
return (
<div
<MovementArtistPortrait
key={artistKey}
className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}
>
<div
className={`artist-lifespan${isHovered ? ' artist-lifespan-active' : ''}`}
style={{
left: `${lineLeft}%`,
width: `${lineWidth}%`,
top: `${(y / layoutHeight) * 100}%`,
zIndex: isHovered ? 12 : 4 + colorIndex,
['--lifespan-color' as string]: color,
}}
>
{isHovered && <span className="artist-lifespan-line" aria-hidden />}
</div>
<button
type="button"
className="artist-portrait"
style={{
left: `${portraitX}%`,
top: `${(y / layoutHeight) * 100}%`,
borderColor: color,
zIndex: isHovered ? 13 : 5 + colorIndex,
}}
onClick={() => onArtistClick(artist.id)}
onMouseEnter={() => {
setHoveredArtistKey(artistKey);
onArtistHover?.({
birthYear: artist.birth_year ?? viewStart,
deathYear: artist.death_year ?? viewEnd,
color,
});
}}
onMouseLeave={() => {
setHoveredArtistKey(null);
onArtistHover?.(null);
}}
onMouseDown={(e) => e.stopPropagation()}
title={`${artist.name} (${birthLabel}${deathLabel}) · ${layout.movement.name}`}
>
<img
src={portraitUrl(artist.portrait_path, portraitRevisions?.[artist.id])}
alt={artist.name}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
<span className="artist-name">{artist.name}</span>
</button>
</div>
artist={artist}
portraitRevision={portraitRevisions?.[artist.id]}
lineLeft={lineLeft}
lineWidth={lineWidth}
portraitX={portraitX}
y={y}
layoutHeight={layoutHeight}
color={color}
colorIndex={colorIndex}
isHovered={hoveredArtistKey === artistKey}
birthLabel={birthLabel}
deathLabel={deathLabel}
movementName={layout.movement.name}
viewStart={viewStart}
viewEnd={viewEnd}
onArtistClick={onArtistClick}
onArtistHover={onArtistHover}
onHoverStart={() => setHoveredArtistKey(artistKey)}
onHoverEnd={() => setHoveredArtistKey(null)}
/>
);
})}
</div>
+4
View File
@@ -130,6 +130,8 @@ function InfluenceCard({
<img
src={imageUrl(inf.artist_portrait)}
alt={artistName}
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
@@ -164,6 +166,8 @@ function InfluenceCard({
<img
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
alt={inf.title || 'Painting'}
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
+16 -38
View File
@@ -10,7 +10,8 @@ import type {
ArtistNavigation,
MovementArtistGroup,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { loadTextureQueued } from '../utils/textureLoadQueue';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
@@ -561,11 +562,9 @@ function usePaintingTexture(url: string | null) {
setFailed(false);
let disposed = false;
let loaded: THREE.Texture | null = null;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
loader.load(
url,
(tex) => {
loadTextureQueued(url)
.then((tex) => {
if (disposed) {
tex.dispose();
return;
@@ -580,12 +579,10 @@ function usePaintingTexture(url: string | null) {
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
setTexture(tex);
},
undefined,
() => {
})
.catch(() => {
if (!disposed) setFailed(true);
}
);
});
return () => {
disposed = true;
@@ -1675,32 +1672,13 @@ export default function VirtualGallery(props: Props) {
return;
}
let cancelled = false;
const artistId = props.data.artist.id;
(async () => {
try {
setSyncStatus('Syncing images…');
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
await Promise.race([preloadArtistImages(artistId), timeout]);
const fresh = await api.getArtist(artistId);
if (!cancelled) {
setPaintings(fresh.paintings);
setPeriods(fresh.periods);
const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length;
setSyncStatus(
withImg < fresh.paintings.length
? `${withImg} of ${fresh.paintings.length} works have images`
: ''
);
}
} catch {
if (!cancelled) setSyncStatus('');
}
})();
return () => {
cancelled = true;
};
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
setSyncStatus(
withImg < initialPaintings.length
? `${withImg} of ${initialPaintings.length} works have images`
: ''
);
}, [hallKey, props.mode, initialPaintings]);
const interiorStyle = useMemo(
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
@@ -2017,7 +1995,7 @@ export default function VirtualGallery(props: Props) {
intensity={interiorStyle ? 0.85 : 0.65}
color={interiorStyle?.sunLight ?? '#fff8f0'}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-mapSize={[1024, 1024]}
/>
<Suspense fallback={null}>
<Environment
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
const MAX_CONCURRENT = 6;
const queue: Array<() => void> = [];
let inFlight = 0;
function pumpQueue() {
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
const next = queue.shift();
if (next) next();
}
}
function enqueueLoad(run: () => void) {
return new Promise<void>((resolve) => {
const task = () => {
inFlight++;
run();
resolve();
inFlight--;
pumpQueue();
};
queue.push(task);
pumpQueue();
});
}
/**
* Limits parallel image URL activation so hundreds of timeline portraits
* do not saturate the browser connection pool at once.
*/
export function useQueuedImageSrc(src: string | null | undefined): string | undefined {
const [activeSrc, setActiveSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!src) {
setActiveSrc(undefined);
return;
}
let cancelled = false;
setActiveSrc(undefined);
enqueueLoad(() => {
if (!cancelled) setActiveSrc(src);
});
return () => {
cancelled = true;
};
}, [src]);
return activeSrc;
}
+12 -14
View File
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
import VirtualGallery from '../components/VirtualGallery';
const VirtualGallery = lazy(() => import('../components/VirtualGallery'));
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
@@ -144,23 +144,16 @@ export default function HomePage() {
(async () => {
try {
setLoading(true);
const b = await api.getBounds();
const min = b.min_year ?? -800;
const max = b.max_year ?? 2025;
const catalog = await api.getCatalogBootstrap();
if (cancelled) return;
const min = catalog.bounds.min_year ?? -800;
const max = catalog.bounds.max_year ?? 2025;
setBounds({ min, max });
setViewStart(min);
setViewEnd(max);
const [timeline, artistList] = await Promise.all([
api.getTimeline(min, max),
api.getTimelineArtists(),
]);
if (cancelled) return;
setTimelineData(timeline);
setArtists(artistList);
setTimelineData({ eras: catalog.eras, movements: catalog.movements });
setArtists(catalog.artists);
setError(null);
} catch {
if (!cancelled) setError('Could not load gallery data. Is the server running?');
@@ -407,6 +400,7 @@ export default function HomePage() {
const handleArtistClick = async (artistId: number) => {
try {
await api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId);
openArtistGallery(artistId, data);
} catch {
@@ -605,6 +599,7 @@ export default function HomePage() {
aria-hidden={!galleryActive}
>
{displayGallery.kind === 'artist' ? (
<Suspense fallback={null}>
<VirtualGallery
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
mode="artist"
@@ -622,7 +617,9 @@ export default function HomePage() {
})
}
/>
</Suspense>
) : (
<Suspense fallback={null}>
<VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
mode="movement"
@@ -632,6 +629,7 @@ export default function HomePage() {
onPaintingClick={handlePaintingClick}
onBack={() => setView({ type: 'timeline' })}
/>
</Suspense>
)}
</div>
)}
+8 -2
View File
@@ -31,8 +31,9 @@ export interface Artist {
movement_name?: string;
movement_color?: string;
portrait_path: string | null;
bio_short: string;
bio_full: string;
portrait_thumb_path?: string | null;
bio_short?: string;
bio_full?: string;
wikipedia_title: string;
century: number;
checkup_checked?: boolean;
@@ -133,6 +134,11 @@ export interface TimelineData {
movements: ArtMovement[];
}
export interface CatalogBootstrap extends TimelineData {
bounds: YearBounds;
artists: Artist[];
}
export interface YearBounds {
min_year: number;
max_year: number;
+39
View File
@@ -0,0 +1,39 @@
import * as THREE from 'three';
const MAX_CONCURRENT = 8;
const queue: Array<() => void> = [];
let inFlight = 0;
function pumpQueue() {
while (inFlight < MAX_CONCURRENT && queue.length > 0) {
const next = queue.shift();
if (next) next();
}
}
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
export function loadTextureQueued(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
const task = () => {
inFlight++;
loader.load(
url,
(tex) => {
inFlight--;
pumpQueue();
resolve(tex);
},
undefined,
(err) => {
inFlight--;
pumpQueue();
reject(err);
}
);
};
queue.push(task);
pumpQueue();
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Some files were not shown because too many files have changed in this diff Show More