hexo配置相册页面

相册演示地址:光影流年 | Wenjie Wang 。本文是基于Hexo 7.3.0开发!!主要实现相册集以及相册页面功能。

1.创建相册页面

在自己的博客项目下,新建相册页

hexo new page photos

编辑 /source/photos/index.md,输入以下内容

---
title: 光影流年
date: 2025-08-25
layout: photo
top_img: img/top_img/photo.png
---
<!-- 分组封面索引 -->

<div id="galleryIntro" class="gallery-intro">“ 这里有光影流年,还把喜欢装进云朵,相拥在明天的河岸。 ”</div>

<div id="galleryIndex" class="gallery-index"></div>

新建 /source/gallery/index.md,输入以下内容

---
comments: false
---
<div class="ImageGrid"></div>

新建 /source/galleries/index.md,输入以下内容

---
title: 光影流年
comments: false
---
<div id="galleryIntro" class="gallery-intro">“ 这里有光影流年,还把喜欢装进云朵,相拥在明天的河岸。 ”</div>

<div id="galleryIndex" class="gallery-index"></div>

2.处理图片信息

这里需要创建自己的github相册,创建一个gallery项目,建一个gallery的文件夹,在这个文件夹里就可以放你想要的照片了。PS:可以放文件夹,一个文件夹就在你的博客中显示的是一个照片集。

这里主要是为了加快图片的加载速度, 利用GitHub +jsDelivr的方式。

把这个项目clone到本地,下面的操作是对这个项目进行的。

这里没有安装 image-size,需要npm安装下

npm i -S image-size

在github相册目录下,执行createjs,

node createjs.js 

createjs.js 脚本如下:

const fs = require('fs-extra');
const path = require('path');

// 兼容各种导出形式:function / { imageSize } / { default }
const imgMod = require('image-size');
const imageSize = typeof imgMod === 'function' ? imgMod : (imgMod.imageSize || imgMod.default);

const rootPath = 'gallery'; // 相册根目录(不用末尾斜杠)

class PhotoExtension {
constructor() {
this.size = 64;
this.offset = [0, 0];
}
}

class Photo {
constructor() {
this.dirName = '';
this.fileName = '';
this.iconID = '';
this.extension = new PhotoExtension();
}
}

class PhotoGroup {
constructor() {
this.name = '';
this.children = [];
}
}

const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg']);

function createPlotIconsData() {
let allPlots = [];
let allPlotGroups = [];

const plotJsonFile = path.join(__dirname, 'photosInfo.json');
const plotGroupJsonFile = path.join(__dirname, 'photos.json');

if (fs.existsSync(plotJsonFile)) {
allPlots = JSON.parse(fs.readFileSync(plotJsonFile, 'utf8'));
}
if (fs.existsSync(plotGroupJsonFile)) {
allPlotGroups = JSON.parse(fs.readFileSync(plotGroupJsonFile, 'utf8'));
}

// 只遍历相册根目录
const rootDir = path.join(__dirname, rootPath);
if (!fs.existsSync(rootDir)) {
console.error(`根目录不存在:${rootDir}`);
return;
}

fs.readdirSync(rootDir).forEach((dirName) => {
const dirFull = path.join(rootDir, dirName);
if (!fs.statSync(dirFull).isDirectory()) return;

const subfiles = fs.readdirSync(dirFull);
subfiles.forEach((subfileName) => {
const ext = path.extname(subfileName).toLowerCase();
if (!IMAGE_EXTS.has(ext)) return; // 只处理图片

// 如果已存在可跳过(按需打开)
if (allPlots.find(o => o.fileName === subfileName && o.dirName === dirName)) return;

const imgPath = path.join(dirFull, subfileName);

try {
const info = imageSize(imgPath); // 这里已兼容函数获取
const plot = new Photo();
plot.dirName = dirName;
plot.fileName = subfileName;
plot.iconID = `${info.width}.${info.height} ${subfileName}`;
allPlots.push(plot);

let group = allPlotGroups.find(o => o.name === dirName);
if (!group) {
group = new PhotoGroup();
group.name = dirName;
allPlotGroups.push(group);
}
group.children.push(plot.iconID);
console.log('✔ 新增图片:', plot.iconID);
} catch (e) {
console.warn('⚠ 读取图片尺寸失败:', imgPath, e.message);
}
});
});

fs.writeJSONSync(plotJsonFile, allPlots, { spaces: 2 });
fs.writeJSONSync(plotGroupJsonFile, allPlotGroups, { spaces: 2 });
}

