diff --git a/index.html b/index.html index c9f948c..093fbbb 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + (y * w + x) * 4 + + // Flood-fill only transparent pixels connected to the canvas edge. + // This prevents transparent details inside labels/text from being treated as silhouette. + const exterior = new Uint8Array(w * h) + const queue = new Int32Array(w * h) + let head = 0 + let tail = 0 + const enqueue = (x, y) => { + const p = y * w + x + if (exterior[p] || data[p * 4 + 3] > 16) return + exterior[p] = 1 + queue[tail++] = p + } + for (let x = 0; x < w; x++) { + enqueue(x, 0) + enqueue(x, h - 1) + } + for (let y = 0; y < h; y++) { + enqueue(0, y) + enqueue(w - 1, y) + } + while (head < tail) { + const p = queue[head++] + const x = p % w + const y = Math.floor(p / w) + if (x > 0) enqueue(x - 1, y) + if (x + 1 < w) enqueue(x + 1, y) + if (y > 0) enqueue(x, y - 1) + if (y + 1 < h) enqueue(x, y + 1) + } + + const R = 3 + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const p = y * w + x + const i = p * 4 + if (exterior[p]) { + out[i] = out[i + 1] = out[i + 2] = 0 + continue + } + + let distance = R + 1 + for (let dy = -R; dy <= R; dy++) { + for (let dx = -R; dx <= R; dx++) { + const xx = x + dx + const yy = y + dy + if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue + if (exterior[yy * w + xx]) { + distance = Math.min(distance, Math.max(Math.abs(dx), Math.abs(dy))) + } + } + } + if (distance > R) continue + + const a = data[i + 3] + let r = data[i] + let g = data[i + 1] + let b = data[i + 2] + + if (a < 255) { + const af = a / 255 + r = Math.min(255, Math.max(0, Math.round((r - 255 * (1 - af)) / af))) + g = Math.min(255, Math.max(0, Math.round((g - 255 * (1 - af)) / af))) + b = Math.min(255, Math.max(0, Math.round((b - 255 * (1 - af)) / af))) + } + + // Turn the complete exterior silhouette into a dark feather: + // strongest on the outermost pixel, fading by the third pixel. + // Because the mask is flood-filled from the canvas edge, internal labels stay untouched. + const strength = distance === 1 ? 0.96 : distance === 2 ? 0.68 : 0.34 + out[i] = Math.round(r * (1 - strength) + DARK.r * strength) + out[i + 1] = Math.round(g * (1 - strength) + DARK.g * strength) + out[i + 2] = Math.round(b * (1 - strength) + DARK.b * strength) + } + } + + return sharp(out, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer() +} + +/** After WebP encode, force transparent RGB to black again. */ +async function neutralizeTransparentRgb(webpBuf) { + const { data, info } = await sharp(webpBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) { + data[i] = data[i + 1] = data[i + 2] = 0 + } + } + return sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } }) + .webp({ quality: 86, alphaQuality: 100 }) + .toBuffer() +} + async function writeThumb(srcPath, name, width) { const dest = path.join(outThumb, `${name}.webp`) - await sharp(srcPath) - .resize(width, null, { withoutEnlargement: true }) - .blur(2) - .webp({ quality: 50 }) - .toFile(dest) + let pipe = sharp(srcPath).resize(width, null, { withoutEnlargement: true }).blur(2) + + let buf = await pipe.webp({ quality: 50, alphaQuality: 100 }).toBuffer() + if (name === 't30-bottle') buf = await neutralizeTransparentRgb(buf) + await fs.writeFile(dest, buf) const stat = await fs.stat(dest) - return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 } + return { dest, kb: Math.round((stat.size / 1024) * 10) / 10 } } async function writeFull(srcPath, name, width) { const dest = path.join(outFull, `${name}.webp`) - await sharp(srcPath) - .resize(width, null, { withoutEnlargement: true }) - .webp({ quality: 82 }) - .toFile(dest) + let pipe = sharp(srcPath).resize(width, null, { + withoutEnlargement: true, + }) + + let buf = await pipe.webp({ quality: 86, alphaQuality: 100 }).toBuffer() + if (name === 't30-bottle') buf = await neutralizeTransparentRgb(buf) + await fs.writeFile(dest, buf) const stat = await fs.stat(dest) - return { dest, kb: Math.round(stat.size / 1024 * 10) / 10 } + return { dest, kb: Math.round((stat.size / 1024) * 10) / 10 } +} + +async function rewriteBottlePng(srcPath) { + const fixed = await defringeBottle(srcPath) + await fs.writeFile(srcPath, fixed) + const stat = await fs.stat(srcPath) + return Math.round((stat.size / 1024) * 10) / 10 } async function main() { await ensureDirs() + + const bottleSrc = path.join(root, 'public/assets/t30-bottle.png') + const pngKb = await rewriteBottlePng(bottleSrc) + console.log(`t30-bottle.png rewritten (${pngKb} KB)`) + let thumbTotal = 0 let fullTotal = 0 @@ -57,6 +187,7 @@ async function main() { continue } + // Bottle PNG was already defringed once above; do not process it twice. const thumb = await writeThumb(srcPath, item.name, item.thumbW) const full = await writeFull(srcPath, item.name, item.fullW) thumbTotal += thumb.kb diff --git a/scripts/prerender-routes.mjs b/scripts/prerender-routes.mjs index 734db88..1ae1f03 100644 --- a/scripts/prerender-routes.mjs +++ b/scripts/prerender-routes.mjs @@ -9,6 +9,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const outDir = path.resolve(__dirname, '../dist') const ROUTES = [ + '/h5', + '/h5/brand', + '/h5/poetry', + '/h5/qingxiang', + '/h5/haoke', + '/h5/contact', '/brand', '/poetry', '/qingxiang', diff --git a/src/App.tsx b/src/App.tsx index b8eb8c6..d3368d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,29 +12,33 @@ import { PartnerPage } from './pages/Partner' import { TechDetailPage } from './pages/TechDetail' import { ContactPage } from './pages/Contact' import { DeepPage } from './pages/DeepPage' +import { ThemeProvider } from './hooks/useTheme' export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) } diff --git a/src/components/Footer.css b/src/components/Footer.css index 545f326..ef07f24 100644 --- a/src/components/Footer.css +++ b/src/components/Footer.css @@ -5,7 +5,7 @@ border-top: 1px solid rgba(196, 163, 90, 0.15); } -.theme-tech .site-footer { +[data-theme='technology'] .site-footer { background: #0c0a0e; border-top: 1px solid rgba(196, 163, 90, 0.28); } @@ -22,8 +22,9 @@ } .footer-logo { - width: 2.6rem; - height: 2.6rem; + height: 4rem; + width: auto; + max-width: 11rem; object-fit: contain; filter: brightness(1.1); } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 679cf7d..18a7a0b 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,15 +1,18 @@ -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import { footer, images } from '../content/site' import './Footer.css' export function Footer() { + const location = useLocation() + const contactPath = + location.pathname === '/h5' || location.pathname.startsWith('/h5/') ? '/h5/contact' : '/contact' + return (