Files
gmTouringMiniApp/scripts/process-poi-photos.mjs
T
周瑞哲 f8ad5e1faf perf(mp-weixin): 照片分两层,详情页移入 pages-poi 分包
微信主包上限 2 MB,30 张 800×600 照片单独就 2.6 MB,
整包 3.2 MB,上传被拒(错误码 80051)。

按使用场景把照片拆成两层:
- 主包缩略图 400×300 q75(678 KB),供地图、行程、打卡卡片使用,
  这些场景最大只显示 192rpx(≈288 px @DPR3),800×600 是过剩
- 分包大图 800×600 q66(1832 KB),供详情页 hero 使用,
  随 pages-poi 分包按需下载

两层都保持 4:3,PoiImage 的 aspectFill 行为不变,视觉表现与之前一致。

详情页从 pages/poi/detail 移到 pages-poi/detail:主包页面无法引用
分包资源,hero 要用大图,页面就必须在分包内。新增
coverPhotoFullUrlFor() 供分包内页面取大图,占位图仍指向主包。

verify-weixin-output.mjs 增加防回归检查:分包注册、两层照片数量
一致、主包与各分包实际体积(留 64 KB 余量)。包体再超限会在
verify 阶段失败,不必等上传才发现。

process-poi-photos.mjs 重写为双层产出,幂等,末尾报告包体占用并
在超限时退出非零。原图改从 src/photo-intake/ 读取(不提交仓库)。

改造后:主包 1522 KB、分包 pages-poi 1847 KB,均在 2048 KB 内。
mp-weixin 与 H5 均编译通过,97 个测试通过,vue-tsc 与 ESLint 无错。
2026-08-03 09:21:23 +08:00

192 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync } from 'node:fs'
/**
* POI 照片处理:产出主包缩略图 + 分包大图两层。
*
* 为什么分两层:微信小程序主包上限 2 MB。30 张 800×600 照片约 2.6 MB
* 单独就超限。所以卡片场景(最大 192rpx ≈ 288 px)用小图放主包,
* 详情页 hero750×422rpx ≈ 1125×633 px)用大图放分包,随详情页按需下载。
*
* 两个产出目录都是提交进仓库的构建产物:
* src/pages-poi/static/photos/<poiId>.jpg 800×600 分包大图(详情页 hero)
* src/static/poi/photos/<poiId>.jpg 400×300 主包缩略图(各处卡片)
*
* 新增一张照片:
* 1. 原图放入 src/photo-intake/(该目录不提交),命名为 <poiId>.jpg
* 或用下方 NAME_TO_POI_ID 表里的中文名,例如「虹桥公园.jpg」
* 2. 跑 `node scripts/process-poi-photos.mjs`
* 3. 把 poiId 加入 src/data/poi/photos.ts 的 POI_COVER_PHOTOS_READY
*
* 不带 intake 直接跑:从分包大图重新生成全部主包缩略图(幂等)。
*/
import { mkdir, readdir, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '..')
/** 原图投放目录,不提交仓库 */
const intakeDir = join(projectRoot, 'src/photo-intake')
/** 分包大图:详情页 hero 使用,随分包按需下载 */
const fullDir = join(projectRoot, 'src/pages-poi/static/photos')
/** 主包缩略图:地图、行程、打卡等卡片使用 */
const thumbDir = join(projectRoot, 'src/static/poi/photos')
/** 分包大图规格。q66 是在 2 MB 分包上限内能给到 hero 的最高质量 */
const FULL = { width: 800, height: 600, quality: 66 }
/** 主包缩略图规格。400×300 覆盖最大卡片 192rpx(≈288 px @DPR3 */
const THUMB = { width: 400, height: 300, quality: 75 }
/** 微信包体上限 */
const MAIN_PACKAGE_LIMIT_KB = 2048
const SUB_PACKAGE_LIMIT_KB = 2048
// 中文名 → POI ID 映射(30 个景点)
const NAME_TO_POI_ID = {
// 文化场馆
文化艺术中心: 'poi_gm_culture_center',
光明区图书馆: 'poi_amap_b0hbtu4bfr',
光明区文化馆: 'poi_amap_b0ffk306gx',
少年儿童图书馆: 'poi_amap_b02f38meio',
深圳国际美术馆: 'poi_amap_b0jun773j3',
城市规划馆: 'poi_amap_b0jbbzx4oo',
茅洲河图书馆: 'poi_amap_b0k025w32e',
// 公园湿地
虹桥公园: 'poi_hongqiao_park',
红花山公园: 'poi_honghuashan_park',
科学公园: 'poi_amap_b0j2jc4n0w',
鹅颈水湿地公园: 'poi_amap_b0ffmdhve1',
明湖公园: 'poi_amap_b0fffwu1l5',
光明新城公园: 'poi_amap_b0ffhfbsje',
开明公园: 'poi_amap_b0ffjf1b20',
左岸科技公园: 'poi_amap_b0g0kzrt5s',
西田公园: 'poi_amap_b02f30abgs',
大雁山森林公园: 'poi_amap_b0jgbp18uf',
楼村湿地公园: 'poi_amap_b0grac6nzz',
大顶岭山林公园: 'poi_amap_b02f309r88',
// 田园休闲
光明农场大观园: 'poi_farm_grand_view',
华侨城光明欢乐田园: 'poi_happy_pastoral',
双晖稻田农场: 'poi_amap_b0ffkkmzsc',
// 人文地标
弘源寺: 'poi_amap_b02f38nj9k',
陈仙姑祠: 'poi_amap_b0ffgthbcf',
黄氏大宗祠: 'poi_amap_b0fffttiov',
玉律醒狮文化馆: 'poi_amap_b0kb4cxaby',
// 绿道户外
大顶岭绿道: 'poi_dadingling_greenway',
光明湖碧道: 'poi_amap_b0ldbsbeea',
茅洲河碧道: 'poi_amap_b0kup558j4',
五指耙森林公园: 'poi_amap_b0jb2aurvw',
}
const IMAGE_RE = /\.(jpe?g|png|webp)$/i
/** 把源文件名解析成 poiId:优先当作已经是 poiId,否则查中文名映射 */
function resolvePoiId(fileName) {
const base = fileName.replace(IMAGE_RE, '')
if (base.startsWith('poi_'))
return base
return NAME_TO_POI_ID[base] ?? null
}
async function render(inputPath, outputPath, spec) {
await sharp(inputPath)
.rotate() // 按 EXIF 摆正
.resize(spec.width, spec.height, {
fit: 'cover',
position: 'attention', // 智能裁剪,尽量保留主体
})
.jpeg({
quality: spec.quality,
mozjpeg: true,
chromaSubsampling: '4:2:0',
})
.toFile(outputPath)
}
async function dirTotalKb(dir) {
if (!existsSync(dir))
return { count: 0, kb: 0 }
const files = (await readdir(dir)).filter(f => f.endsWith('.jpg'))
let bytes = 0
for (const f of files)
bytes += (await stat(join(dir, f))).size
return { count: files.length, kb: Math.round(bytes / 1024) }
}
async function listIntake() {
if (!existsSync(intakeDir))
return []
return (await readdir(intakeDir)).filter(f => IMAGE_RE.test(f))
}
async function main() {
await mkdir(fullDir, { recursive: true })
await mkdir(thumbDir, { recursive: true })
const intake = await listIntake()
const processed = new Set()
// 1) 有 intake 原图的:同时生成大图和缩略图
for (const file of intake) {
const poiId = resolvePoiId(file)
if (!poiId) {
console.log(`SKIP ${file} — 文件名既不是 poiId 也不在中文名映射表里`)
continue
}
const inputPath = join(intakeDir, file)
try {
await render(inputPath, join(fullDir, `${poiId}.jpg`), FULL)
await render(inputPath, join(thumbDir, `${poiId}.jpg`), THUMB)
processed.add(poiId)
console.log(`OK ${file}${poiId}.jpg(大图 + 缩略图)`)
}
catch (error) {
console.error(`FAIL ${file}: ${error.message}`)
process.exitCode = 1
}
}
// 2) 其余已有大图的:从大图重新生成缩略图,保证两层始终同步
const fullFiles = (await readdir(fullDir)).filter(f => f.endsWith('.jpg'))
for (const file of fullFiles) {
const poiId = file.replace(/\.jpg$/, '')
if (processed.has(poiId))
continue
try {
await render(join(fullDir, file), join(thumbDir, file), THUMB)
console.log(`THUMB ${poiId}.jpg ← 由分包大图重新生成`)
}
catch (error) {
console.error(`FAIL ${file}: ${error.message}`)
process.exitCode = 1
}
}
// 3) 报告包体占用,超限直接失败
const full = await dirTotalKb(fullDir)
const thumb = await dirTotalKb(thumbDir)
console.log('\n包体占用:')
console.log(` 分包大图 ${String(full.count).padStart(2)}${String(full.kb).padStart(4)} KB (分包上限 ${SUB_PACKAGE_LIMIT_KB} KB)`)
console.log(` 主包缩略图 ${String(thumb.count).padStart(2)}${String(thumb.kb).padStart(4)} KB (主包上限 ${MAIN_PACKAGE_LIMIT_KB} KB,还需容纳代码)`)
if (full.kb >= SUB_PACKAGE_LIMIT_KB) {
console.error(`\n分包大图已达 ${full.kb} KB,超过分包上限。请下调 FULL.quality 后重跑。`)
process.exitCode = 1
}
if (thumb.kb >= MAIN_PACKAGE_LIMIT_KB) {
console.error(`\n主包缩略图已达 ${thumb.kb} KB,超过主包上限。请下调 THUMB 规格后重跑。`)
process.exitCode = 1
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})