createPlotIconsData();

将生成的photos.json拷贝到博客目录下的photos

3.加载 js和css文件

在 /source/js/ 目录下创建 photoWall.js

var imgDataPath = "/photos/photos.json"; // 先用站点内路径,稳定 200
var imgPath = "https://cdn.jsdelivr.net/gh/wenjiew-astro/gallery@main/gallery/";
var imgMaxNum = 50;

var windowWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
var imageWidth = windowWidth < 768 ? 145 : 250;

function slugify(s){ return String(s).toLowerCase().replace(/\s+/g,'-').replace(/[^\w\-]+/g,'').replace(/\-+/g,'-').replace(/^\-+|\-+$/g,'')||'tab'; }
function enc(s){ return encodeURIComponent(s); }
function buildImgURL(dir,file){ return imgPath + enc(dir) + '/' + enc(file); }

// 可选:为分组定义“中文/英文标题”这类别名;找不到就用 name 填充
const groupMeta = {
// '凤凰古城': { title:'Fenghuang', sub:'凤凰古城' },
// '西湖': { title:'West Lake', sub:'西湖' },
};

const photo = {
offset: imgMaxNum,

init(){
if (typeof jQuery==='undefined'){ console.error('[photoWall] jQuery 未加载'); return; }
$.getJSON(imgDataPath,(data)=>{
if(!Array.isArray(data)||!data.length){
$('.ImageGrid').html('<div style="padding:1rem;color:#999;">没有可显示的相册数据</div>'); return;
}
this.renderIndex(data); // 上方分组封面区(像示例页)
this.renderGroups(data); // 下方分组内容(瀑布流)
this.bindEvents();
}).fail((_,s,e)=>{
console.error('[photoWall] 加载 photos.json 失败:',s,e);
$('.ImageGrid').html('<div style="padding:1rem;color:#c00;">加载 photos.json 失败</div>');
});
},

renderIndex(data){
let html='';
data.forEach((g,idx)=>{
const {name='',children=[]}=g||{};
const first=children[0];
const cover = first ? buildImgURL(name, String(first).split(' ')[1])
: 'https://via.placeholder.com/400x240?text=No+Image';
const meta = groupMeta[name] || { title:name, sub:name };
const tiltClass = 'tilt-' + (1 + Math.floor(Math.random()*6));
html += `
<div class="gcard ${tiltClass}" data-target="#${slugify(name)}-${idx}" data-group="${enc(name)}">
<div class="gimg"><img src="${cover}" alt="${meta.title}"></div>
<div class="gmeta">
<div class="gtitle">${meta.title}</div>
<div class="gsub">${meta.sub}(${children.length})</div>
</div>
</div>`;
});
$('#galleryIndex').html(html);
},

renderGroups(data){
let liHtml='', panesHtml='';
data.forEach((group,idx)=>{
const {name='',children=[]}=group||{};
const active = idx===0 ? 'active' : '';
const slug = `${slugify(name)}-${idx}`;

// tabs(已被 CSS 隐藏,仅用于内部切分容器)
liHtml += `
<li class="nav-item" role="presentation">
<a class="nav-link ${active} photo-tab" data-toggle="tab"
href="#${slug}" role="tab" aria-controls="${slug}" aria-selected="${idx===0}">
${name}
</a>
</li>`;

// 分组里的图片卡片
let cards='';
children.slice(0,this.offset).forEach((item)=>{
const [size,file]=String(item).split(' ');
if(!size||!file) return;
const [w,h]=size.split('.');
const url=buildImgURL(name,file);
const imgName=file.split('.')[0];

cards += `
<div class="card" style="width:${imageWidth}px">
<div class="ImageInCard" style="height:${(imageWidth*(+h||1))/(+w||1)}px">
<a data-fancybox="gallery" href="${url}" data-caption="${imgName}" title="${imgName}">
<img loading="lazy" src="${url}" width="${imageWidth}" />
</a>
</div>
</div>`;
});

panesHtml += `
<div class="tab-pane fade ${active?'show active':''}" id="${slug}" role="tabpanel">
<div class="ImageGrid">${cards}</div>
</div>`;
});

const tabs = `<ul class="nav nav-tabs" id="myTab" role="tablist">${liHtml}</ul>`;
const content = `<div class="tab-content" id="myTabContent">${panesHtml}</div>`;
$('#imageTab').html(tabs);
$('.ImageGrid').not('#myTabContent .ImageGrid').remove();
$('#myTab').after(content);

this.mountGrids();
},

bindEvents(){
// 点击封面卡片 → 切换到对应分组
$(document).on('click','.gcard',(e)=>{
const target=$(e.currentTarget).data('target');
if(target){ $(`a[href='${target}']`).tab('show'); $('html,body').animate({scrollTop: $('#imageTab').offset().top-16}, 280); }
});
// Tab 切换/窗口尺寸更改 → 重新布局
$(document).on('shown.bs.tab','a[data-toggle="tab"]',()=>this.mountGrids());
$(window).on('resize',()=>this.mountGrids());
},

mountGrids(){
$(".tab-pane.show.active .ImageGrid, .tab-pane.active .ImageGrid").each(function(){
try{
var grid=new Minigrid({ container:this, item:".card", gutter:12 });
grid.mount();
const imgs=$(this).find('img'); let left=imgs.length;
if(!left) return;
imgs.off('load.pw error.pw').on('load.pw error.pw',()=>{ if(--left<=0) grid.mount(); });
}catch(e){ console.warn('[photoWall] Minigrid 失败:',e.message); }
});
}
};

$(function(){ photo.init(); });

在 /source/js/ 目录下创建 gallery-group.js

console.log('gallery-group.js loaded');

var IMG_DATA = "/photos/photos.json";
var IMG_ROOT = "https://cdn.jsdelivr.net/gh/wenjiew-astro/gallery@main/gallery/";
var W = (window.innerWidth || document.documentElement.clientWidth) < 768 ? 145 : 250;

function enc(s){ return encodeURIComponent(s) }
function build(dir, file){ return IMG_ROOT + enc(dir) + '/' + enc(file) }
function q(k){ return new URLSearchParams(location.search).get(k) || '' }

$(function(){
var $grid = $(".ImageGrid");
if (!$grid.length) return;

$.getJSON(IMG_DATA).done(function(list){
if (!Array.isArray(list) || !list.length) {
$grid.html('<p style="color:#999">暂无相册</p>');
return;
}

// 取参数;没有则默认第一组(但只有在确实没带参数时才兜底)
var group = q('group');
if (!group) {
group = list[0].name;
history.replaceState(null, '', '?group=' + encodeURIComponent(group));
}

// 兼容中文参数:URLSearchParams 已解码,这里不再反复解码
var item = list.find(function(x){ return x && x.name === group; });
if (!item) {
$grid.html('<p style="color:#c00">未找到分组:'+ group +'</p>');
return;
}

// 找到分组后(item 存在)——把页头标题改成组名,并锁定
(function forceSiteTitle(name) {
// 1) 浏览器标签
document.title = name + ' - 相册';

// 2) 页头大标题 #site-title
var el = document.querySelector('#page-site-info #site-title');
if (!el) return;

// 重复多次以覆盖主题的晚注入
function apply() { el.textContent = name; }
[0, 30, 80, 200, 500, 1000, 2000].forEach(function(t){ setTimeout(apply, t); });

// 监听这个节点,一旦被改回就再写一次
try {
var mo = new MutationObserver(function(){ if (el.textContent !== name) el.textContent = name; });
mo.observe(el, { childList: true, characterData: true, subtree: true });
setTimeout(function(){ mo.disconnect(); }, 8000); // 8 秒后基本稳定
} catch(e){}
})(group);


$('#groupTitle').text(group);

var cards = item.children.map(function(s){
var seg = String(s).split(' '); if (seg.length < 2) return '';
var wh = seg[0].split('.'); var iw = +wh[0]||1, ih = +wh[1]||1;
var file = seg[1]; var url = build(group, file); var name = file.split('.')[0];

return '' +
'<div class="card" style="width:'+W+'px">' +
'<div class="ImageInCard" style="height:'+(W*ih/iw)+'px">' +
'<a data-fancybox="gallery" href="'+url+'" data-caption="'+name+'" title="'+name+'">' +
'<img loading="lazy" src="'+url+'" width="'+W+'"/>' +
'</a>' +
'</div>' +
'</div>';
}).join('');

$grid.html(cards);

try {
var grid = new Minigrid({ container: $grid[0], item: '.card', gutter: 12 });
grid.mount();
var imgs = $grid.find('img'), left = imgs.length;
imgs.on('load error', function(){ if(--left <= 0) grid.mount(); });
$(window).on('resize', function(){ grid.mount(); });
} catch(e) { console.warn('Minigrid error:', e.message); }
}).fail(function(_,s,e){
$grid.html('<p style="color:#c00">photos.json 加载失败</p>');
console.error('[gallery-group] photos.json 加载失败:', s, e);
});
});


document.addEventListener('pjax:complete', function () {
var g = new URLSearchParams(location.search).get('group');
if (g) (function(name){
var el = document.querySelector('#page-site-info #site-title');
if (el) el.textContent = name;
document.title = name + ' - 相册';
})(g);
});

在 /source/js/ 目录下创建 gallery-index.js

console.log('gallery-index.js loaded');

// 数据与图片前缀
var IMG_DATA = "/photos/photos.json"; // 请确保 source/photos/photos.json 存在并会部署
var IMG_ROOT = "https://cdn.jsdelivr.net/gh/wenjiew-astro/gallery@main/gallery/";

// 从封面页渲染“可点击的封面卡片”,卡片本身就是 <a href="../gallery/?group=xxx">
function enc(s){ return encodeURIComponent(s) }
function build(dir, file){ return IMG_ROOT + enc(dir) + '/' + enc(file) }

$(function(){
var $index = $("#galleryIndex");
if (!$index.length) return;

$.getJSON(IMG_DATA).done(function(list){
if (!Array.isArray(list) || !list.length) {
$index.html('<p style="color:#999">暂无相册</p>');
return;
}

var html = list.map(function(g){
var name = g.name || '';
var first = (g.children && g.children[0]) ? String(g.children[0]).split(' ')[1] : null;
var cover = first ? build(name, first) : 'https://via.placeholder.com/400x240?text=No+Image';
var tilt = 'tilt-' + (1 + Math.floor(Math.random()*6));

// ★ 用相对路径 ../gallery/,从 /galleries/ 跳到 /gallery/,任何根路径都成立
return '' +
'<a class="gcard '+tilt+'" href="../gallery/?group='+ enc(name) +'">' +
'<div class="gimg"><img src="'+cover+'" alt="'+name+'"></div>' +
'<div class="gmeta"><div class="gtitle">'+name+'</div>' +
'<div class="gsub">共 '+(g.children?g.children.length:0)+' 张</div></div>' +
'</a>';
}).join('');

$index.html(html);
}).fail(function(_,s,e){
console.error('[gallery-index] photos.json 加载失败:', s, e);
});
});

用 Butterfly 的 inject 注入配置文件

打开 themes/butterfly/_config.yml,增加(或合并到你自己的 inject):

inject:
head:
# 相册页需要的东西
- '<link rel="stylesheet" href="https://cdn.staticfile.org/bootstrap/4.6.2/css/bootstrap.min.css">'
- '<link rel="stylesheet" href="https://cdn.staticfile.org/fancybox/3.5.7/jquery.fancybox.min.css">'
- |
<style>
.gallery-index, .ImageGrid { width: min(1200px, calc(100% - 32px)); margin: 12px auto 24px; }
.post-block:has(#galleryIndex){ background:transparent!important; box-shadow:none!important; padding:0!important; }
.post-title:has(+ .post-content #galleryIndex),
.post-meta:has(+ .post-content #galleryIndex),
.post-content:has(#galleryIndex) > *:not(#galleryIntro):not(#galleryIndex){ display:none!important; }

/* 引言容器样式(放大 + 增距 + 现代卡片效果) */
.gallery-intro {
/* 尺寸更大 */
width: min(2000px, calc(100% - 1px));
margin: -80px auto 44px; /* ↑ 上 28px,↓ 下 44px:与下面内容拉开距离 */
padding: 22px 28px;

/* 排版与可读性 */
text-align: center;
font-size: 16px; /* 整体字更大 */
line-height: 1.9;

/* 背景与毛玻璃 */
background: rgb(166, 169, 175);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);

/* 形状与投影 */
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.18);

/* 悬停动效(轻微上浮 + 放大) */
transform: translateY(0) scale(1);
transition: transform .25s ease, box-shadow .25s ease, filter .25s ease;
will-change: transform;

position: relative; /* 供 ::before 定位用 */
}

.gallery-intro:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 16px 40px rgba(0,0,0,0.24);
}

/* 渐变边框(不遮住内容) */
.gallery-intro::before {
content: '';
position: absolute;
inset: 0;
padding: 2px; /* 边框粗细 */
border-radius: inherit;
background: linear-gradient(135deg, #4facfe, #00f2fe, #f093fb, #f5576c);

/* 用蒙版把中间“挖空”,只显示边框 */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;

pointer-events: none;
z-index: 0;
}



.gallery-index{ display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:22px; }
.gcard{ width:240px; cursor:pointer; user-select:none; transform-origin:50% 80%;
transition:transform .25s ease, filter .25s ease; filter:drop-shadow(0 10px 20px rgba(0,0,0,.18)); text-decoration:none; }
.gcard:hover{ transform:rotate(0deg) translateY(-6px) scale(1.02); filter:drop-shadow(0 14px 28px rgba(0,0,0,.22)); }
.gimg{ width:100%; height:150px; border-radius:12px; overflow:hidden; background:#111; }
.gimg img{ width:100%; height:100%; object-fit:cover; display:block; }
.gmeta{ text-align:center; margin-top:8px; line-height:1.25; color:inherit; }
.gtitle{ font-weight:700; }
.gsub{ font-size:12px; opacity:.75; margin-top:4px; }
.tilt-1{ transform:rotate(-4deg) } .tilt-2{ transform:rotate(-2.5deg) }
.tilt-3{ transform:rotate(-1.5deg) } .tilt-4{ transform:rotate(1.5deg) }
.tilt-5{ transform:rotate(2.5deg) } .tilt-6{ transform:rotate(4deg) }

.card{ border-radius:14px; overflow:hidden; box-shadow:0 6px 18px rgba(0,0,0,.08) }
.ImageInCard{ overflow:hidden; border-radius:14px }
.card img{ width:100%; height:100%; object-fit:cover; display:block }
.gallery-title{ width:min(1200px, calc(100% - 32px)); margin:16px auto 8px; font-weight:700; font-size:20px; }
/* 用伪元素显示我们设置的标题;把原文字隐藏掉 */
#page-header .dyn-title{ color: transparent !important; position: relative; }
#page-header .dyn-title::after{
content: attr(data-group); /* 读取元素属性 data-group 作为标题 */
color: inherit;
position: absolute;
left: 0; top: 0;
}
</style>
# 自定义样式
- '<style>
/* 让 nav 成为定位容器 */
#nav{position:relative;}

/* 方案 A:适配大多数版本的菜单容器 */
#nav .menus_items{
position:absolute;
left:50%;
top:20%;
transform:translateX(-50%);
display:flex;
gap:.1rem;
white-space:nowrap;
/* 避免被右侧按钮挤压 */
max-width:calc(100% - 240px);
}

/* 方案 B:部分版本菜单容器是 .menus */
#nav .menus{
display:flex !important;
justify-content:center !important;
width:100% !important;
}

/* 保留左右两侧布局(logo 左、右侧按钮在右) */
#nav .site-brand, #nav .brand, #nav .site-title{
position:relative; z-index:2;
}
#nav .right, #nav .right-menu{
position:relative; z-index:2;
margin-left:auto;
}

/* 菜单项间距(可调) */
#nav .menus_items>li, #nav .menus>li{
margin:0 .5rem;
}
</style>'
bottom:
# 相册页需要的东西
- '<script src="https://cdn.staticfile.org/jquery/3.6.4/jquery.min.js"></script>'
- '<script src="https://cdn.staticfile.org/bootstrap/4.6.2/js/bootstrap.bundle.min.js"></script>'
- '<script src="https://gcore.jsdelivr.net/npm/minigrid@3.1.1/dist/minigrid.min.js"></script>'
- '<script src="https://cdn.staticfile.org/fancybox/3.5.7/jquery.fancybox.min.js"></script>'
- '<script defer src="/js/gallery-index.js"></script>'
- '<script defer src="/js/gallery-group.js"></script>'

最后 hexo clean && hexo g && hexo s,本地部署看看效果。

如果你的相册中图片比较大的话我写了一个程序来处理

