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:
107
scripts/bench-avif-encode.mjs
Normal file
107
scripts/bench-avif-encode.mjs
Normal 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));
|
||||||
83
scripts/export-pic-by-hash.mjs
Normal file
83
scripts/export-pic-by-hash.mjs
Normal 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();
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ export const article = {
|
|||||||
/**
|
/**
|
||||||
* GET /api/article/:sku → { article }
|
* GET /api/article/:sku → { article }
|
||||||
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
|
* 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) {
|
getBySku(sku) {
|
||||||
return http(`/api/article/${encodeURIComponent(sku)}`);
|
return http(`/api/article/${encodeURIComponent(sku)}`);
|
||||||
@@ -243,7 +243,10 @@ export const category = {
|
|||||||
return http('/api/category/tree');
|
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) {
|
getArticles(categoryId) {
|
||||||
return http(`/api/category/${categoryId}/articles`);
|
return http(`/api/category/${categoryId}/articles`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ class ArticlePhoto extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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 { thumbLoaded } = this.state;
|
||||||
const fullHash = photo?.fullHash;
|
const fullHash = photo?.fullHash;
|
||||||
const hasPreview = photo?.thumbHash != null;
|
const hasPreview = photo?.thumbHash != null;
|
||||||
@@ -39,28 +45,32 @@ class ArticlePhoto extends Component {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: size,
|
...(fill
|
||||||
height: size,
|
? { width: '100%', height: '100%' }
|
||||||
maxWidth: '100%',
|
: { width: size, height: size, maxWidth: '100%', flexShrink: 0 }),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: 2,
|
borderRadius: fill ? 0 : 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.35)',
|
bgcolor: 'rgba(0, 0, 0, 0.35)',
|
||||||
flexShrink: 0,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasPreview && !thumbLoaded ? (
|
{hasPreview && !thumbLoaded ? (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={
|
||||||
position: 'absolute',
|
fill || objectFit === 'cover'
|
||||||
maxWidth: '100%',
|
? { position: 'absolute', inset: 0 }
|
||||||
maxHeight: '100%',
|
: {
|
||||||
aspectRatio,
|
position: 'absolute',
|
||||||
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
|
maxWidth: '100%',
|
||||||
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
|
maxHeight: '100%',
|
||||||
}}
|
aspectRatio,
|
||||||
|
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
|
||||||
|
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ThumbHashImage thumbHash={photo.thumbHash} />
|
<ThumbHashImage thumbHash={photo.thumbHash} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -72,15 +82,25 @@ class ArticlePhoto extends Component {
|
|||||||
alt=""
|
alt=""
|
||||||
onLoad={this.handleThumbLoad}
|
onLoad={this.handleThumbLoad}
|
||||||
onError={this.handleThumbError}
|
onError={this.handleThumbError}
|
||||||
sx={{
|
sx={
|
||||||
display: 'block',
|
fill || objectFit === 'cover'
|
||||||
maxWidth: '100%',
|
? {
|
||||||
maxHeight: '100%',
|
display: 'block',
|
||||||
width: 'auto',
|
width: '100%',
|
||||||
height: 'auto',
|
height: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'cover',
|
||||||
opacity: thumbLoaded ? 1 : 0,
|
opacity: thumbLoaded ? 1 : 0,
|
||||||
}}
|
}
|
||||||
|
: {
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
opacity: thumbLoaded ? 1 : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ class ArticlePhotoTile extends Component {
|
|||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
const file = await prepareArticlePhoto(rawBlob);
|
const file = await prepareArticlePhoto(rawBlob);
|
||||||
|
const uploadStarted = performance.now();
|
||||||
const res = await article.uploadImage(articleData.sku, file);
|
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, 'POST /api/article/:sku/image');
|
||||||
requireObject(res.image, 'image');
|
requireObject(res.image, 'image');
|
||||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||||
|
|||||||
@@ -61,7 +61,13 @@ class ArticlePhotosDialog extends Component {
|
|||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
const file = await prepareArticlePhoto(rawBlob);
|
const file = await prepareArticlePhoto(rawBlob);
|
||||||
|
const uploadStarted = performance.now();
|
||||||
const res = await article.uploadImage(articleData.sku, file);
|
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, 'POST /api/article/:sku/image');
|
||||||
requireObject(res.image, 'image');
|
requireObject(res.image, 'image');
|
||||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||||
@@ -136,11 +142,12 @@ class ArticlePhotosDialog extends Component {
|
|||||||
key={photo.fullHash}
|
key={photo.fullHash}
|
||||||
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
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 Typography from '@mui/material/Typography';
|
||||||
import { Tile } from './Tile';
|
import { Tile, tileBaseSx } from './Tile';
|
||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
import { articleSku } from '../../pages/artikel/articleRoute';
|
import { articleSku } from '../../pages/artikel/articleRoute';
|
||||||
|
import ArticlePhoto from '../article/ArticlePhoto';
|
||||||
|
|
||||||
export class ArticleTile extends Tile {
|
export class ArticleTile extends Tile {
|
||||||
|
hasPhoto() {
|
||||||
|
const { article } = this.props;
|
||||||
|
return article?.thumbHash != null || article?.fullHash != null;
|
||||||
|
}
|
||||||
|
|
||||||
renderIcon() {
|
renderIcon() {
|
||||||
return <Inventory2OutlinedIcon color="action" sx={{ fontSize: 36 }} />;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import Typography from '@mui/material/Typography';
|
|||||||
|
|
||||||
export const TILE_SIZE = 140;
|
export const TILE_SIZE = 140;
|
||||||
|
|
||||||
|
/** Matches panel tile rounding (theme.shape.borderRadius × 4 → typically 16px). */
|
||||||
|
export const TILE_BORDER_RADIUS = 4;
|
||||||
|
|
||||||
export const tileBaseSx = {
|
export const tileBaseSx = {
|
||||||
aspectRatio: '1 / 1',
|
aspectRatio: '1 / 1',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -16,10 +19,15 @@ export const tileBaseSx = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
textAlign: 'center',
|
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': {
|
'&:hover': {
|
||||||
bgcolor: 'action.hover',
|
bgcolor: 'action.hover',
|
||||||
borderColor: 'primary.main',
|
boxShadow: 6,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,11 +70,6 @@ export class Tile extends Component {
|
|||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
sx={{
|
sx={{
|
||||||
...tileBaseSx,
|
...tileBaseSx,
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
...(disabled
|
...(disabled
|
||||||
? { opacity: 0.5, pointerEvents: 'none' }
|
? { opacity: 0.5, pointerEvents: 'none' }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const MAX_EDGE = 1000;
|
const MAX_EDGE = 1000;
|
||||||
const AVIF_MIME = 'image/avif';
|
const AVIF_MIME = 'image/avif';
|
||||||
const AVIF_QUALITY = 80;
|
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 encodeAvif;
|
||||||
let resizeImage;
|
let resizeImage;
|
||||||
@@ -61,21 +63,48 @@ export async function prepareArticlePhoto(input) {
|
|||||||
throw new Error('Kein Bild.');
|
throw new Error('Kein Bild.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const started = performance.now();
|
||||||
|
|
||||||
|
const decodeStarted = performance.now();
|
||||||
const originalImageData = await decodeToImageData(input);
|
const originalImageData = await decodeToImageData(input);
|
||||||
|
const decodeMs = performance.now() - decodeStarted;
|
||||||
|
|
||||||
const { width, height } = scaledDimensions(
|
const { width, height } = scaledDimensions(
|
||||||
originalImageData.width,
|
originalImageData.width,
|
||||||
originalImageData.height,
|
originalImageData.height,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resizeStarted = performance.now();
|
||||||
const resize = await getResizer();
|
const resize = await getResizer();
|
||||||
const imageData = await resize(originalImageData, { width, height });
|
const imageData = await resize(originalImageData, { width, height });
|
||||||
|
const resizeMs = performance.now() - resizeStarted;
|
||||||
|
|
||||||
|
const encodeStarted = performance.now();
|
||||||
const encode = await getAvifEncoder();
|
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
|
const baseName = input instanceof File && input.name
|
||||||
? input.name.replace(/\.[^.]+$/, '')
|
? input.name.replace(/\.[^.]+$/, '')
|
||||||
: 'photo';
|
: '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
BIN
tmp/bench-source.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
Reference in New Issue
Block a user