2026-08-03 09:21:23 +08:00
|
|
|
|
import { existsSync } from 'node:fs'
|
|
|
|
|
|
/**
|
|
|
|
|
|
* POI 照片处理:产出主包缩略图 + 分包大图两层。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么分两层:微信小程序主包上限 2 MB。30 张 800×600 照片约 2.6 MB,
|
|
|
|
|
|
* 单独就超限。所以卡片场景(最大 192rpx ≈ 288 px)用小图放主包,
|
|
|
|
|
|
* 详情页 hero(750×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'
|
2026-07-31 18:11:38 +08:00
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-08-03 09:21:23 +08:00
|
|
|
|
import sharp from 'sharp'
|
2026-07-31 18:11:38 +08:00
|
|
|
|
|
|
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
|
|
|
const projectRoot = join(__dirname, '..')
|
|
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
/** 原图投放目录,不提交仓库 */
|
|
|
|
|
|
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 个景点)
|
2026-07-31 18:11:38 +08:00
|
|
|
|
const NAME_TO_POI_ID = {
|
2026-08-03 09:21:23 +08:00
|
|
|
|
// 文化场馆
|
|
|
|
|
|
文化艺术中心: 'poi_gm_culture_center',
|
|
|
|
|
|
光明区图书馆: 'poi_amap_b0hbtu4bfr',
|
|
|
|
|
|
光明区文化馆: 'poi_amap_b0ffk306gx',
|
|
|
|
|
|
少年儿童图书馆: 'poi_amap_b02f38meio',
|
|
|
|
|
|
深圳国际美术馆: 'poi_amap_b0jun773j3',
|
|
|
|
|
|
城市规划馆: 'poi_amap_b0jbbzx4oo',
|
|
|
|
|
|
茅洲河图书馆: 'poi_amap_b0k025w32e',
|
2026-07-31 18:11:38 +08:00
|
|
|
|
// 公园湿地
|
2026-08-03 09:21:23 +08:00
|
|
|
|
虹桥公园: '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',
|
2026-07-31 18:11:38 +08:00
|
|
|
|
// 田园休闲
|
2026-08-03 09:21:23 +08:00
|
|
|
|
光明农场大观园: 'poi_farm_grand_view',
|
|
|
|
|
|
华侨城光明欢乐田园: 'poi_happy_pastoral',
|
|
|
|
|
|
双晖稻田农场: 'poi_amap_b0ffkkmzsc',
|
2026-07-31 18:11:38 +08:00
|
|
|
|
// 人文地标
|
2026-08-03 09:21:23 +08:00
|
|
|
|
弘源寺: 'poi_amap_b02f38nj9k',
|
|
|
|
|
|
陈仙姑祠: 'poi_amap_b0ffgthbcf',
|
|
|
|
|
|
黄氏大宗祠: 'poi_amap_b0fffttiov',
|
|
|
|
|
|
玉律醒狮文化馆: 'poi_amap_b0kb4cxaby',
|
2026-07-31 18:11:38 +08:00
|
|
|
|
// 绿道户外
|
2026-08-03 09:21:23 +08:00
|
|
|
|
大顶岭绿道: 'poi_dadingling_greenway',
|
|
|
|
|
|
光明湖碧道: 'poi_amap_b0ldbsbeea',
|
|
|
|
|
|
茅洲河碧道: 'poi_amap_b0kup558j4',
|
|
|
|
|
|
五指耙森林公园: 'poi_amap_b0jb2aurvw',
|
2026-07-31 18:11:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
const IMAGE_RE = /\.(jpe?g|png|webp)$/i
|
2026-07-31 18:11:38 +08:00
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
/** 把源文件名解析成 poiId:优先当作已经是 poiId,否则查中文名映射 */
|
|
|
|
|
|
function resolvePoiId(fileName) {
|
|
|
|
|
|
const base = fileName.replace(IMAGE_RE, '')
|
|
|
|
|
|
if (base.startsWith('poi_'))
|
|
|
|
|
|
return base
|
|
|
|
|
|
return NAME_TO_POI_ID[base] ?? null
|
|
|
|
|
|
}
|
2026-07-31 18:11:38 +08:00
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-31 18:11:38 +08:00
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
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) }
|
|
|
|
|
|
}
|
2026-07-31 18:11:38 +08:00
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
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)
|
2026-07-31 18:11:38 +08:00
|
|
|
|
if (!poiId) {
|
2026-08-03 09:21:23 +08:00
|
|
|
|
console.log(`SKIP ${file} — 文件名既不是 poiId 也不在中文名映射表里`)
|
2026-07-31 18:11:38 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
const inputPath = join(intakeDir, file)
|
2026-07-31 18:11:38 +08:00
|
|
|
|
try {
|
2026-08-03 09:21:23 +08:00
|
|
|
|
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
|
2026-07-31 18:11:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
// 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
|
2026-07-31 18:11:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 09:21:23 +08:00
|
|
|
|
main().catch((error) => {
|
|
|
|
|
|
console.error(error)
|
|
|
|
|
|
process.exitCode = 1
|
|
|
|
|
|
})
|