新建一个compress_gallery.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# python compress_gallery.py --src .\gallery --dst .\gallery_5MB
# python compress_gallery.py --src .\gallery --dst .\gallery_5MB --limit 5
# python compress_gallery.py --src .\gallery --dst .\gallery_10MB --limit 10


import os, sys, io, shutil, argparse
from pathlib import Path
from PIL import Image, ImageOps

# 可选支持 HEIC/HEIF
try:
from pillow_heif import register_heif_opener # type: ignore
register_heif_opener()
except Exception:
pass

IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp", ".heic", ".heif"}

def is_image_file(p: Path) -> bool:
return p.suffix.lower() in IMAGE_EXTS

def ensure_dir(p: Path):
p.parent.mkdir(parents=True, exist_ok=True)

def normalize_orientation(img: Image.Image, strategy: str = "auto") -> tuple[Image.Image, bytes | None]:
"""
返回 (像素已标准化的图像, EXIF字节或None)。写回的EXIF已将 Orientation(274)=1。
strategy: "auto" | "force" | "strip"
"""
try:
exif = img.getexif()
except Exception:
exif = None

ori = 1
if exif and len(exif):
ori = exif.get(274, 1)

if strategy == "strip":
if exif:
exif[274] = 1
return img, exif.tobytes()
return img, None

if strategy == "force":
base = ImageOps.exif_transpose(img)
if exif:
exif[274] = 1
return base, exif.tobytes()
return base, None

# strategy == "auto"
w, h = img.size
need_rotate = False
if ori in (3, 4): # 180°
need_rotate = True
elif ori in (5, 6, 7, 8): # 90°/270°
need_rotate = (w >= h) # 横图才旋转;已是竖图则不旋

base = ImageOps.exif_transpose(img) if need_rotate else img
if exif:
exif[274] = 1
return base, exif.tobytes()
return base, None

def save_jpeg_under_limit(img: Image.Image,
limit_bytes: int,
exif_bytes: bytes | None,
icc: bytes | None,
min_side: int = 800,
quality_steps = (92, 85, 80, 72, 65, 60, 50)) -> bytes:
"""逐步降质,必要时等比缩放,直到 <= limit_bytes。此时 img 已完成方向归一化。"""
base = img
if base.mode not in ("RGB", "L"):
base = base.convert("RGB")

w, h = base.size
scale = 1.0
last_data = None

while True:
work = base if scale == 1.0 else base.resize((max(1,int(w*scale)), max(1,int(h*scale))), Image.LANCZOS)

for q in quality_steps:
buf = io.BytesIO()
save_kwargs = dict(format="JPEG", quality=q, optimize=True, progressive=True, subsampling="4:2:0")
if exif_bytes: save_kwargs["exif"] = exif_bytes
if icc: save_kwargs["icc_profile"] = icc
work.save(buf, **save_kwargs)
data = buf.getvalue()
last_data = data
if len(data) <= limit_bytes:
return data

if min(work.size) <= min_side:
return last_data
scale *= 0.85

def save_webp_under_limit(img: Image.Image,
limit_bytes: int,
exif_bytes: bytes | None,
icc: bytes | None,
min_side: int = 800,
quality_steps = (95, 90, 85, 80, 75, 70, 65, 60)) -> bytes:
"""保存为 WebP(可带透明),逐步降质与缩放至 <= limit_bytes。此时 img 已完成方向归一化。"""
base = img
has_alpha = (base.mode in ("RGBA", "LA")) or (base.mode == "P" and "transparency" in base.info)
if has_alpha:
if base.mode != "RGBA":
base = base.convert("RGBA")
else:
if base.mode not in ("RGB", "L"):
base = base.convert("RGB")

w, h = base.size
scale = 1.0
last_data = None

while True:
work = base if scale == 1.0 else base.resize((max(1,int(w*scale)), max(1,int(h*scale))), Image.LANCZOS)

for q in quality_steps:
buf = io.BytesIO()
save_kwargs = dict(format="WEBP", quality=q, method=6)
if exif_bytes: save_kwargs["exif"] = exif_bytes # 某些查看器可能忽略 WebP EXIF,但我们仍写入
if icc: save_kwargs["icc_profile"] = icc
work.save(buf, **save_kwargs)
data = buf.getvalue()
last_data = data
if len(data) <= limit_bytes:
return data

if min(work.size) <= min_side:
return last_data
scale *= 0.85

def compress_one(input_path: Path, output_path: Path, limit_bytes: int, min_side: int = 800, orient_strategy: str = "auto") -> tuple[bool, str]:
try:
if input_path.stat().st_size <= limit_bytes:
ensure_dir(output_path)
shutil.copy2(input_path, output_path)
return True, f"SKIP (<= limit): {input_path}"

with Image.open(input_path) as im:
base, exif_bytes = normalize_orientation(im, strategy=orient_strategy)
icc = im.info.get("icc_profile", None)
ext = input_path.suffix.lower()

if ext == ".png":
data = save_webp_under_limit(base, limit_bytes, exif_bytes, icc, min_side=min_side)
out = output_path.with_suffix(".webp")
ensure_dir(out)
with open(out, "wb") as f:
f.write(data)
return True, f"PNG->WebP->OK: {input_path} -> {out.name}"

elif ext in (".jpg", ".jpeg", ".webp", ".tif", ".tiff", ".bmp", ".heic", ".heif"):
data = save_jpeg_under_limit(base, limit_bytes, exif_bytes, icc, min_side=min_side)
out = output_path.with_suffix(".jpg")
ensure_dir(out)
with open(out, "wb") as f:
f.write(data)
return True, f"{ext.upper().lstrip('.')}->JPEG->OK: {input_path} -> {out.name}"

else:
data = save_jpeg_under_limit(base, limit_bytes, exif_bytes, icc, min_side=min_side)
out = output_path.with_suffix(".jpg")
ensure_dir(out)
with open(out, "wb") as f:
f.write(data)
return True, f"OTHER->JPEG->OK: {input_path} -> {out.name}"

except Exception as e:
return False, f"FAIL: {input_path} ({e})"

def walk_and_compress(src: Path, dst: Path, limit_mb: float, min_side: int, orient_strategy: str):
limit_bytes = int(limit_mb * 1024 * 1024)
total = ok = fail = 0
for root, _, files in os.walk(src):
for name in files:
total += 1
in_path = Path(root) / name
rel = in_path.relative_to(src)
out_path = (dst / rel)

if not is_image_file(in_path):
try:
ensure_dir(out_path)
shutil.copy2(in_path, out_path)
ok += 1
print(f"COPY (non-image): {in_path}")
except Exception as e:
fail += 1
print(f"FAIL COPY: {in_path} ({e})")
continue

success, msg = compress_one(in_path, out_path, limit_bytes, min_side=min_side, orient_strategy=orient_strategy)
print(msg)
ok += int(success)
fail += int(not success)

print("\n=== Summary ===")
print(f"Source: {src}")
print(f"Output: {dst}")
print(f"Limit : {limit_mb} MB")
print(f"Total : {total} | OK: {ok} | FAIL: {fail}")

def main():
ap = argparse.ArgumentParser(description="Compress images; PNG->WebP (alpha), others->JPEG. Orientation fixed.")
ap.add_argument("--src", type=str, required=True, help="Source folder (e.g., ./gallery)")
ap.add_argument("--dst", type=str, required=True, help="Output folder (e.g., ./gallery_5MB)")
ap.add_argument("--limit", type=float, default=5.0, help="Max size per image in MB (default: 5)")
ap.add_argument("--min-side", type=int, default=800, help="Do not scale below this shorter side (default: 800px)")
ap.add_argument("--orientation", type=str, choices=["auto","force","strip"], default="auto",
help="Orientation fix strategy: auto (default), force (always rotate by EXIF), strip (no rotate, set Orientation=1)")
args = ap.parse_args()

src = Path(args.src).resolve()
dst = Path(args.dst).resolve()

if not src.exists():
print(f"Source folder not found: {src}")
sys.exit(1)
dst.mkdir(parents=True, exist_ok=True)

walk_and_compress(src, dst, args.limit, args.min_side, args.orientation)

if __name__ == "__main__":
main()

运行命令是前几行

# python compress_gallery.py --src .\gallery --dst .\gallery_5MB
# python compress_gallery.py --src .\gallery --dst .\gallery_5MB --limit 5
# python compress_gallery.py --src .\gallery --dst .\gallery_10MB --limit 10

这里的 5MB/10MB 是最后需要限制的照片大小,如果想替换原相册的话就把后面的.\gallery_5MB或者.\gallery_10MB换成源目录.\gallery