diff --git a/scripts/bench-avif-encode.mjs b/scripts/bench-avif-encode.mjs new file mode 100644 index 0000000..daa1538 --- /dev/null +++ b/scripts/bench-avif-encode.mjs @@ -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)); diff --git a/scripts/export-pic-by-hash.mjs b/scripts/export-pic-by-hash.mjs new file mode 100644 index 0000000..3c0a055 --- /dev/null +++ b/scripts/export-pic-by-hash.mjs @@ -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 [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(); +} diff --git a/src/api/index.js b/src/api/index.js index f41a7b7..87f0a53 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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`); }, diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx index 560d7d3..ec356f6 100644 --- a/src/components/article/ArticlePhoto.jsx +++ b/src/components/article/ArticlePhoto.jsx @@ -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 { {hasPreview && !thumbLoaded ? ( = 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', + } + } > @@ -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} diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index dab092a..622e5c8 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -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)); diff --git a/src/components/article/ArticlePhotosDialog.jsx b/src/components/article/ArticlePhotosDialog.jsx index 55e6e22..a8132df 100644 --- a/src/components/article/ArticlePhotosDialog.jsx +++ b/src/components/article/ArticlePhotosDialog.jsx @@ -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 }} > - + ; } @@ -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 ( + + + + + + {label ? ( + + {label} + + ) : null} + {sku ? ( + + {sku} + + ) : null} + + + ); + } } diff --git a/src/components/tiles/Tile.jsx b/src/components/tiles/Tile.jsx index 16231b4..84d850e 100644 --- a/src/components/tiles/Tile.jsx +++ b/src/components/tiles/Tile.jsx @@ -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' } : {}), diff --git a/src/utils/prepareArticlePhoto.js b/src/utils/prepareArticlePhoto.js index 8d24190..122397a 100644 --- a/src/utils/prepareArticlePhoto.js +++ b/src/utils/prepareArticlePhoto.js @@ -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; } diff --git a/tmp/bench-source.avif b/tmp/bench-source.avif new file mode 100644 index 0000000..0595dad Binary files /dev/null and b/tmp/bench-source.avif differ