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
欢迎关注微信公众号,留言交流;也欢迎使用微信小程序。

相關文章:

翻譯: