Hugo全站AVIF记

当你读到这篇文章的时候,本站除了一些 logo、thumbnail、icon(个人觉得不适合),绝大部分图片已完成从 png、jpg、jpeg、WebP 等到 AVIF 的切换。

所谓 AVIF(AV1 Image File Format)是基于 AV1 编码、开放免版税的高效图像格式,是当前“压缩效率 + 现代特性 + 开放生态”的最佳平衡方案,显著优于 JPEG、WebP、PNG 等传统格式。

说人话是:节约带宽、用户加载快、可能利于 SEO。

时代在变化,以前用 Webp,觉得已经很不错了,如今又得切换。年近八旬的老人唐纳德·特朗普都在为美国四处抢矿、抢油、抢地盘,给自己网站优化下图片方案有何不可?利己利人。

先看疗效,非常显著!同一个文件,不同格式的体积差别:

1➜ ls -al GPXSee.png
2-rw-r--r-- 1 mephisto mephisto 802779 Dec 10 20:35 GPXSee.png
3➜ ls -al GPXSee.webp
4-rw-r--r-- 1 mephisto mephisto 126820 Dec 10 20:35 GPXSee.webp
5➜ mogrify -format avif GPXSee.png
6➜ ls -lhrS --time-style=+"" GPXSee.*
7-rw-r--r-- 1 mephisto mephisto 103K  GPXSee.avif
8-rw-r--r-- 1 mephisto mephisto 124K  GPXSee.webp
9-rw-r--r-- 1 mephisto mephisto 784K  GPXSee.png

本站原始图片一般是 png 格式,来自各种截图软件的默认输出,我总是尽力保留原始图片和一个小体积压缩图片,之前是 png 和 webp,很明显现在要换成 png 和 AVIF 了,webp 后续可能考虑删除。

原始图片效果最清晰,以防不时之需,兜底的。

1. 如何转换格式

  • 单张转换
1magick input.png output.avif
  • 批量转换

批量操作要小心,你应该懂的!,毕竟受响图片多,mogrify 会保留原始图片(各个 Linux 发行版是否行为一致未测试)。

决定动手前,最好自己创建个临时目录,放入各种图片测试下,看看是否和预期一致。

1# jpg/jpeg/png/webp → avif
2mogrify -format avif *.jpg *.jpeg *.png *.webp

magickmogrify都来自 imagemagick 包,不得不说真的很强大。

1➜  ~ pacman -Qo $(which magick)
2/usr/bin/magick is owned by imagemagick 7.1.2.12-2
3➜  ~ pacman -Qo $(which mogrify)
4/usr/bin/mogrify is owned by imagemagick 7.1.2.12-2
  • 写程序转换

我让 AI 帮我写了个 Golang 程序,初步审核了下,符合自己需求。

需求:

  • 转换图片格式,优先级 png > jpg > jpeg > webp,因为我知道自己的图片源是这个顺序,有 png 优先使用 png 源转换压缩。
  • 帮忙修改 Markdown 中的图片连接
  1package main
  2
  3import (
  4	"flag"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"regexp"
 10	"strings"
 11)
 12
 13// 定义源文件优先级:png > jpg > jpeg > webp
 14var srcPriorities = []string{"png", "jpg", "jpeg", "webp"}
 15
 16// convertToAVIF 按优先级查找源文件并转换为 AVIF(支持深层目录)
 17// relDir: 图片相对于 imagesDir 的子目录(如 travel/wugongshan)
 18func convertToAVIF(imagesDir, relDir, filename string) (bool, error) {
 19	var srcPath string
 20	// 1. 拼接子目录路径
 21	fullRelDir := filepath.Join(imagesDir, relDir)
 22
 23	// 2. 按优先级查找源文件
 24	for _, ext := range srcPriorities {
 25		candidate := filepath.Join(fullRelDir, fmt.Sprintf("%s.%s", filename, ext))
 26		if _, err := os.Stat(candidate); err == nil {
 27			srcPath = candidate
 28			break
 29		}
 30	}
 31
 32	// 无可用源文件
 33	if srcPath == "" {
 34		return false, fmt.Errorf("无可用源文件(png/jpg/jpeg/webp)")
 35	}
 36
 37	// 3. 目标 AVIF 文件路径(保持原目录结构)
 38	avifPath := filepath.Join(fullRelDir, fmt.Sprintf("%s.avif", filename))
 39
 40	// 检查 AVIF 是否已存在,避免重复转换
 41	if _, err := os.Stat(avifPath); err == nil {
 42		fmt.Printf("ℹ️ AVIF 文件已存在,跳过: %s\n", avifPath)
 43		return true, nil
 44	}
 45
 46	// 4. 使用 ffmpeg 转换为 AVIF(适配 Arch Linux 的 libsvtav1 编码器)
 47	cmd := exec.Command(
 48		"ffmpeg",
 49		"-i", srcPath,          // 源文件
 50		"-c:v", "libsvtav1",    // Arch Linux 兼容的 AVIF 编码器
 51		"-crf", "30",           // 质量参数(0-63,值越小质量越好)
 52		"-preset", "6",         // 速度预设(0-13,值越小速度越慢/质量越高)
 53		"-g", "240",            // GOP size,适配图片转换
 54		"-pix_fmt", "yuv420p",  // 色彩格式,保证兼容性
 55		"-y",                   // 覆盖已存在的文件(前面已检查,此处为兜底)
 56		avifPath,
 57	)
 58
 59	// 执行转换并捕获输出
 60	output, err := cmd.CombinedOutput()
 61	if err != nil {
 62		return false, fmt.Errorf("转换失败: %v, 输出: %s", err, string(output))
 63	}
 64
 65	fmt.Printf("✅ 转换成功: %s → %s\n", srcPath, avifPath)
 66	return true, nil
 67}
 68
 69// replaceMDLinks 替换 Markdown 文件中的图片链接(支持深层目录)
 70func replaceMDLinks(mdPath, imagesDir string) (bool, error) {
 71	// 读取 Markdown 内容
 72	content, err := os.ReadFile(mdPath)
 73	if err != nil {
 74		return false, fmt.Errorf("读取 MD 文件失败: %v", err)
 75	}
 76	oldContent := string(content)
 77
 78	// 正则匹配 /images/ 下所有层级的图片链接:![xxx](/images/任意路径/filename.ext)
 79	// 关键修改:([^/.]+/)* 匹配任意层级的子目录(如 travel/wugongshan/)
 80	pattern := `(!\[[^\]]*\]\(/images/(([^/.]+/)*)([^/.]+))\.(webp|jpg|jpeg|png)(\))`
 81	re := regexp.MustCompile(pattern)
 82
 83	// 替换为 .avif 链接
 84	newContent := re.ReplaceAllString(oldContent, "${1}.avif${6}")
 85
 86	// 无内容变化
 87	if newContent == oldContent {
 88		return false, nil
 89	}
 90
 91	// 写入替换后的内容
 92	err = os.WriteFile(mdPath, []byte(newContent), 0644)
 93	if err != nil {
 94		return false, fmt.Errorf("写入 MD 文件失败: %v", err)
 95	}
 96
 97	fmt.Printf("✅ MD 链接替换完成: %s\n", mdPath)
 98	return true, nil
 99}
100
101func main() {
102	// 命令行参数
103	imagesDir := flag.String("images", "./images", "图片根目录路径")
104	mdDir := flag.String("md", ".", "Markdown 文件目录路径")
105	flag.Parse()
106
107	// 1. 验证图片目录是否存在
108	if _, err := os.Stat(*imagesDir); os.IsNotExist(err) {
109		fmt.Printf("❌ 图片目录不存在: %s\n", *imagesDir)
110		os.Exit(1)
111	}
112
113	// 2. 递归扫描图片目录所有层级,提取待转换文件(保留相对路径)
114	fmt.Println("===== 开始转换图片为 AVIF =====")
115	type imageItem struct {
116		relDir   string // 相对于 imagesDir 的子目录(如 travel/wugongshan)
117		filename string // 无后缀的文件名(如 IMG_2150)
118	}
119	var imageItems []imageItem
120	// 去重映射:key = relDir/filename,避免重复处理
121	processed := make(map[string]bool)
122
123	err := filepath.Walk(*imagesDir, func(path string, info os.FileInfo, err error) error {
124		if err != nil {
125			return err
126		}
127		// 跳过目录
128		if info.IsDir() {
129			return nil
130		}
131
132		// 过滤非目标格式文件
133		ext := strings.TrimPrefix(filepath.Ext(path), ".")
134		isTargetExt := false
135		for _, e := range srcPriorities {
136			if ext == e {
137				isTargetExt = true
138				break
139			}
140		}
141		if !isTargetExt {
142			return nil
143		}
144
145		// 计算相对路径和文件名
146		relPath, err := filepath.Rel(*imagesDir, path)
147		if err != nil {
148			fmt.Printf("⚠️ 无法计算相对路径: %s, 跳过\n", path)
149			return nil
150		}
151		// 拆分:relPath = relDir/filename.ext → relDir + filename
152		relDir := filepath.Dir(relPath)       // 相对子目录(如 travel/wugongshan)
153		baseName := filepath.Base(relPath)    // 带后缀的文件名(如 IMG_2150.webp)
154		filename := strings.TrimSuffix(baseName, filepath.Ext(baseName)) // 无后缀文件名
155
156		// 去重:避免同一文件(不同后缀)被重复处理
157		key := filepath.Join(relDir, filename)
158		if processed[key] {
159			return nil
160		}
161		processed[key] = true
162
163		imageItems = append(imageItems, imageItem{
164			relDir:   relDir,
165			filename: filename,
166		})
167		return nil
168	})
169
170	if err != nil {
171		fmt.Printf("❌ 扫描图片目录失败: %v\n", err)
172		os.Exit(1)
173	}
174
175	// 批量转换图片(支持深层目录)
176	convertCount := 0
177	for _, item := range imageItems {
178		success, err := convertToAVIF(*imagesDir, item.relDir, item.filename)
179		if success {
180			convertCount++
181		} else {
182			fullPath := filepath.Join(item.relDir, item.filename)
183			fmt.Printf("❌ 跳过 %s: %v\n", fullPath, err)
184		}
185	}
186
187	// 3. 替换 Markdown 中的图片链接(支持深层目录)
188	fmt.Println("\n===== 开始替换 MD 图片链接 =====")
189	mdCount := 0
190	modifiedMdCount := 0
191	err = filepath.Walk(*mdDir, func(path string, info os.FileInfo, err error) error {
192		if err != nil {
193			return err
194		}
195		// 仅处理 .md 文件
196		if !info.IsDir() && filepath.Ext(path) == ".md" {
197			mdCount++
198			modified, err := replaceMDLinks(path, *imagesDir)
199			if err != nil {
200				fmt.Printf("❌ 处理 MD 文件失败 %s: %v\n", path, err)
201			} else if modified {
202				modifiedMdCount++
203			} else {
204				fmt.Printf("ℹ️ 无需要替换的链接,跳过: %s\n", path)
205			}
206		}
207		return nil
208	})
209
210	if err != nil {
211		fmt.Printf("❌ 扫描 MD 目录失败: %v\n", err)
212		os.Exit(1)
213	}
214
215	// 4. 输出汇总信息
216	fmt.Println("\n===== 处理完成汇总 =====")
217	fmt.Printf("📸 图片转换:共找到 %d 个待处理文件,成功转换 %d 个 AVIF 文件\n", len(imageItems), convertCount)
218	fmt.Printf("📝 MD 替换:共扫描 %d 个 MD 文件,成功替换 %d 个文件的链接\n", mdCount, modifiedMdCount)
219}

AI 还挺厉害的,简单交互几次后,就能生成代码。

编译成可执行文件 img-convert-replace

1go build -o img-convert-replace img-convert-replace.go

自定义路径示例(比如图片目录是 /home/you/static/images md 文件目录是 /home/you/mds)

1./img-convert-replace -images /home/you/static/images -md /home/you/mds

友情提示,上述程序不适应于其它场景,每个站点逻辑不一样。比如 B 站这种切换 AVIF,肯定是个大工程。

如果你的需求很简单,那就不用写程序,用命令行处理也挺方便的,额外判断逻辑多就需要写程序处理。

2. 核验

转换完成后,git status、git diff 查看下,看看是否有问题,小心驶得万年船。

hugo 站点都是静态的,万一真的搞崩了,也能 git 回滚。

3. webserver 加上缓存仿盗链等

hugo 静态站点通常配合 Nginx、Caddy 来对外提供服务,可以配置下图片缓存和防盗链。

我用 caddy,diff 示例如下:

 1➜ git diff 7127 203d
 2diff --git a/Caddyfile b/Caddyfile
 3index 4c99e16..674de8b 100644
 4--- a/Caddyfile
 5+++ b/Caddyfile
 6@@ -86,13 +86,13 @@ mephisto.cc {
 7
 8        @static {
 9                file
10-               path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.webp *.avif
11+               path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.webp
12        }
13        header @static Cache-Control max-age=31536000
14
15        @invalid_referer {
16                file
17-               path *.webp *.png *.avif
18+               path *.webp *.png
19                not header Referer https://mephisto.cc*
20        }

丐版服务器小水管没办法,防君子不防小人。

最后修改于: Friday, January 16, 2026
欢迎关注微信公众号,留言交流;也欢迎使用微信小程序。

相关文章:

翻译: