Enhance article photo handling and API documentation.

- Updated API documentation to reflect changes in article data structure.
- Improved ArticlePhoto component to support dynamic sizing and object fit options.
- Added performance logging for image upload in ArticlePhotosDialog and ArticlePhotoTile components.
- Enhanced ArticleTile to conditionally render photos and adjusted styling for better layout.
- Optimized image preparation process with configurable AVIF encoding speed.
This commit is contained in:
seb
2026-08-01 04:32:49 +02:00
parent 701487f525
commit 09fb1da335
10 changed files with 368 additions and 36 deletions

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* Benchmark AVIF encode options (+ JPEG/WebP reference) on a real fixture.
* Usage: node scripts/bench-avif-encode.mjs [path-to-avif]
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import decodeAvif, { init as initAvifDecode } from '@jsquash/avif/decode.js';
import encodeAvif, { init as initAvifEncode } from '@jsquash/avif/encode.js';
import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js';
import encodeWebp, { init as initWebpEncode } from '@jsquash/webp/encode.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const RUNS = 3;
const fixturePath = resolve(
process.argv[2] ?? resolve(root, 'tmp/bench-source.avif'),
);
async function compileWasm(relPath) {
const bytes = await readFile(resolve(root, relPath));
return WebAssembly.compile(bytes);
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
async function timeEncode(label, fn) {
const times = [];
let bytes = 0;
for (let i = 0; i < RUNS; i += 1) {
const start = performance.now();
const buf = await fn();
times.push(performance.now() - start);
bytes = buf.byteLength ?? buf.length;
}
return {
label,
encodeMs: Math.round(median(times)),
outputBytes: bytes,
};
}
console.log('init wasm…');
await initAvifDecode(await compileWasm('node_modules/@jsquash/avif/codec/dec/avif_dec.wasm'));
await initAvifEncode(await compileWasm('node_modules/@jsquash/avif/codec/enc/avif_enc.wasm'));
await initJpegEncode(await compileWasm('node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm'));
await initWebpEncode(await compileWasm('node_modules/@jsquash/webp/codec/enc/webp_enc.wasm'));
const raw = await readFile(fixturePath);
console.log(`fixture: ${fixturePath} (${raw.byteLength} bytes)`);
const decodeStarted = performance.now();
const imageData = await decodeAvif(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
const decodeMs = Math.round(performance.now() - decodeStarted);
console.log(`decode: ${decodeMs}ms → ${imageData.width}×${imageData.height}`);
// Warmup
await encodeAvif(imageData, { quality: 80, speed: 10 });
const variants = [
{ label: 'avif q80/s6 (baseline)', fn: () => encodeAvif(imageData, { quality: 80, speed: 6 }) },
{ label: 'avif q80/s8', fn: () => encodeAvif(imageData, { quality: 80, speed: 8 }) },
{ label: 'avif q80/s10', fn: () => encodeAvif(imageData, { quality: 80, speed: 10 }) },
{ label: 'avif q70/s8', fn: () => encodeAvif(imageData, { quality: 70, speed: 8 }) },
{ label: 'avif q70/s10', fn: () => encodeAvif(imageData, { quality: 70, speed: 10 }) },
{ label: 'jpeg q80 (ref)', fn: () => encodeJpeg(imageData, { quality: 80 }) },
{ label: 'webp q80 (ref)', fn: () => encodeWebp(imageData, { quality: 80 }) },
];
const rows = [];
for (const variant of variants) {
process.stdout.write(`… ${variant.label}\n`);
rows.push(await timeEncode(variant.label, variant.fn));
}
const baseline = rows.find((row) => row.label.includes('baseline'));
console.log('\nresults (median of 3 runs):');
console.log(
'label'.padEnd(28),
'encodeMs'.padStart(10),
'bytes'.padStart(10),
'vsBaseMs'.padStart(10),
'vsBaseSz'.padStart(10),
);
for (const row of rows) {
const vsMs = baseline ? `${(row.encodeMs / baseline.encodeMs).toFixed(2)}x` : '-';
const vsSz = baseline ? `${(row.outputBytes / baseline.outputBytes).toFixed(2)}x` : '-';
console.log(
row.label.padEnd(28),
String(row.encodeMs).padStart(10),
String(row.outputBytes).padStart(10),
vsMs.padStart(10),
vsSz.padStart(10),
);
}
console.log(JSON.stringify({
fixturePath,
decodeMs,
size: `${imageData.width}×${imageData.height}`,
rows,
}, null, 2));

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Export pic.fullbinary by hash using dashback/.env MSSQL credentials.
* Usage: node scripts/export-pic-by-hash.mjs <fullHash> [outPath]
*/
import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const dotenv = require('../../dashback/node_modules/dotenv');
const sql = require('../../dashback/node_modules/mssql');
const __dirname = dirname(fileURLToPath(import.meta.url));
const dashfrontRoot = resolve(__dirname, '..');
const dashbackEnv = resolve(dashfrontRoot, '../dashback/.env');
dotenv.config({ path: dashbackEnv, quiet: true });
const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/;
const hash = process.argv[2]?.trim();
const outPath = resolve(
process.argv[3] ?? resolve(dashfrontRoot, 'tmp/bench-source.avif'),
);
if (!hash || !FULL_HASH_RE.test(hash)) {
console.error('Usage: node scripts/export-pic-by-hash.mjs <64-char-hex-fullHash> [outPath]');
process.exit(1);
}
const config = {
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
port: Number(process.env.MSSQL_PORT) || 1433,
options: {
encrypt: process.env.MSSQL_ENCRYPT === 'true',
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE !== 'false',
},
};
if (!config.server || !config.database || !config.user || !config.password) {
console.error('Missing MSSQL_* in dashback/.env');
process.exit(1);
}
const pool = await sql.connect(config);
try {
const result = await pool
.request()
.input('hash', sql.VarChar(64), hash.toLowerCase())
.query(`
SELECT w, h, size, fullbinary
FROM pic
WHERE hash = @hash
`);
const row = result.recordset[0];
if (!row?.fullbinary) {
console.error(`No pic found for hash ${hash}`);
process.exit(1);
}
const buffer = Buffer.isBuffer(row.fullbinary)
? row.fullbinary
: Buffer.from(row.fullbinary);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, buffer);
console.log(JSON.stringify({
outPath,
hash: hash.toLowerCase(),
w: row.w,
h: row.h,
size: row.size,
bytes: buffer.length,
}, null, 2));
} finally {
await pool.close();
}

View File

@@ -151,7 +151,7 @@ export const article = {
/**
* GET /api/article/:sku → { article }
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
* (Flat net/taxRate/thumb* only on category tree / getCategoryArticles.)
* (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.)
*/
getBySku(sku) {
return http(`/api/article/${encodeURIComponent(sku)}`);
@@ -243,7 +243,10 @@ export const category = {
return http('/api/category/tree');
},
/** GET /api/category/:id/articles → { articles: [...] } */
/**
* GET /api/category/:id/articles → { articles: [...] }
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, …
*/
getArticles(categoryId) {
return http(`/api/category/${categoryId}/articles`);
},

View File

@@ -26,7 +26,13 @@ class ArticlePhoto extends Component {
}
render() {
const { photo, size = 200 } = this.props;
const {
photo,
size = 200,
fill = false,
objectFit = fill ? 'cover' : 'contain',
sx,
} = this.props;
const { thumbLoaded } = this.state;
const fullHash = photo?.fullHash;
const hasPreview = photo?.thumbHash != null;
@@ -39,28 +45,32 @@ class ArticlePhoto extends Component {
<Box
sx={{
position: 'relative',
width: size,
height: size,
maxWidth: '100%',
...(fill
? { width: '100%', height: '100%' }
: { width: size, height: size, maxWidth: '100%', flexShrink: 0 }),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 2,
borderRadius: fill ? 0 : 2,
overflow: 'hidden',
bgcolor: 'rgba(0, 0, 0, 0.35)',
flexShrink: 0,
...sx,
}}
>
{hasPreview && !thumbLoaded ? (
<Box
sx={{
position: 'absolute',
maxWidth: '100%',
maxHeight: '100%',
aspectRatio,
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
}}
sx={
fill || objectFit === 'cover'
? { position: 'absolute', inset: 0 }
: {
position: 'absolute',
maxWidth: '100%',
maxHeight: '100%',
aspectRatio,
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
}
}
>
<ThumbHashImage thumbHash={photo.thumbHash} />
</Box>
@@ -72,15 +82,25 @@ class ArticlePhoto extends Component {
alt=""
onLoad={this.handleThumbLoad}
onError={this.handleThumbError}
sx={{
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
opacity: thumbLoaded ? 1 : 0,
}}
sx={
fill || objectFit === 'cover'
? {
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: thumbLoaded ? 1 : 0,
}
: {
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
opacity: thumbLoaded ? 1 : 0,
}
}
/>
) : null}
</Box>

View File

@@ -28,7 +28,13 @@ class ArticlePhotoTile extends Component {
this.setState({ busy: true });
try {
const file = await prepareArticlePhoto(rawBlob);
const uploadStarted = performance.now();
const res = await article.uploadImage(articleData.sku, file);
console.log('[article-photo] upload', {
uploadMs: Math.round(performance.now() - uploadStarted),
bytes: file.size,
sku: articleData.sku,
});
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));

View File

@@ -61,7 +61,13 @@ class ArticlePhotosDialog extends Component {
this.setState({ busy: true });
try {
const file = await prepareArticlePhoto(rawBlob);
const uploadStarted = performance.now();
const res = await article.uploadImage(articleData.sku, file);
console.log('[article-photo] upload', {
uploadMs: Math.round(performance.now() - uploadStarted),
bytes: file.size,
sku: articleData.sku,
});
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
@@ -136,11 +142,12 @@ class ArticlePhotosDialog extends Component {
key={photo.fullHash}
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
>
<ArticlePhoto photo={photo} size={200} />
<ArticlePhoto photo={photo} size={200} sx={{ position: 'absolute', inset: 0 }} />
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 1,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',

View File

@@ -1,10 +1,18 @@
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import { Tile } from './Tile';
import { Tile, tileBaseSx } from './Tile';
import { formatPrice } from '../../utils/price';
import { articleSku } from '../../pages/artikel/articleRoute';
import ArticlePhoto from '../article/ArticlePhoto';
export class ArticleTile extends Tile {
hasPhoto() {
const { article } = this.props;
return article?.thumbHash != null || article?.fullHash != null;
}
renderIcon() {
return <Inventory2OutlinedIcon color="action" sx={{ fontSize: 36 }} />;
}
@@ -28,4 +36,70 @@ export class ArticleTile extends Tile {
</>
);
}
render() {
if (!this.hasPhoto()) {
return super.render();
}
const { article, label, sx, disabled } = this.props;
const sku = articleSku(article);
return (
<ButtonBase
component="button"
type="button"
disabled={disabled}
onClick={this.handleClick}
sx={{
...tileBaseSx,
p: 0,
gap: 0,
position: 'relative',
...(disabled ? { opacity: 0.5, pointerEvents: 'none' } : {}),
...sx,
}}
>
<Box sx={{ position: 'absolute', inset: 0 }}>
<ArticlePhoto
fill
photo={{
fullHash: article.fullHash,
thumbW: article.thumbW,
thumbH: article.thumbH,
thumbHash: article.thumbHash,
}}
/>
</Box>
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
px: 1,
py: 0.75,
background: 'linear-gradient(transparent, rgba(0,0,0,0.65))',
textAlign: 'left',
}}
>
{label ? (
<Typography
variant="body2"
fontWeight={600}
noWrap
sx={{ color: '#fff', lineHeight: 1.2 }}
>
{label}
</Typography>
) : null}
{sku ? (
<Typography variant="caption" noWrap sx={{ color: 'rgba(255,255,255,0.85)' }}>
{sku}
</Typography>
) : null}
</Box>
</ButtonBase>
);
}
}

View File

@@ -4,6 +4,9 @@ import Typography from '@mui/material/Typography';
export const TILE_SIZE = 140;
/** Matches panel tile rounding (theme.shape.borderRadius × 4 → typically 16px). */
export const TILE_BORDER_RADIUS = 4;
export const tileBaseSx = {
aspectRatio: '1 / 1',
width: '100%',
@@ -16,10 +19,15 @@ export const tileBaseSx = {
cursor: 'pointer',
userSelect: 'none',
textAlign: 'center',
transition: 'border-color 0.15s, background-color 0.15s',
border: 'none',
borderRadius: TILE_BORDER_RADIUS,
overflow: 'hidden',
bgcolor: 'background.paper',
boxShadow: 3,
transition: 'box-shadow 0.15s, background-color 0.15s',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
boxShadow: 6,
},
};
@@ -62,11 +70,6 @@ export class Tile extends Component {
onClick={this.handleClick}
sx={{
...tileBaseSx,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
borderRadius: 1,
overflow: 'hidden',
...(disabled
? { opacity: 0.5, pointerEvents: 'none' }
: {}),

View File

@@ -1,6 +1,8 @@
const MAX_EDGE = 1000;
const AVIF_MIME = 'image/avif';
const AVIF_QUALITY = 80;
/** libaom speed 0–10; higher = faster encode, slightly larger files. Bench: s10 ~8× vs s6, +5% bytes. */
const AVIF_SPEED = 9;
let encodeAvif;
let resizeImage;
@@ -61,21 +63,48 @@ export async function prepareArticlePhoto(input) {
throw new Error('Kein Bild.');
}
const started = performance.now();
const decodeStarted = performance.now();
const originalImageData = await decodeToImageData(input);
const decodeMs = performance.now() - decodeStarted;
const { width, height } = scaledDimensions(
originalImageData.width,
originalImageData.height,
);
const resizeStarted = performance.now();
const resize = await getResizer();
const imageData = await resize(originalImageData, { width, height });
const resizeMs = performance.now() - resizeStarted;
const encodeStarted = performance.now();
const encode = await getAvifEncoder();
const avifBuffer = await encode(imageData, { quality: AVIF_QUALITY });
const avifBuffer = await encode(imageData, {
quality: AVIF_QUALITY,
speed: AVIF_SPEED,
});
const encodeMs = performance.now() - encodeStarted;
const baseName = input instanceof File && input.name
? input.name.replace(/\.[^.]+$/, '')
: 'photo';
return new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
const file = new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
const totalMs = performance.now() - started;
console.log('[article-photo] process', {
totalMs: Math.round(totalMs),
decodeMs: Math.round(decodeMs),
resizeMs: Math.round(resizeMs),
encodeMs: Math.round(encodeMs),
quality: AVIF_QUALITY,
speed: AVIF_SPEED,
inputBytes: input.size,
outputBytes: file.size,
size: `${width}×${height}`,
});
return file;
}

BIN
tmp/bench-source.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB