目前博客部署在 Cloudflare Pages 服务上,通过 DnsPod 服务做了国内和海外分线路解析。

Cloudflare Pages
blog.liudon.xyz
   ↑         ↑
 回源       直接访问
   │         │
腾讯云 CDN    │
   ↑         ↑
   国内     海外
     \       /
      DNSPod
    liudon.com

因为 Cloudflare 在国内属于反向加速,所以加了一层国内CDN做加速。

但总感觉博客访问不快,随便打开一个页面,页面完全处理完都要在秒级,实在是慢。

通过 Lighthouse 测试,借助 AI 的能力,又做了一轮新的优化。

以下内容全部基于 PaperMod 主题进行修改。

1. 增加国内 CDN 节点缓存时间

腾讯 CDN 首页的响应:

cf-cache-status: DYNAMIC
cf-ray: ...-AMS
x-cache-lookup: Cache Miss
cache-control: public, must-revalidate, max-age=0
age: 0
x-nws-log-uuid: ...

请求确实经过腾讯云 CDN(x-nws-log-uuid),但腾讯边缘节点没有命中首页缓存,随后回源 Cloudflare,并落到 AMS(阿姆斯特丹)。

Cloudflare Pages 默认返回 max-age=0, must-revalidate,这是 Pages 的正常默认行为;但腾讯 CDN 如果“遵循源站”,就会频繁回源。

通过调整腾讯云 CDN 的缓存规则,增加强制缓存规则解决。

首页 缓存30分钟
/posts 缓存30分钟
/tags 缓存30分钟
/page 缓存30分钟

2. 延长指纹资源缓存时间

cache-control: public, must-revalidate, max-age=14400
age: 223
x-cache-lookup: Cache Hit

当前 CSS / JS 文件名已经包含 hash,可以设置更长的过期时间。

通过 _headers 文件配置缓存时间解决。

/assets/*
  Cache-Control: public, max-age=31536000, immutable

3. 修正响应式图片效果

终于来到这次优化的重头戏了。

这里之前其实做过一次优化,见当Hugo遇上AVIF,优化图片加载

当时引入了 AVIF 格式文件,增加了响应式图片效果。

但通过 Lighthouse 测试,仍有图片大小的问题。

比如广府古城一日游文章里,整个页面 Lighthouse 给出的图片优化空间是3,914 KiB,接近 4 MB。

当前页面的响应式代码如下:

<source
  type="image/avif"
  srcset="IMG_7537.PNG_1080x.avif 1080w"
  sizes="(min-width: 768px) 1080px, 100vw"
>

这里只有 1080x 这一个规格,并不能发挥响应式图片的作用。

这里是我理解有误,我理解成浏览器是按页面屏幕大小来做选择了,实际上是按这个元素的占位大小来选择不同规格。

页面里多图并排的情况,也会下载 1080x 这个规格的文件,导致大量的流量浪费。

另外还有一个问题是,现在的 AVIF / WEBP 格式文件都是提前预处理的。

因为每次都是全量生成,虽然现在只生成一个规格,但每次执行耗时都要在 10 分钟以上。

如果生成多个规格文件,会导致 Github Actions 执行非常慢。

怎么优化这个耗时,一直想优化来着,苦于找不到好的方案。

将这些问题反馈给 AI,给出了如下的方案:

通过预生成多个规格的 AVIF / WEBP 文件做响应式图片;

通过增加流水线缓存,避免每次都全量生成,只做增量更新。

3.1 流水线增加媒体处理逻辑

新增.github/scripts/media/package.json文件,内容如下:

{
  "name": "liudon-media-pipeline",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "dependencies": {
    "sharp": "0.35.0"
  }
}

新增.github/scripts/media/process-media.mjs文件,这里还加了视频处理/视频封面生成等逻辑,内容如下:

import sharp from "sharp";

import {
    access,
    copyFile,
    mkdir,
    readdir,
    rm,
    stat,
} from "node:fs/promises";

import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";

import path from "node:path";


// ============================================================
// Paths
// ============================================================

const ROOT = process.cwd();

const CONTENT_DIR =
    path.join(ROOT, "content/posts");

const CACHE_DIR =
    path.join(ROOT, ".cache/media");

const IMAGE_CACHE_DIR =
    path.join(CACHE_DIR, "images");

const VIDEO_CACHE_DIR =
    path.join(CACHE_DIR, "videos");

const FONT =
    path.join(
        ROOT,
        "static/ArchitectsDaughter-Regular.ttf"
    );

const SCRIPT_FILE =
    fileURLToPath(import.meta.url);


// ============================================================
// Image configuration
// ============================================================

const IMAGE_MAX_WIDTH = 1080;

const RESPONSIVE_WIDTHS = [
    320,
    480,
    720,
    1080,
];


// fallback JPG

const JPEG_QUALITY = 82;


// WebP

const WEBP_PHOTO_QUALITY = 75;
const WEBP_PICTURE_QUALITY = 80;


// AVIF

const AVIF_PHOTO_QUALITY = 50;
const AVIF_PICTURE_QUALITY = 55;


// ============================================================
// Watermark
// ============================================================

const WATERMARK_TEXT =
    "@liudon\nhttps://liudon.com";

const WATERMARK_COLOR =
    "#909090";

const WATERMARK_POINTSIZE = 48;

const WATERMARK_BOTTOM = 20;


// ============================================================
// Video configuration
// ============================================================

// 最大边 1280
//
// 横屏:
// 3840x2160 -> 1280x720
//
// 竖屏:
// 2160x3840 -> 720x1280

const VIDEO_MAX_EDGE = 1280;


// 平均码率高于 3500kbps 时压缩

const VIDEO_MAX_BITRATE_KBPS = 3500;


// H.264 CRF
//
// 23:质量和体积比较均衡

const VIDEO_CRF = 23;

const VIDEO_PRESET = "medium";


//  AAC 音频转换参数

const VIDEO_AUDIO_BITRATE = "128k";


// Poster

const POSTER_MAX_WIDTH = 1080;

const POSTER_QUALITY = 82;


// ============================================================
// Statistics
// ============================================================

let imageProcessed = 0;
let imageCached = 0;

let videoProcessed = 0;
let videoCached = 0;


// ============================================================
// Cache tracking
// ============================================================

const usedImageCaches = new Set();
const usedVideoCaches = new Set();


// ============================================================
// Basic utilities
// ============================================================

async function exists(file) {

    try {

        await access(file);

        return true;

    } catch {

        return false;
    }
}


async function sha256File(file) {

    return new Promise(
        (resolve, reject) => {

            const hash =
                createHash("sha256");

            const stream =
                createReadStream(file);

            stream.on(
                "data",
                chunk => hash.update(chunk)
            );

            stream.on(
                "error",
                reject
            );

            stream.on(
                "end",
                () => resolve(
                    hash.digest("hex")
                )
            );
        }
    );
}


function sha256String(value) {

    return createHash("sha256")
        .update(value)
        .digest("hex");
}


async function walk(dir) {

    const output = [];

    const entries =
        await readdir(
            dir,
            {
                withFileTypes: true,
            }
        );

    for (const entry of entries) {

        const full =
            path.join(
                dir,
                entry.name
            );

        if (entry.isDirectory()) {

            output.push(
                ...(await walk(full))
            );

        } else {

            output.push(full);
        }
    }

    return output;
}


// ============================================================
// Execute external command
// ============================================================

function run(
    command,
    args,
    {
        quiet = false,
    } = {}
) {

    return new Promise(
        (resolve, reject) => {

            const child =
                spawn(
                    command,
                    args,
                    {
                        stdio: [
                            "ignore",
                            "pipe",
                            "pipe",
                        ],
                    }
                );

            let stdout = "";
            let stderr = "";

            child.stdout.on(
                "data",
                data => {

                    stdout +=
                        data.toString();
                }
            );

            child.stderr.on(
                "data",
                data => {

                    stderr +=
                        data.toString();

                    if (!quiet) {

                        process.stderr.write(
                            data
                        );
                    }
                }
            );

            child.on(
                "error",
                reject
            );

            child.on(
                "close",
                code => {

                    if (code === 0) {

                        resolve({
                            stdout,
                            stderr,
                        });

                        return;
                    }

                    reject(
                        new Error(
                            `${command} failed with code ${code}\n${stderr}`
                        )
                    );
                }
            );
        }
    );
}


// ============================================================
// Image validation
// ============================================================

async function validateImage(
    file,
    expectedMediaType = null
) {

    if (!(await exists(file))) {

        return false;
    }

    try {

        const metadata =
            await sharp(file).metadata();

        if (
            !metadata.width ||
            !metadata.height
        ) {

            return false;
        }

        if (
            expectedMediaType &&
            metadata.mediaType !==
                expectedMediaType
        ) {

            return false;
        }

        return true;

    } catch {

        return false;
    }
}


// ============================================================
// Image orientation
// ============================================================

function getOrientedDimensions(metadata) {

    let width =
        metadata.width;

    let height =
        metadata.height;

    if (
        [
            5,
            6,
            7,
            8,
        ].includes(metadata.orientation)
    ) {

        [
            width,
            height,
        ] = [
            height,
            width,
        ];
    }

    return {
        width,
        height,
    };
}


// ============================================================
// Watermark
// ============================================================

async function createWatermark(
    targetWidth
) {

    // 防止小图中文字宽度超过图片。

    const maxWidth =
        Math.max(
            1,
            targetWidth - 20
        );

    const markup =
        `<span foreground="${WATERMARK_COLOR}">` +
        WATERMARK_TEXT +
        "</span>";

    return await sharp({
        text: {
            text:
                markup,

            font:
                `Architects Daughter ${WATERMARK_POINTSIZE}`,

            fontfile:
                FONT,

            width:
                maxWidth,

            align:
                "center",

            rgba:
                true,

            dpi:
                72,
        },
    })
        .png()
        .toBuffer({
            resolveWithObject: true,
        });
}


// ============================================================
// Restore image outputs
// ============================================================

async function restoreImageOutputs(
    src,
    base,
    cacheDir,
    widths
) {

    // fallback 原图

    await copyFile(
        base,
        src
    );


    // responsive variants

    for (const width of widths) {

        await copyFile(
            path.join(
                cacheDir,
                `${width}.webp`
            ),
            `${src}_${width}x.webp`
        );

        await copyFile(
            path.join(
                cacheDir,
                `${width}.avif`
            ),
            `${src}_${width}x.avif`
        );
    }
}


// ============================================================
// Process image
// ============================================================

async function processImage(
    src,
    scriptHash,
    fontHash
) {

    const ext =
        path.extname(src)
            .toLowerCase();

    const imageType =
        ext === ".png"
            ? "picture"
            : "photo";

    const webpQuality =
        imageType === "photo"
            ? WEBP_PHOTO_QUALITY
            : WEBP_PICTURE_QUALITY;

    const avifQuality =
        imageType === "photo"
            ? AVIF_PHOTO_QUALITY
            : AVIF_PICTURE_QUALITY;


    // --------------------------------------------------------
    // Source metadata
    // --------------------------------------------------------

    const metadata =
        await sharp(src).metadata();

    const oriented =
        getOrientedDimensions(
            metadata
        );

    const targetWidth =
        Math.min(
            IMAGE_MAX_WIDTH,
            oriented.width
        );


    // --------------------------------------------------------
    // Cache hash
    // --------------------------------------------------------

    const sourceHash =
        await sha256File(src);

    const config =
        JSON.stringify({
            type:
                "image",

            sourceHash,

            scriptHash,

            fontHash,

            IMAGE_MAX_WIDTH,

            RESPONSIVE_WIDTHS,

            JPEG_QUALITY,

            webpQuality,

            avifQuality,

            WATERMARK_TEXT,

            WATERMARK_COLOR,

            WATERMARK_POINTSIZE,

            WATERMARK_BOTTOM,
        });

    const hash =
        sha256String(config);

    usedImageCaches.add(hash);

    const cacheDir =
        path.join(
            IMAGE_CACHE_DIR,
            hash
        );

    await mkdir(
        cacheDir,
        {
            recursive: true,
        }
    );


    // --------------------------------------------------------
    // Widths
    //
    // 1080 -> 320 / 480 / 720 / 1080
    // 900  -> 320 / 480 / 720 / 900
    // 600  -> 320 / 480 / 600
    // --------------------------------------------------------

    const widths = [
        ...RESPONSIVE_WIDTHS.filter(
            width =>
                width <= targetWidth
        ),
        targetWidth,
    ];

    const finalWidths =
        [...new Set(widths)]
            .sort(
                (a, b) =>
                    a - b
            );


    const base =
        path.join(
            cacheDir,
            `source${ext}`
        );


    // --------------------------------------------------------
    // Cache validation
    // --------------------------------------------------------

    let cacheOK =
        await validateImage(base);

    if (cacheOK) {

        for (const width of finalWidths) {

            const webp =
                path.join(
                    cacheDir,
                    `${width}.webp`
                );

            const avif =
                path.join(
                    cacheDir,
                    `${width}.avif`
                );

            if (
                !(await validateImage(
                    webp,
                    "image/webp"
                )) ||
                !(await validateImage(
                    avif,
                    "image/avif"
                ))
            ) {

                cacheOK = false;

                break;
            }
        }
    }


    // --------------------------------------------------------
    // Cache hit
    // --------------------------------------------------------

    if (cacheOK) {

        console.log(
            `CACHE IMAGE   ${path.relative(ROOT, src)}`
        );

        await restoreImageOutputs(
            src,
            base,
            cacheDir,
            finalWidths
        );

        imageCached++;

        return;
    }


    // --------------------------------------------------------
    // Cache miss
    // --------------------------------------------------------

    console.log();
    console.log(
        `PROCESS IMAGE ${path.relative(ROOT, src)}`
    );

    await rm(
        cacheDir,
        {
            recursive: true,
            force: true,
        }
    );

    await mkdir(
        cacheDir,
        {
            recursive: true,
        }
    );


    // --------------------------------------------------------
    // Calculate final base dimensions
    // --------------------------------------------------------

    const scale =
        targetWidth /
        oriented.width;

    const targetHeight =
        Math.max(
            1,
            Math.round(
                oriented.height * scale
            )
        );


    // --------------------------------------------------------
    // Watermark
    // --------------------------------------------------------

    let watermark =
        await createWatermark(
            targetWidth
        );

    // 极端小图情况下避免 watermark 高于图片。

    const maxWatermarkHeight =
        Math.max(
            1,
            targetHeight -
            WATERMARK_BOTTOM
        );

    if (
        watermark.info.height >
        maxWatermarkHeight
    ) {

        const resized =
            await sharp(watermark.data)
                .resize({
                    height:
                        maxWatermarkHeight,

                    withoutEnlargement:
                        true,
                })
                .png()
                .toBuffer({
                    resolveWithObject:
                        true,
                });

        watermark = resized;
    }


    const left =
        Math.max(
            0,
            Math.round(
                (
                    targetWidth -
                    watermark.info.width
                ) / 2
            )
        );

    const top =
        Math.max(
            0,
            targetHeight -
            watermark.info.height -
            WATERMARK_BOTTOM
        );


    // --------------------------------------------------------
    // Generate fallback/base
    // --------------------------------------------------------

    let pipeline =
        sharp(src)
            .rotate()
            .resize({
                width:
                    IMAGE_MAX_WIDTH,

                withoutEnlargement:
                    true,

                fit:
                    "inside",
            })
            .toColourspace(
                "srgb"
            )
            .composite([
                {
                    input:
                        watermark.data,

                    left,

                    top,
                },
            ]);


    if (ext === ".png") {

        pipeline =
            pipeline.png({
                compressionLevel:
                    9,

                adaptiveFiltering:
                    true,
            });

    } else {

        pipeline =
            pipeline.jpeg({
                quality:
                    JPEG_QUALITY,

                progressive:
                    true,

                optimiseCoding:
                    true,
            });
    }


    await pipeline.toFile(base);


    if (!(await validateImage(base))) {

        throw new Error(
            `Invalid processed image: ${src}`
        );
    }


    // --------------------------------------------------------
    // Get actual width
    // --------------------------------------------------------

    const baseMetadata =
        await sharp(base).metadata();

    const actualWidth =
        baseMetadata.width;


    const actualWidths = [
        ...RESPONSIVE_WIDTHS.filter(
            width =>
                width <= actualWidth
        ),
        actualWidth,
    ];

    const outputWidths =
        [...new Set(actualWidths)]
            .sort(
                (a, b) =>
                    a - b
            );


    // --------------------------------------------------------
    // WebP + AVIF
    // --------------------------------------------------------

    for (const width of outputWidths) {

        console.log(
            `        generate ${width}w`
        );

        const webp =
            path.join(
                cacheDir,
                `${width}.webp`
            );

        const avif =
            path.join(
                cacheDir,
                `${width}.avif`
            );


        await sharp(base)
            .resize({
                width,

                withoutEnlargement:
                    true,
            })
            .webp({
                quality:
                    webpQuality,

                effort:
                    6,

                preset:
                    imageType,
            })
            .toFile(webp);


        await sharp(base)
            .resize({
                width,

                withoutEnlargement:
                    true,
            })
            .avif({
                quality:
                    avifQuality,

                effort:
                    5,

                chromaSubsampling:
                    imageType === "photo"
                        ? "4:2:0"
                        : "4:4:4",
            })
            .toFile(avif);


        if (
            !(await validateImage(
                webp,
                "image/webp"
            ))
        ) {

            throw new Error(
                `Invalid WebP: ${webp}`
            );
        }


        if (
            !(await validateImage(
                avif,
                "image/avif"
            ))
        ) {

            throw new Error(
                `Invalid AVIF: ${avif}`
            );
        }
    }


    await restoreImageOutputs(
        src,
        base,
        cacheDir,
        outputWidths
    );


    // --------------------------------------------------------
    // Stats
    // --------------------------------------------------------

    const largestWidth =
        outputWidths.at(-1);

    const baseStat =
        await stat(base);

    const webpStat =
        await stat(
            path.join(
                cacheDir,
                `${largestWidth}.webp`
            )
        );

    const avifStat =
        await stat(
            path.join(
                cacheDir,
                `${largestWidth}.avif`
            )
        );


    console.log(
        `        size     : ${baseMetadata.width}x${baseMetadata.height}`
    );

    console.log(
        `        variants : ${outputWidths.join(",")}`
    );

    console.log(
        `        base     : ${(baseStat.size / 1024).toFixed(1)} KB`
    );

    console.log(
        `        webp max : ${(webpStat.size / 1024).toFixed(1)} KB`
    );

    console.log(
        `        avif max : ${(avifStat.size / 1024).toFixed(1)} KB`
    );


    imageProcessed++;
}


// ============================================================
// FFprobe
// ============================================================

async function probeVideo(file) {

    const result =
        await run(
            "ffprobe",
            [
                "-v",
                "error",

                "-show_entries",
                "format=duration,bit_rate:stream=codec_type,codec_name,width,height,pix_fmt",

                "-of",
                "json",

                file,
            ],
            {
                quiet: true,
            }
        );

    return JSON.parse(
        result.stdout
    );
}


// ============================================================
// Validate processed video
// ============================================================

async function validateVideo(file) {

    if (!(await exists(file))) {

        return false;
    }

    try {

        const probe =
            await probeVideo(file);

        const video =
            probe.streams.find(
                stream =>
                    stream.codec_type ===
                    "video"
            );

        if (!video) {

            return false;
        }

        if (
            video.codec_name !== "h264"
        ) {

            return false;
        }

        if (
            video.pix_fmt !== "yuv420p"
        ) {

            return false;
        }

        if (
            Math.max(
                Number(video.width || 0),
                Number(video.height || 0)
            ) >
            VIDEO_MAX_EDGE
        ) {

            return false;
        }

        return true;

    } catch {

        return false;
    }
}


// ============================================================
// Process MP4
// ============================================================

async function processVideo(
    src,
    scriptHash
) {

    const sourceStat =
        await stat(src);

    const sourceHash =
        await sha256File(src);

    const probe =
        await probeVideo(src);


    const video =
        probe.streams.find(
            stream =>
                stream.codec_type ===
                "video"
        );

    const audio =
        probe.streams.find(
            stream =>
                stream.codec_type ===
                "audio"
        );


    if (!video) {

        throw new Error(
            `Video stream not found: ${src}`
        );
    }


    const width =
        Number(video.width || 0);

    const height =
        Number(video.height || 0);

    const duration =
        Number(
            probe.format.duration || 0
        );


    // --------------------------------------------------------
    // Calculate bitrate
    // --------------------------------------------------------

    let bitrateKbps =
        Number(
            probe.format.bit_rate || 0
        ) / 1000;


    // 某些 MP4 没有 bit_rate
    // 用文件大小 / 时长估算。

    if (
        bitrateKbps <= 0 &&
        duration > 0
    ) {

        bitrateKbps =
            (
                sourceStat.size *
                8 /
                duration /
                1000
            );
    }


    // --------------------------------------------------------
    // Decide transcode
    // --------------------------------------------------------

    const transcodeVideo =
        video.codec_name !== "h264" ||

        video.pix_fmt !== "yuv420p" ||

        Math.max(
            width,
            height
        ) > VIDEO_MAX_EDGE ||

        (
            bitrateKbps > 0 &&
            bitrateKbps >
                VIDEO_MAX_BITRATE_KBPS
        );


    const transcodeAudio =
        Boolean(
            audio &&
            audio.codec_name !== "aac"
        );


    // --------------------------------------------------------
    // Cache hash
    // --------------------------------------------------------

    const config =
        JSON.stringify({
            type:
                "video",

            sourceHash,

            scriptHash,

            VIDEO_MAX_EDGE,

            VIDEO_MAX_BITRATE_KBPS,

            VIDEO_CRF,

            VIDEO_PRESET,

            VIDEO_AUDIO_BITRATE,

            POSTER_MAX_WIDTH,

            POSTER_QUALITY,

            transcodeVideo,

            transcodeAudio,
        });


    const hash =
        sha256String(config);

    usedVideoCaches.add(hash);

    const cacheDir =
        path.join(
            VIDEO_CACHE_DIR,
            hash
        );

    const outputVideo =
        path.join(
            cacheDir,
            "video.mp4"
        );

    const poster =
        path.join(
            cacheDir,
            "poster.webp"
        );


    await mkdir(
        cacheDir,
        {
            recursive: true,
        }
    );


    // --------------------------------------------------------
    // Cache hit
    // --------------------------------------------------------

    if (
        await validateVideo(outputVideo) &&
        await validateImage(
            poster,
            "image/webp"
        )
    ) {

        console.log(
            `CACHE VIDEO   ${path.relative(ROOT, src)}`
        );

        await copyFile(
            outputVideo,
            src
        );

        await copyFile(
            poster,
            `${src}.poster.webp`
        );

        videoCached++;

        return;
    }


    // --------------------------------------------------------
    // Cache miss
    // --------------------------------------------------------

    console.log();
    console.log(
        `PROCESS VIDEO ${path.relative(ROOT, src)}`
    );

    console.log(
        `        video    : ${video.codec_name}`
    );

    console.log(
        `        pix_fmt  : ${video.pix_fmt}`
    );

    console.log(
        `        audio    : ${audio?.codec_name || "none"}`
    );

    console.log(
        `        size     : ${width}x${height}`
    );

    console.log(
        `        bitrate  : ${bitrateKbps.toFixed(0)} kbps`
    );

    console.log(
        `        duration : ${duration.toFixed(1)} sec`
    );


    await rm(
        cacheDir,
        {
            recursive: true,
            force: true,
        }
    );

    await mkdir(
        cacheDir,
        {
            recursive: true,
        }
    );


    // --------------------------------------------------------
    // FFmpeg
    // --------------------------------------------------------

    const args = [
        "-hide_banner",
        "-loglevel",
        "warning",

        "-y",

        "-i",
        src,

        "-map",
        "0:v:0",

        "-map",
        "0:a?",

        "-map_metadata",
        "0",
    ];


    // --------------------------------------------------------
    // Video codec
    // --------------------------------------------------------

    if (transcodeVideo) {

        args.push(
            "-vf",
            `scale=w='min(iw,${VIDEO_MAX_EDGE})':h='min(ih,${VIDEO_MAX_EDGE})':force_original_aspect_ratio=decrease:force_divisible_by=2`,

            "-c:v",
            "libx264",

            "-preset",
            VIDEO_PRESET,

            "-crf",
            String(VIDEO_CRF),

            "-pix_fmt",
            "yuv420p"
        );

    } else {

        args.push(
            "-c:v",
            "copy"
        );
    }


    // --------------------------------------------------------
    // Audio codec
    // --------------------------------------------------------

    if (audio) {

        if (transcodeAudio) {

            args.push(
                "-c:a",
                "aac",

                "-b:a",
                VIDEO_AUDIO_BITRATE
            );

        } else {

            args.push(
                "-c:a",
                "copy"
            );
        }
    }


    // --------------------------------------------------------
    // MP4 fast start
    //
    //  moov atom 放文件前部,
    // 浏览器不用等下载完整视频才能开始播放。
    // --------------------------------------------------------

    args.push(
        "-movflags",
        "+faststart",

        outputVideo
    );


    if (transcodeVideo) {

        console.log(
            "        mode     : transcode"
        );

    } else if (transcodeAudio) {

        console.log(
            "        mode     : audio transcode"
        );

    } else {

        console.log(
            "        mode     : remux"
        );
    }


    await run(
        "ffmpeg",
        args
    );


    // --------------------------------------------------------
    // Validate video
    // --------------------------------------------------------

    if (!(await validateVideo(outputVideo))) {

        throw new Error(
            `Invalid generated video: ${src}`
        );
    }


    // --------------------------------------------------------
    // Poster frame
    //
    // 取视频 10% 位置,
    // 最大不超过第 3 秒。
    // --------------------------------------------------------

    const posterTime =
        duration > 0
            ? Math.min(
                3,
                duration * 0.1
            )
            : 0;


    const frame =
        path.join(
            cacheDir,
            "poster-frame.png"
        );


    await run(
        "ffmpeg",
        [
            "-hide_banner",
            "-loglevel",
            "error",

            "-y",

            "-i",
            outputVideo,

            "-ss",
            posterTime.toFixed(3),

            "-frames:v",
            "1",

            "-an",

            frame,
        ]
    );


    if (!(await validateImage(frame))) {

        throw new Error(
            `Failed to generate video poster: ${src}`
        );
    }


    // --------------------------------------------------------
    // Poster -> WebP
    // --------------------------------------------------------

    await sharp(frame)
        .resize({
            width:
                POSTER_MAX_WIDTH,

            withoutEnlargement:
                true,
        })
        .webp({
            quality:
                POSTER_QUALITY,

            effort:
                6,
        })
        .toFile(poster);


    await rm(
        frame,
        {
            force: true,
        }
    );


    if (
        !(await validateImage(
            poster,
            "image/webp"
        ))
    ) {

        throw new Error(
            `Invalid poster: ${poster}`
        );
    }


    // --------------------------------------------------------
    // Copy back into Hugo page bundle
    // --------------------------------------------------------

    await copyFile(
        outputVideo,
        src
    );

    await copyFile(
        poster,
        `${src}.poster.webp`
    );


    // --------------------------------------------------------
    // Stats
    // --------------------------------------------------------

    const outputStat =
        await stat(outputVideo);

    const posterStat =
        await stat(poster);


    console.log(
        `        input    : ${(sourceStat.size / 1024 / 1024).toFixed(2)} MB`
    );

    console.log(
        `        output   : ${(outputStat.size / 1024 / 1024).toFixed(2)} MB`
    );

    console.log(
        `        poster   : ${(posterStat.size / 1024).toFixed(1)} KB`
    );


    videoProcessed++;
}


// ============================================================
// Prune obsolete per-file cache
// ============================================================

async function pruneCache(
    dir,
    used
) {

    if (!(await exists(dir))) {

        return;
    }

    const entries =
        await readdir(
            dir,
            {
                withFileTypes: true,
            }
        );


    for (const entry of entries) {

        if (!entry.isDirectory()) {

            continue;
        }


        if (!used.has(entry.name)) {

            console.log(
                `PRUNE CACHE   ${path.relative(ROOT, path.join(dir, entry.name))}`
            );

            await rm(
                path.join(
                    dir,
                    entry.name
                ),
                {
                    recursive: true,
                    force: true,
                }
            );
        }
    }
}


// ============================================================
// Main
// ============================================================

async function main() {

    await mkdir(
        IMAGE_CACHE_DIR,
        {
            recursive: true,
        }
    );

    await mkdir(
        VIDEO_CACHE_DIR,
        {
            recursive: true,
        }
    );


    if (!(await exists(FONT))) {

        throw new Error(
            `Watermark font not found: ${FONT}`
        );
    }


    const scriptHash =
        await sha256File(
            SCRIPT_FILE
        );

    const fontHash =
        await sha256File(
            FONT
        );


    // --------------------------------------------------------
    // Runtime info
    // --------------------------------------------------------

    const ffmpegVersion =
        await run(
            "ffmpeg",
            [
                "-version",
            ],
            {
                quiet: true,
            }
        );


    console.log(
        "========================================"
    );

    console.log(
        "Media environment"
    );

    console.log(
        "========================================"
    );

    console.log(
        `Sharp   : ${sharp.versions.sharp}`
    );

    console.log(
        `libvips : ${sharp.versions.vips}`
    );

    console.log(
        `FFmpeg  : ${ffmpegVersion.stdout.split("\n")[0]}`
    );

    console.log();


    // --------------------------------------------------------
    // Find sources
    // --------------------------------------------------------

    const files =
        await walk(
            CONTENT_DIR
        );


    const images =
        files.filter(file => {

            const ext =
                path.extname(file)
                    .toLowerCase();

            return [
                ".jpg",
                ".jpeg",
                ".png",
            ].includes(ext);
        });


    const videos =
        files.filter(file => {

            return (
                path.extname(file)
                    .toLowerCase() ===
                ".mp4"
            );
        });


    console.log(
        `Images : ${images.length}`
    );

    console.log(
        `Videos : ${videos.length}`
    );

    console.log();


    // --------------------------------------------------------
    // Images
    // --------------------------------------------------------

    for (const image of images) {

        await processImage(
            image,
            scriptHash,
            fontHash
        );
    }


    // --------------------------------------------------------
    // Videos
    // --------------------------------------------------------

    for (const video of videos) {

        await processVideo(
            video,
            scriptHash
        );
    }


    // --------------------------------------------------------
    // Prune
    // --------------------------------------------------------

    await pruneCache(
        IMAGE_CACHE_DIR,
        usedImageCaches
    );

    await pruneCache(
        VIDEO_CACHE_DIR,
        usedVideoCaches
    );


    // --------------------------------------------------------
    // Result
    // --------------------------------------------------------

    console.log();

    console.log(
        "========================================"
    );

    console.log(
        "Media processing finished"
    );

    console.log(
        "========================================"
    );

    console.log(
        `Images processed : ${imageProcessed}`
    );

    console.log(
        `Images cached    : ${imageCached}`
    );

    console.log(
        `Videos processed : ${videoProcessed}`
    );

    console.log(
        `Videos cached    : ${videoCached}`
    );

    console.log(
        "========================================"
    );
}


main().catch(error => {

    console.error();

    console.error(
        "MEDIA PIPELINE FAILED"
    );

    console.error(
        error
    );

    process.exit(1);
});

调整 .github/workflows/main.yml 文件,修改预处理逻辑为下面内容:

      # ------------------------------------------------------
      # Media cache
      #
      # 第一次:
      #
      # media-v1 不存在
      # -> 全量图片 + 视频处理
      #
      # 后续:
      #
      # source 未变化
      # -> exact cache hit
      #
      # 修改一张图片/视频
      # -> restore 最近的 media-v1
      # -> 只处理变化文件
      # ------------------------------------------------------

      - name: Restore media cache
        uses: actions/cache@v5
        with:
          path: .cache/media
          key: media-v1-${{ hashFiles('content/posts/**/*.jpg', 'content/posts/**/*.jpeg', 'content/posts/**/*.png', 'content/posts/**/*.JPG', 'content/posts/**/*.JPEG', 'content/posts/**/*.PNG', 'content/posts/**/*.mp4', 'content/posts/**/*.MP4', 'static/ArchitectsDaughter-Regular.ttf', '.github/scripts/media/package.json', '.github/scripts/media/process-media.mjs') }}
          restore-keys: |
            media-v1-

      # ------------------------------------------------------
      # Node.js
      # ------------------------------------------------------

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      # ------------------------------------------------------
      # Sharp
      #
      # 使用 Sharp 官方预编译 Linux 二进制。
      #
      # 不再需要:
      #
      # build-essential
      # libheif-dev
      # libaom-dev
      # libwebp-dev
      # ImageMagick configure/make
      # ------------------------------------------------------

      - name: Install Sharp
        run: |
          npm install \
            --prefix .github/scripts/media \
            --omit=dev \
            --no-audit \
            --no-fund

      # ------------------------------------------------------
      # FFmpeg
      # ------------------------------------------------------

      - name: Setup FFmpeg
        shell: bash
        run: |
          set -euo pipefail

          NEED_APT=0

          if ! command -v ffmpeg >/dev/null 2>&1; then
            NEED_APT=1
          fi

          if ! command -v ffprobe >/dev/null 2>&1; then
            NEED_APT=1
          fi

          if ! command -v fc-match >/dev/null 2>&1; then
            NEED_APT=1
          fi

          if [[ "$NEED_APT" == "1" ]]; then
            sudo apt-get update

            sudo apt-get install -y \
              ffmpeg \
              fontconfig
          fi

          echo
          echo "FFmpeg:"
          ffmpeg -version | head -n 1

          echo
          echo "FFprobe:"
          ffprobe -version | head -n 1

          echo
          echo "Checking H.264 encoder..."

          if ! ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264; then
            echo "ERROR: FFmpeg does not contain libx264 encoder"
            exit 1
          fi

          echo "libx264: OK"

      # ------------------------------------------------------
      # Process images + videos
      # ------------------------------------------------------

      - name: Process media
        run: |
          node .github/scripts/media/process-media.mjs

把 ImageMagic 换成了 Sharp,压缩后的文件更小一些。

3.2 Hugo 图片解析响应式调整

新增 layouts/_default/_markup/render-image.html文件,内容如下:

{{- /*
响应式图片尺寸:

普通大图:
320 / 480 / 720 / 1080

如果源图片小于 1080
还会自动加入图片实际宽度。

例如:
源图 900px
 320 / 480 / 720 / 900

源图 600px
 320 / 480 / 600
*/ -}}

{{- $respSizes := slice 320 480 720 1080 -}}

{{- /*
auto:
现代浏览器根据 CSS 布局后的实际尺寸选择图片。

fallback:
如果浏览器不支持 auto,则最大按照 1080px 处理。
*/ -}}

{{- $dataSizes := site.Params.responsiveImageSizes | default "auto, (min-width: 1080px) 1080px, calc(100vw - 32px)" -}}

{{- $filter := "box" -}}

{{- $Destination := .Destination -}}
{{- $Page := .Page -}}
{{- $Text := .Text -}}
{{- $Title := .Title -}}

{{- $responsiveImages := (.Page.Params.responsiveImages | default site.Params.responsiveImages) | default true -}}

{{- with $src := .Page.Resources.GetMatch .Destination -}}

    {{- if $responsiveImages -}}

        {{- /*
        根据源图片宽度生成实际候选尺寸。

        最大不会超过源图。
        */ -}}

        {{- $candidateSizes := slice -}}

        {{- range $size := $respSizes -}}
            {{- if ge $src.Width $size -}}
                {{- $candidateSizes = $candidateSizes | append $size -}}
            {{- end -}}
        {{- end -}}

        {{- /*
        如果源图片实际宽度不是标准档位,
        把实际宽度作为最后一档。

        例如 900px
        320 / 480 / 720 / 900
        */ -}}

        {{- if not (in $candidateSizes $src.Width) -}}
            {{- $candidateSizes = $candidateSizes | append $src.Width -}}
        {{- end -}}

        {{- /*
        优先 AVIF,再 WebP
        */ -}}

        {{- $imageTypes := slice "avif" "webp" -}}

        <picture>

            {{- range $imageType := $imageTypes -}}

                {{- $srcset := slice -}}

                {{- range $size := $candidateSizes -}}

                    {{- $compressedImage := printf "%s_%dx.%s" $Destination $size $imageType -}}

                    {{- $cmSrc := $Page.Resources.GetMatch $compressedImage -}}

                    {{- if $cmSrc -}}

                        {{- $url := $cmSrc.RelPermalink | absURL -}}

                        {{- /*
                        用实际图片宽度作为 descriptor
                        不依赖文件名里的数字。
                        */ -}}

                        {{- $candidate := printf "%s %dw" $url $cmSrc.Width -}}

                        {{- $srcset = $srcset | append $candidate -}}

                    {{- else if and (eq $imageType "webp") hugo.IsExtended -}}

                        {{- /*
                        本地 hugo server 没有预处理文件时,
                        WebP 仍然可以让 Hugo 自己生成。

                        AVIF 则依赖 GitHub Action 预处理。
                        */ -}}

                        {{- $resized := $src.Resize (printf "%dx %s %s" $size $imageType $filter) -}}

                        {{- $url := $resized.RelPermalink | absURL -}}

                        {{- $candidate := printf "%s %dw" $url $resized.Width -}}

                        {{- $srcset = $srcset | append $candidate -}}

                    {{- end -}}

                {{- end -}}

                {{- if gt (len $srcset) 0 -}}

                    <source
                        type="image/{{ $imageType }}"
                        srcset="{{ delimit $srcset ", " }}"
                        sizes="{{ $dataSizes }}"
                    />

                {{- end -}}

            {{- end -}}

            <img
                src="{{ $Destination | safeURL }}"
                width="{{ $src.Width }}"
                height="{{ $src.Height }}"
                alt="{{ $Text }}"
                {{- with $Title }}
                title="{{ . }}"
                {{- end }}
                loading="lazy"
                decoding="async"
            />

        </picture>

    {{- else -}}

        <img
            src="{{ $Destination | safeURL }}"
            width="{{ $src.Width }}"
            height="{{ $src.Height }}"
            alt="{{ $Text }}"
            {{- with $Title }}
            title="{{ . }}"
            {{- end }}
            loading="lazy"
            decoding="async"
        />

    {{- end -}}

{{- end -}}

第一次执行需要处理全量文件,输出类似如下:

Images processed : N
Images cached    : 0

再次执行,输出类似如下:

CACHE IMAGE ...
CACHE IMAGE ...

Images processed : 0
Images cached    : N

至此,图片响应式问题搞定了,流水线的耗时问题也解决了,目前控制在3分钟左右。

4. JS 文件按需加载

图片从数 MB 降下来以后,Lighthouse 中最明显的问题开始变成:

Reduce unused JavaScript

目前文章页共引入了两个 JS 文件:

view-image.min.js 用于点击图片浮窗展示;

twikoo.min.js 用于展示twikoo评论;

在页面打开后就立即加载这两个文件,而评论部分在页面最下面,这个资源的加载时机不合理。

通过 article.js 文件引入 ViewImage 和 Twikoo loader,待用户接近评论区时才真正加载Twikoo评论。

ViewImage
+
Twikoo loader
article.js

Twikoo 本体
用户接近评论区才加载

同时通过 Hugo 判断页面是否有插入图片,有的话才加载 ViewImage 代码,改为按需加载。

  1. 下载 view-image.min.js 到 assets/js/vendor目录下,不再依赖 tokinx.github.io 服务访问。
  2. 通过 Hugo Pipeline 构建文章页 JS文件引入。

文件目录:

assets/js/
├── article.js
└── vendor/
    └── view-image.min.js

article.js 文件代码如下:

"use strict";


// ============================================================
// Hugo build-time configuration
// ============================================================

const TWIKOO_VERSION =
    {{ .Params.twikoo.version | jsonify }};

const TWIKOO_ENV_ID =
    {{ (.Params.twikoo.envId | default "") | jsonify }};

const TWIKOO_URL =
    `https://cdnjs.cloudflare.com/ajax/libs/twikoo/${TWIKOO_VERSION}/twikoo.min.js`;


// ============================================================
// ViewImage
// ============================================================

function initViewImage() {

    if (!window.ViewImage) {
        return;
    }

    const images =
        document.querySelectorAll(
            ".post-content img"
        );

    if (images.length === 0) {
        return;
    }

    window.ViewImage.init(
        ".post-content img"
    );
}


// ============================================================
// Twikoo state
// ============================================================

let twikooLoading = null;

let twikooInitialized = false;


// ============================================================
// Twikoo initialization
// ============================================================

function initTwikoo() {

    if (twikooInitialized) {
        return;
    }

    if (!window.twikoo) {
        return;
    }

    const container =
        document.querySelector(
            "#tcomment"
        );

    if (!container) {
        return;
    }

    twikooInitialized = true;

    try {

        const result =
            window.twikoo.init({
                envId:
                    TWIKOO_ENV_ID,
                el:
                "#tcomment",
                lang: 'zh-CN',
                region: 'ap-shanghai',
                path: window.TWIKOO_MAGIC_PATH||window.location.pathname,
            });

        if (
            result &&
            typeof result.catch ===
                "function"
        ) {

            result.catch(error => {

                twikooInitialized = false;

                console.error(
                    "Twikoo initialization failed:",
                    error
                );
            });
        }

    } catch (error) {

        twikooInitialized = false;

        console.error(
            "Twikoo initialization failed:",
            error
        );
    }
}


// ============================================================
// Dynamically load Twikoo
// ============================================================

function loadTwikoo() {

    if (window.twikoo) {

        initTwikoo();

        return Promise.resolve();
    }


    if (twikooLoading) {

        return twikooLoading;
    }


    twikooLoading =
        new Promise(
            (resolve, reject) => {

                const script =
                    document.createElement(
                        "script"
                    );

                script.src =
                    TWIKOO_URL;

                script.async =
                    true;

                script.crossOrigin =
                    "anonymous";


                script.onload =
                    () => {

                        initTwikoo();

                        resolve();
                    };


                script.onerror =
                    () => {

                        twikooLoading =
                            null;

                        reject(
                            new Error(
                                `Failed to load Twikoo: ${TWIKOO_URL}`
                            )
                        );
                    };


                document.head.appendChild(
                    script
                );
            }
        );


    return twikooLoading;
}


// ============================================================
// Lazy load Twikoo
// ============================================================

function initLazyTwikoo() {

    const container =
        document.querySelector(
            "#tcomment"
        );


    // 当前文章没有评论区域:
    // 完全不下载 Twikoo

    if (!container) {
        return;
    }


    // 老浏览器 fallback

    if (
        !(
            "IntersectionObserver"
            in window
        )
    ) {

        loadTwikoo()
            .catch(console.error);

        return;
    }


    const observer =
        new IntersectionObserver(
            entries => {

                const shouldLoad =
                    entries.some(
                        entry =>
                            entry.isIntersecting
                    );


                if (!shouldLoad) {
                    return;
                }


                observer.disconnect();


                loadTwikoo()
                    .catch(error => {

                        console.error(
                            error
                        );
                    });
            },
            {
                // 用户距离评论区约 1000px
                // 时开始下载 Twikoo
                // 避免滚动到评论区才等待。

                rootMargin:
                    "1000px 0px",
            }
        );


    observer.observe(
        container
    );
}


// ============================================================
// Article initialization
// ============================================================

function initArticle() {

    initViewImage();

    initLazyTwikoo();
}


// ============================================================
// DOM ready
// ============================================================

if (
    document.readyState ===
    "loading"
) {

    document.addEventListener(
        "DOMContentLoaded",
        initArticle,
        {
            once: true,
        }
    );

} else {

    initArticle();
}

把原有的 view-image.min.js 和 twikoo.min.js 加载代码全部去掉,在 layouts/partials/extend_footer.html 文件里增加下面代码:

{{- if .IsPage -}}

    {{- $hasImages :=
        strings.Contains
            .Content
            "<img"
    -}}

    {{- $hasComments :=
        .Param "comments"
    -}}


    {{- if or $hasImages $hasComments -}}


        {{- $articleSource :=
            resources.Get
                "js/article.js"
        -}}


        {{- $article :=
            resources.ExecuteAsTemplate
                "js/article.generated.js"
                site
                $articleSource
        -}}


        {{- $bundle :=
            $article
        -}}


        {{- if $hasImages -}}

            {{- $viewImage :=
                resources.Get
                    "js/vendor/view-image.min.js"
            -}}


            {{- $bundle =
                slice
                    $viewImage
                    $article
                | resources.Concat
                    "js/article.js"
            -}}

        {{- else -}}

            {{- $bundle =
                slice $article
                | resources.Concat
                    "js/article.js"
            -}}

        {{- end -}}


        {{- $bundle =
            $bundle
            | minify
            | fingerprint "sha256"
        -}}


        <script
            src="{{ $bundle.RelPermalink }}"
            integrity="{{ $bundle.Data.Integrity }}"
            crossorigin="anonymous"
            defer>
        </script>


    {{- end -}}

{{- end -}}

优化后的 Lighthouse 测试效果,目前自己体感快了一些,不知道是不是错觉。

博客优化永无止境,咱这次就先优化到这里。😁