Files
hoelee-blog/docs/consultant-review/batches/batch-07.md
T

186 KiB
Raw Blame History

博客文章顾问审阅资料包 — Batch-07

批次编号:Batch-07
包含文章数:5 篇
本批次总字符量:159,503 字(严格限制 < 200,000 字)
模式:BILINGUAL
生成时间:2026-10-27 04:23:31

外部顾问审阅前置须知与工程背景规范

本文档目标:本批次包含 blog.hoelee.com 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。

1. 站点定位与双重业务目的

  • 受众 A(技术招聘官 / 资深技术主管):寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。
  • 受众 B(中小企业主 SME / 商业客户):寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。

2. 核心人设铁律与工程硬约束(必须严格遵守)

  1. 彻底杜绝业余爱好者/玩具口吻:
    • ❌ 绝对禁用词:homelab, home lab, 家用, 小主机, 玩具, 折腾。
    • ✅ 标准替代词:the infrastructure I manage(我管理的私有基础设施)、self-hosted production node(自托管生产节点)、on-premise host(私有化服务器)。
  2. 严禁捏造虚假指标与性能数字:
    • 所有 benchmark 数据必须有明确的测试基准与单设备限制。
    • 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。
    • 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。
  3. 去模板化叙事与真实物理锚点:
    • 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。
    • 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。
  4. 代码与配置展示规范:
    • 超过 20 行的配置或日志采用 HTML <details><summary> 原生折叠,<summary> 必须精准标注文档名与行数。

3. 顾问重点开刀维度

  • 事实与数字可核性:技术判断与性能数据是否自洽?有无过度夸大?
  • 人设与语调纯洁性:有无遗漏的 hobbyist/homelab 口吻?
  • 双轨转化引导有效性:是否有清晰的业务价值提炼与行动召唤(CTA)?
  • 代码与生产安全性:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置?

本批次文章目录索引

  1. [tutorials] 给没有时间戳的歌曲自动生成字幕 (generating-subtitles-for-songs-without-timestamps) — ~88,620 字符
  2. [tutorials] 购买主机前,如何核实一家服务商的真实情况 (how-to-verify-a-hosting-provider-before-you-buy) — ~9,649 字符
  3. [web3] 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 (chainlink-vrf-v2-lottery-contract) — ~25,244 字符
  4. [web3] 全链上 SVG NFT:把艺术作品放进合约里 (fully-on-chain-svg-nfts) — ~24,005 字符
  5. [web3] 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 (why-my-on-chain-nft-art-changed-on-windows) — ~11,985 字符

文章 01 / 05:给没有时间戳的歌曲自动生成字幕

✍️ 顾问审阅意见区 (Review Feedback)

【审阅结论】:[ ] 通过  [ ] 需微调  [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:

🇨🇳 中文版原文 (Chinese Markdown Twin)

---
title: "给没有时间戳的歌曲自动生成字幕"
description: "AI 音乐工具只给你音频 —— 没有逐行时间,没有卡拉 OK。这是我给 18 首歌生成 LRC 与 SRT 字幕的完整流程,为什么单纯转写不行,以及让歌词框滚到底的两个 bug。"
pubDate: 2026-10-20
category: tutorials
blueprint: ai-agent-automation
blueprintLayer: L3-local-gpu-inference
stream: creative-lab
tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"]
ogImage: "/og/zh-generating-subtitles-for-songs-without-timestamps.jpg"
banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg"
draft: false
---
我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你**每一句是什么时候唱的**。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。

时间轴才是全部难点。音频反而是简单的部分。

这是我最后跑通的一套流程,用在本地生成的 18 首英文歌上,外加它在浏览器里崩掉的两个地方。下面全部可复现:它唯一需要你提供的东西,是**你已经写好的歌词**。

## 你真正需要的两样东西

两种字幕格式,共用同一份数据结构:

| 格式 | 用在哪 | 长什么样 |
|---|---|---|
| **LRC** | 音乐播放器、卡拉 OK 块、`<audio>` 歌词层 | `[mm:ss.xx]歌词行` |
| **SRT** | VLC、YouTube、Premiere、ffmpeg 烧字 | 序号 / `hh:mm:ss,mmm --> hh:mm:ss,mmm` / 文本 |

两者本质都是「**行 → 起始时间**」(SRT 多一个结束时间)。所以真正的任务是:
**给定一串歌词行和一个音频文件,找出每一行从哪儿开始。**

## 为什么「直接转写」行不通

最直觉的做法:跑一次语音识别,把它的分段结果写出去。它会因为两个原因失败。

**1. 唱腔的识别率远低于说话。** 模型要同时对付乐器、混响和拉长的元音。我这一批里,中文唱词的词错率大约 25–35%,电子混音密集的更差。

**2. 它会整段丢掉。** 有一首 140 BPM 的燃曲,整段第一段主歌压根没出现在转写结果里 —— 人声被埋住了,模型直接跳过去。另外两首丢了尾奏。

有洞的转写,变不出没洞的字幕。但换个框法就全通了:

> 你不是在**转写**,你是在**对齐**。你早就知道确切的字 —— 那就是你的歌词。音频只是用来提供**锚点**。

## 让它成立的思路反转

让识别器给出词级时间戳,然后拿它的输出跟你已知的歌词做最长公共子序列 diff。你根本不在乎它有没有听对 —— 你在乎的是它听出来的那些词**顺序对不对**,因为每一个匹配都是一个时间锚点。

```python
import difflib

ref = [u for line in lines for u in units(line)]      # 你的歌词
hyp = [w[0] for w in asr_words]                        # 它听到的
sm = difflib.SequenceMatcher(a=ref, b=hyp, autojunk=False)

for tag, i1, i2, j1, j2 in sm.get_opcodes():
    if tag != "equal":
        continue            # 不匹配的地方只是空洞,不是错误
    for k in range(i2 - i1):
        matched[ref_index[i1 + k]] = asr_words[j1 + k][1:]   # (起, 止)
```

然后每一行歌词取它**第一个匹配单位**的时间戳。这种做法的稳健性是转写给不了的:60–70% 的词准确率就完全够用,因为每一个位置正确的词都钉住了它所属的那一行。

这批实测的锚定覆盖率 —— 也就是「拿到了至少一个真实锚点」的行占比:

| 语言 | 单位 | 平均锚定覆盖率 | 全行锚定的歌 |
|---|---|---:|---|
| 英文 | 词 | **0.96** | 9/18 首 |
| 中文 | 字 | 0.70 | 11/11 首 |

英文对齐更好,原因很无聊:词之间用空格隔开,所以每个 ASR token 能干净地对应一个歌词 token。中文则必须把每个字平摊到 token 的时间跨度上:

```python
span = w.end - w.start
for i, ch in enumerate(hanzi_in_word):
    chars.append((ch, w.start + span * i / n, w.start + span * (i + 1) / n))
```

## 修正器:三条规则,挡住荒唐的时间

锚点能带你走完大半程。但如果放着不管,锚点之间的空隙会产出物理上不可能的时间 —— 其中两个是真的咬到我了:

**故障 A —— 一行落在 0.0 秒。** 有一首歌的第一句歌词正好落在零点,前奏都还没结束,因为锚点搜索在开头的喊麦里匹配到了一个零散的字。

**故障 B —— 四行挤在 0.6 秒里。** 有一首歌的尾奏被识别器整段漏掉,剩下的行被压到相隔 0.18 秒。12 个字不可能在 0.18 秒里唱完。

所以对齐之后,跑三条规则:

1. **估计这首歌自己的速率** —— 用相邻两行都已锚定的样本(单位数 ÷ 秒数,取中位数)。每行的期望时长 = `单位数 ÷ 速率`,下限约 1.8 秒。同一批里英文是 0.96 到 2.33 词/秒,中文是 2.3 到 4.1 字/秒,**不能用全局常数**。
2. **按比例填充没锚定的段**,而不是固定行距。一段夹在两个锚点之间,就按期望时长分摊;空间不够就等比压缩。
3. **剔除不可能的锚点 + 强制最小行距。** 起始时间早于「5 秒 + 它前面所有内容可能占用的时间」的锚点一定是假匹配 —— 降级重算。然后正向扫一遍,保证任意两行起始间隔不小于 1.2 秒;若尾部被推出音频,再从末尾倒推收回。

最后那趟扫描,才是把「看起来合理」变成「不可能闪烁」的关键。

## 同时输出两种格式

同一份时间数组,两个写出函数:

```python
def lrc(ts, lines):                      # [mm:ss.xx]
    return "\n".join(f"[{m:02d}:{s:05.2f}]{l}" for (m, s), l in zip(ts, lines))

def srt(spans, lines):                   # hh:mm:ss,mmm
    out = []
    for i, (line, (a, b)) in enumerate(zip(lines, spans), 1):
        out.append(f"{i}\n{stamp(a)} --> {stamp(b)}\n{line}\n")
    return "\n".join(out)
```

LRC 只要起点;SRT 要起点**和**终点,所以用**下一行**的起点当作当前行的结束(这正是最小行距规则重要的原因 —— 没有它,SRT 块会变成零时长,有些播放器会整块跳过)。

## 验证:我错了两次的地方

字幕早在显示之前就是对的。两个 bug 都在那 20 行播放器脚本里,而且都是**读代码看不出来**的。

**Bug 1 —— 一开播就在末尾。** 找当前行我写的是 `lines.findIndex(l => l.t > now) - 1`。第一句之前 `findIndex` 返回 `0`,于是 `i` 变成 `-1`,而那句 `if (i < 0) i = lines.length - 1` 把「还没开始」翻译成了「最后一行」。播放器一打开就停在尾奏。

**Bug 2 —— 然后第一个和弦就滚到底。** 修完 bug 1,我用 `p.offsetTop` 来做居中。`offsetTop` 是相对**最近的定位祖先**量的 —— 而歌词框没有 `position: relative`,所以它返回的是这一行到**页面顶部**的距离,上千像素。`scrollTop` 被夹到 `maxScroll`:直接到底,字全看不见。

正确写法根本不关心定位上下文:

```js
const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
```

**关于「怎么测」的教训比修复本身更重要。** 我在 Node 里单测过索引逻辑 —— 通过了。我看过截图 —— 我把高亮的那一行看错了。两者都看不见布局 bug。真正有效的是在 headless Chrome 里打数值探针:stub 掉 `currentTime`,派发 `timeupdate`,然后把真实 DOM 读回来。

```bash
chrome --headless=new --dump-dom --virtual-time-budget=8000 \
       --window-size=760,520 "file:///probe.html"
```

```js
Object.defineProperty(a, 'currentTime', { get: () => 100, configurable: true });
a.dispatchEvent(new Event('timeupdate'));
// 然后输出:当前行索引、它在框内的偏移、scrollTop、maxScroll
```

通过条件是**数字**,不是观点:当前行在框内的偏移应当约为框高的一半(我得到的 274 px 框里是 124 px);t = 0 时 `scrollTop = 0` 且没有任何行高亮;只有最后几句时 `scrollTop = maxScroll`。

## 结果

18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。

| | |
|---|---:|
| 歌曲 | 18 |
| 字幕行数 | 342 |
| 平均锚定覆盖率 | 0.96 |
| 全行锚定的歌 | 9 / 18(每行都有时间) |
| 处理耗时 | 每首 63–90 秒,纯 CPU |
| 占用显卡 | 无 |

下面每个播放器都会滚动歌词、把当前句保持在中间,旁边还有 **.srt 下载** —— 想烧字进视频或在 VLC 里打开都行。

<style>
.subs-track{margin:2.4rem 0;display:flex;gap:1.6rem;align-items:flex-start;padding:1.25rem;border-radius:12px;background:var(--card,#1e2025);border:1px solid var(--border,#3a3f47);box-shadow:0 4px 16px rgba(0,0,0,.1);box-sizing:border-box;width:100%;max-width:100%;overflow:hidden}
.subs-cover{flex:0 0 38%;width:38%;max-width:325px;min-width:220px;aspect-ratio:1/1;border-radius:10px;overflow:hidden;border:1px solid var(--border,#3a3f47);background:#000;box-sizing:border-box}
.subs-cover img{width:100%;height:100%;object-fit:cover;display:block}
.subs-content{flex:1 1 0;min-width:0;width:100%;max-width:100%;box-sizing:border-box}
.subs-content figcaption{display:flex;align-items:flex-start;justify-content:space-between;gap:.35rem .6rem;margin-bottom:.55rem;width:100%;box-sizing:border-box}
.subs-title{flex:1 1 auto;min-width:0;font-size:1.08em;font-weight:700;color:var(--foreground,#f3f4f6);line-height:1.35;word-break:break-word;overflow-wrap:break-word}
.subs-meta{display:block;width:100%;font-weight:400;opacity:.75;font-size:.85em;line-height:1.45;margin-top:.2rem;white-space:normal;word-break:break-word;overflow-wrap:break-word}
.subs-dl{flex:0 0 auto;margin-left:auto;font-size:.8em;font-weight:600;text-decoration:none;border:1px solid var(--accent,#22c55e);border-radius:6px;padding:.18rem .55rem;color:var(--accent,#22c55e);background:transparent;white-space:nowrap;transition:all .15s}
.subs-dl:hover{background:rgba(34,197,94,.14)}
.subs-dl::before{content:"↓ "}
.subs-content audio{width:100%;margin-top:.35rem;box-sizing:border-box}
.subs-box{position:relative;max-height:220px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6;background:rgba(0,0,0,.25);box-sizing:border-box}
.subs-box p{margin:.18rem 0;opacity:.55}
.subs-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
@media(max-width:680px){
  .subs-track{flex-direction:column;align-items:stretch;gap:1.2rem;padding:1rem;margin:1.8rem 0}
  .subs-cover{flex:none;width:100%;max-width:100%;min-width:0;aspect-ratio:1/1;margin:0;border-radius:8px}
  .subs-content{width:100%;min-width:0}
  .subs-title{font-size:1.02em}
}
</style>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/01_Tiny-Borrowed-Shoes.webp" alt="Tiny Borrowed Shoes album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Tiny Borrowed Shoes <span class="subs-meta">· Celtic folk · 21 lines · 139 s</span></span><a class="subs-dl" href="/subs/tiny-borrowed-shoes.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-1" src="https://content.hoelee.com/file/hoelee/music/Tiny-Borrowed-Shoes-Mr-Hoelee.mp3"></audio>
    <pre id="subs-1" hidden>[ti:Tiny Borrowed Shoes]
[ar:Mr Hoelee]
[00:11.56]Underneath the floorboards where the shadows play
[00:14.60]I tied a little ribbon at the break of day
[00:18.32]A button for a table, a thimble for a cup
[00:22.02]I climbs the ivy ladder when the sun comes up
[00:27.76]Sugar on the windowsill, a feather in my hand
[00:31.42]The garden is a jungle and a wonderland
[00:35.22]Careful not to wake the cat sleeping by the door
[00:40.00]Tip-toe in my borrowed shoes across the wooden floor
[00:44.46]Fly away, little breeze, carry me along
[00:48.10]Through the clover and the leaves, singing my own song
[00:51.90]Though the world is wide and tall, and I am very small
[00:57.58]In the secret morning light, I can have it all
[01:09.62]Dewdrops on the dandelion shining like a crown
[01:12.24]I catch a floating parachute and spin around and down
[01:15.98]If you hear a tiny laugh hiding in the wall
[01:19.62]That is just my secret song, answering your call
[01:27.26]Fly away, little breeze, carry me along
[01:30.72]Through the clover and the leaves, singing my own song
[01:34.58]Though the world is wide and tall, and I am very small
[01:40.32]In the secret morning light, I can have it all
[01:51.98]La-la-la, in the morning light...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/02_Mist-Over-Skellig.webp" alt="Mist Over Skellig album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Mist Over Skellig <span class="subs-meta">· Celtic folk · 18 lines · 193 s</span></span><a class="subs-dl" href="/subs/mist-over-skellig.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-2" src="https://content.hoelee.com/file/hoelee/music/Mist-Over-Skellig-Mr-Hoelee.mp3"></audio>
    <pre id="subs-2" hidden>[ti:Mist Over Skellig]
[ar:Mr Hoelee]
[00:21.73]Where the gray Atlantic meets the abbey stone
[00:28.56]White birds circle high above the cliff alone
[00:33.56]Carved into the stairway of a thousand years
[00:42.16]Every drop of ocean rain whispers in my ears
[00:49.16]Sail into the silver mist, beyond the western sea
[00:57.96]Where the ancient lanterns glow for you and me
[01:06.32]Hush now, hear the harp strings ring across the foam
[01:14.74]Through the fog and falling stars, the tide will bring us home
[01:25.36]Candles in the chapel window flicker in the gale
[01:33.04]Ink upon the parchment tells a forgotten tale
[01:57.48]Little hands upon the rock, looking at the sky
[02:05.18]Watching all the centuries gently passing by
[02:16.98]Sail into the silver mist, beyond the western sea
[02:22.94]Where the ancient lanterns glow for you and me
[02:30.46]Hush now, hear the harp strings ring across the foam
[02:38.98]Through the fog and falling stars, the tide will bring us home
[02:57.02]Oceans rise and oceans fall...
[03:04.34]The tide will bring us home.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/03_Willow-by-the-Silver-Stream.webp" alt="Willow by the Silver Stream album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Willow by the Silver Stream <span class="subs-meta">· Celtic folk · 19 lines · 197 s</span></span><a class="subs-dl" href="/subs/willow-by-the-silver-stream.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-3" src="https://content.hoelee.com/file/hoelee/music/Willow-by-the-Silver-Stream-Mr-Hoelee.mp3"></audio>
    <pre id="subs-3" hidden>[ti:Willow by the Silver Stream]
[ar:Mr Hoelee]
[00:23.83]Mmm-mmm, ahh-ahh...
[00:26.70]Silver runs the quiet stream beneath the willow tree
[00:33.64]Golden is the evening star shining down on me
[00:40.54]All the little meadow flowers fold their petals tight
[00:46.36]Waiting for the moon to weave a blanket of the night
[00:53.02]May the wind be gentle on your weary wings
[00:59.76]May you hear the lullaby the river softly sings
[01:06.28]Close your eyes and drift away where the shadows gleam
[01:13.52]Peace will walk beside you in the valley of a dream
[01:27.32]Fireflies are dancing slow among the reeds and moss
[01:33.14]Showing every wandering heart the stepping stones to cross
[01:39.46]Nothing in the dark can harm the light inside your chest
[01:46.66]Lay your head upon the grass and let the world rest
[01:53.62]May the wind be gentle on your weary wings
[01:59.76]May you hear the lullaby the river softly sings
[02:06.30]Close your eyes and drift away where the shadows gleam
[02:13.42]Peace will walk beside you in the valley of a dream
[02:50.86]In the valley of a dream...
[02:56.54]Sleep in peace tonight.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/04_Clockwork-Starlight.webp" alt="Clockwork Starlight album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Clockwork Starlight <span class="subs-meta">· Japanese healing · 21 lines · 160 s</span></span><a class="subs-dl" href="/subs/clockwork-starlight.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-4" src="https://content.hoelee.com/file/hoelee/music/Clockwork-Starlight-Mr-Hoelee.mp3"></audio>
    <pre id="subs-4" hidden>[ti:Clockwork Starlight]
[ar:Mr Hoelee]
[00:19.30]Ticking in the quiet tower made of porcelain and glass
[00:23.44]I can see the painted clouds and paper swallows pass
[00:32.36]Every gear is turning slow beneath the velvet sky
[00:36.72]Counting all the shooting stars that never say goodbye
[00:46.12]Someone left a silver key beside the windowpane
[00:50.52]Unlocking all the memories hidden in the rain
[00:59.86]Sing for the dawn across the endless night
[01:04.66]Spinning the threads of shadow into light
[01:09.20]Even if the clock should stop and time fades away
[01:13.70]I will keep this little wish until the break of day
[01:22.28]La-la-lu-la, hear the distant bell
[01:31.34]Breaking the silent spell
[01:38.14]Pages of an ancient book are fluttering like wings
[01:42.50]Telling me of emerald seas and forgotten kings
[01:52.16]With my small and trembling hands I open up the door
[01:56.38]Stepping out into a world I've never seen before
[02:05.70]Sing for the dawn across the endless night
[02:10.56]Spinning the threads of shadow into light
[02:15.02]Even if the clock should stop and time fades away
[02:21.12]I will keep this little wish until the break of day
[02:33.64]Until the break of day...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/05_Waltz-of-the-Dandelion.webp" alt="Waltz of the Dandelion album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Waltz of the Dandelion <span class="subs-meta">· Japanese healing · 18 lines · 179 s</span></span><a class="subs-dl" href="/subs/waltz-of-the-dandelion.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-5" src="https://content.hoelee.com/file/hoelee/music/Waltz-of-the-Dandelion-Mr-Hoelee.mp3"></audio>
    <pre id="subs-5" hidden>[ti:Waltz of the Dandelion]
[ar:Mr Hoelee]
[00:17.62]One, two, three, the summer breeze is knocking at my door
[00:22.56]Dancing with the sunlit dust upon the wooden floor
[00:30.62]Out across the grassy hill the dandelions grow
[00:38.40]Waiting for a little breath to tell them where to go
[00:48.08]La-la-la, wherever you may fly
[00:53.28]Underneath the big and gentle sky
[00:57.28]Even when the seasons change and winter turns to spring
[01:06.06]I will still remember every little song we sing
[01:27.36]Drops of rain upon a leaf are rolling into one
[01:34.62]Shining like a diamond in the afternoon sun
[01:42.46]If you ever lose your way or feel a little sad
[01:50.26]Think about the quiet days and all the smiles we had
[02:00.18]La-la-la, wherever you may fly
[02:05.28]Underneath the big and gentle sky
[02:09.14]Even when the seasons change and winter turns to spring
[02:17.90]I will still remember every little song we sing
[02:25.96]La-la-la, la-la-la...
[02:29.46]See you again someday.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/06_Whale-in-the-Milky-Way.webp" alt="Whale in the Milky Way album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Whale in the Milky Way <span class="subs-meta">· Japanese healing · 20 lines · 164 s</span></span><a class="subs-dl" href="/subs/whale-in-the-milky-way.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-6" src="https://content.hoelee.com/file/hoelee/music/Whale-in-the-Milky-Way-Mr-Hoelee.mp3"></audio>
    <pre id="subs-6" hidden>[ti:Whale in the Milky Way]
[ar:Mr Hoelee]
[00:12.10]Ah-ah-ah, ooh-ooh...
[00:15.70]Up above the sleeping town where all the rooftops end
[00:21.86]Swims a giant silver whale that wants to be my friend
[00:27.94]Ripples in the Milky Way are washing on the shore
[00:34.34]Leaving little seashells by my bedroom door
[00:40.98]Can you hear the quiet song echoing in space?
[00:47.28]Every lonely little star has found a hiding place
[00:59.60]Float away, float away, into the velvet blue
[01:06.26]I am sending all my gentle lullabies to you
[01:12.74]Through the clouds of stardust and the galaxies afar
[01:19.59]You are never lost because I see how bright you are
[01:38.82]Paper boats upon the sea of infinite delight
[01:44.56]Carrying our tiny dreams across the quiet night
[01:51.08]Hold my hand and do not fear the deep and silent sky
[01:57.42]We can ride the silver whale and watch the world go by
[02:04.14]Float away, float away, into the velvet blue
[02:10.26]I am sending all my gentle lullabies to you
[02:16.74]Through the clouds of stardust and the galaxies afar
[02:23.52]You are never lost because I see how bright you are
[02:32.74]Goodnight, little star...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/07_Puddle-Jumping-Parade.webp" alt="Puddle Jumping Parade album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Puddle Jumping Parade <span class="subs-meta">· Japanese cute · 21 lines · 105 s</span></span><a class="subs-dl" href="/subs/puddle-jumping-parade.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-7" src="https://content.hoelee.com/file/hoelee/music/Puddle-Jumping-Parade-Mr-Hoelee.mp3"></audio>
    <pre id="subs-7" hidden>[ti:Puddle Jumping Parade]
[ar:Mr Hoelee]
[00:06.76]One, two! Ready, go!
[00:08.68]Put on my yellow boots and grab my red umbrella too
[00:12.30]The rain has stopped and left the sky a shiny, happy blue!
[00:16.08]A giant puddle in the street is looking back at me
[00:20.08]I take a giant running leap—one, two, and three!
[00:23.86]Splash! Goes the water, jumping to the sky
[00:27.66]Even little sparrows stop to watch us marching by!
[00:32.66]Hooray, hooray! Join the happy parade!
[00:36.20]Dancing in the sunshine that the morning made!
[00:40.16]Clap your hands together, stomp your little feet
[00:44.44]Every single puddle makes the melody complete!
[00:49.30]La-la-la, step in time, smiling ear to ear!
[00:56.42]A snail upon the garden wall is waving with its horn
[01:00.10]The brightest, biggest rainbow in the neighborhood is born!
[01:03.90]We'll share a pocketful of sweets and sing a silly tune
[01:07.72]And march around the playground till we reach the afternoon!
[01:13.06]Hooray, hooray! Join the happy parade!
[01:16.24]Dancing in the sunshine that the morning made!
[01:20.12]Clap your hands together, stomp your little feet
[01:23.90]Every single puddle makes the melody complete!
[01:29.20]Jump! Splash! Hooray!</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/08_Strawberry-Cloud-Bakery.webp" alt="Strawberry Cloud Bakery album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Strawberry Cloud Bakery <span class="subs-meta">· Japanese cute · 19 lines · 100 s</span></span><a class="subs-dl" href="/subs/strawberry-cloud-bakery.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-8" src="https://content.hoelee.com/file/hoelee/music/Strawberry-Cloud-Bakery-Mr-Hoelee.mp3"></audio>
    <pre id="subs-8" hidden>[ti:Strawberry Cloud Bakery]
[ar:Mr Hoelee]
[00:08.32]Ding-dong rings the little bell above the bakery door
[00:13.48]Flour on the apron and confetti on the floor
[00:16.54]Butter in the mixing bowl and sugar in the pan
[00:20.34]Baking up the fluffiest creations that I can!
[00:24.20]Wait a little minute as the oven starts to glow
[00:29.12]Watch the little loaves of bread begin to rise and grow!
[00:34.04]Welcome to the bakery upon the strawberry cloud!
[00:39.24]Where singing in the kitchen is definitely allowed!
[00:42.76]A croissant for the puppy and a muffin for the cat
[00:46.20]With a dollop of vanilla cream upon a beret hat!
[00:57.82]Pour a cup of warm milk tea and sit beside the window
[01:01.68]Wave hello to everyone walking down below
[01:05.98]If your day is sour like a lemon on a tree
[01:09.78]Take a bite of honey toast and share a smile with me!
[01:13.90]Welcome to the bakery upon the strawberry cloud!
[01:19.26]Where singing in the kitchen is definitely allowed!
[01:22.48]A croissant for the puppy and a muffin for the cat
[01:26.20]With a dollop of vanilla cream upon a beret hat!
[01:33.58]Fresh out of the oven! Yum, yum!</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/09_Captain-Marshmallow.webp" alt="Captain Marshmallow album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Captain Marshmallow <span class="subs-meta">· Japanese cute · 22 lines · 95 s</span></span><a class="subs-dl" href="/subs/captain-marshmallow.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-9" src="https://content.hoelee.com/file/hoelee/music/Captain-Marshmallow-Mr-Hoelee.mp3"></audio>
    <pre id="subs-9" hidden>[ti:Captain Marshmallow]
[ar:Mr Hoelee]
[00:08.06]Attention please! Captain on the deck!
[00:10.64]Sofa is my pirate ship, the carpet is the sea
[00:13.92]Pillow fort is waiting there for teddy bear and me
[00:17.52]Put a paper triangle upon my curly head
[00:20.96]No one has to take a nap or go to sleep in bed!
[00:25.68]Left turn! Right turn! Watch out for the shark!
[00:31.08]Wait—that's just the fluffy dog barking in the dark!
[00:36.78]Aye, aye, Captain Marshmallow!
[00:38.42]Sailing where the candy breezes blow!
[00:42.04]Cookies in the treasure chest, chocolate on the map
[00:45.56]Give a little salute and a double-double clap! (Clap-clap!)
[00:50.74]We rule the living room today!
[00:56.34]Homework is forbidden by the royal pirate law
[00:59.60]Every single citizen must draw a dinosaur!
[01:03.12]More pudding for the afternoon, and juice inside the cup
[01:07.28]When the clock strikes dinner time, we'll eat the treasure up!
[01:13.80]Aye, aye, Captain Marshmallow!
[01:15.28]Sailing where the candy breezes blow!
[01:19.00]Cookies in the treasure chest, chocolate on the map
[01:22.46]Give a little salute and a double-double clap! (Clap-clap!)
[01:26.82]We rule the living room today!
[01:33.00]Mission accomplished! Yay!</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/10_Lanterns-on-the-Lotus-Pond.webp" alt="Lanterns on the Lotus Pond album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Lanterns on the Lotus Pond <span class="subs-meta">· Guofeng fusion · 19 lines · 131 s</span></span><a class="subs-dl" href="/subs/lanterns-on-the-lotus-pond.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-10" src="https://content.hoelee.com/file/hoelee/music/Lanterns-on-the-Lotus-Pond-Mr-Hoelee.mp3"></audio>
    <pre id="subs-10" hidden>[ti:Lanterns on the Lotus Pond]
[ar:Mr Hoelee]
[00:10.69]Patter goes the summer rain upon the lotus leaf
[00:15.29]Washing all the dusty world and bringing cool relief
[00:19.89]Stepping on the wet bluestone beside the willow bridge
[00:24.49]Dragonflies are resting on the painted wooden ridge
[00:28.58]Fold a little paper boat and light a candlewick
[00:33.16]Make a wish before the current carries it so quick
[00:40.46]Floating down the lotus pond, a hundred lanterns glow
[00:46.38]Mirrored in the ripples where the silver fishes go
[00:49.62]Listen to the bamboo flute across the misty stream
[00:54.22]In the courtyard of the moon, we share a summer dream
[01:03.96]Silk fan in my little hand to chase the fireflies
[01:08.72]Red string braided in my hair beneath the lantern skies
[01:13.36]Grandma tells a fairy tale of rabbits in the moon
[01:18.22]While the sweet osmanthus flowers blossom in the June
[01:25.54]Floating down the lotus pond, a hundred lanterns glow
[01:30.02]Mirrored in the ripples where the silver fishes go
[01:34.62]Listen to the bamboo flute across the misty stream
[01:39.36]In the courtyard of the moon, we share a summer dream
[02:06.76]Red lanterns drifting far away...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/11_Wings-of-the-Kunpeng.webp" alt="Wings of the Kunpeng album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Wings of the Kunpeng <span class="subs-meta">· Guofeng fusion · 19 lines · 213 s</span></span><a class="subs-dl" href="/subs/wings-of-the-kunpeng.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-11" src="https://content.hoelee.com/file/hoelee/music/Wings-of-the-Kunpeng-Mr-Hoelee.mp3"></audio>
    <pre id="subs-11" hidden>[ti:Wings of the Kunpeng]
[ar:Mr Hoelee]
[00:25.18]Deep beneath the silent waves where no one ever sees
[00:31.60]Sleeps a tiny silver fish dreaming of the breeze
[00:38.16]Counting every falling tear that drops into the tide
[00:44.50]Waiting for the heavy gates of heaven to divide
[00:51.16]Do not fear the rising storm or shadows in the deep
[00:57.24]There is still a ancient promise that the ocean keeps
[01:06.34]Spread your wings and rise above the sea of clouds and rain
[01:13.18]Never let the mortal world bind you with a chain
[01:19.74]Fly across the mountain peaks where immortal rivers flow
[01:25.96]I will watch you turn to wind and let your spirit go
[01:49.10]Cycles of a thousand years are written in the stars
[01:54.96]Time will heal the broken shells and wash away the scars
[02:01.32]When the red camellia blooms upon the snowy shore
[02:07.58]We will meet beyond the sky and part ways nevermore
[02:16.86]Spread your wings and rise above the sea of clouds and rain
[02:23.52]Never let the mortal world bind you with a chain
[02:30.18]Fly across the mountain peaks where immortal rivers flow
[02:36.26]I will watch you turn to wind and let your spirit go
[02:48.84]Ah-ah-ah, fly into the wind...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/12_The-Puppeteer's-Thread.webp" alt="The Puppeteer's Thread album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">The Puppeteer's Thread <span class="subs-meta">· Guofeng fusion · 19 lines · 221 s</span></span><a class="subs-dl" href="/subs/the-puppeteer-s-thread.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-12" src="https://content.hoelee.com/file/hoelee/music/The-Puppeteers-Thread-Mr-Hoelee.mp3"></audio>
    <pre id="subs-12" hidden>[ti:The Puppeteer's Thread]
[ar:Mr Hoelee]
[00:24.12]Carved from a willow branch, painted with a smile
[00:34.16]Dressed in tiny embroidered silk, traveling mile by mile
[00:46.00]Ten thin threads of crimson silk tied upon my hands
[00:55.50]Dancing in the market square across the distant lands
[01:07.80]Crowds will gather when the gong begins to softly ring
[01:13.12]Laughing at the little doll that cannot speak or sing
[01:21.66]Dance in the lantern light, spin around the stage
[01:26.70]Telling all the legends of a long-forgotten age
[01:32.42]Though my heart is made of wood and cannot feel the rain
[01:37.66]Every pull upon the thread remembers joy and pain
[02:10.68]Autumn leaves are falling down upon the empty street
[02:15.82]Only echoes remain of the applause so sweet
[02:21.36]Keep me in the wooden box beside your warm fireplace
[02:26.98]Even when the paint fades off my little smiling face
[02:36.74]Dance in the lantern light, spin around the stage
[02:42.86]Telling all the legends of a long-forgotten age
[02:48.56]Though my heart is made of wood and cannot feel the rain
[02:54.02]Every pull upon the thread remembers joy and pain
[03:05.04]Ten thin threads of crimson silk...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/13_Paper-Airplane-in-September.webp" alt="Paper Airplane in September album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Paper Airplane in September <span class="subs-meta">· Acoustic folk · 18 lines · 128 s</span></span><a class="subs-dl" href="/subs/paper-airplane-in-september.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-13" src="https://content.hoelee.com/file/hoelee/music/Paper-Airplane-in-September-Mr-Hoelee.mp3"></audio>
    <pre id="subs-13" hidden>[ti:Paper Airplane in September]
[ar:Mr Hoelee]
[00:10.42]Sunlight on the wooden desk is moving inch by inch
[00:15.10]Sparrows on the telegraph wire never seem to flinch
[00:20.88]I fold a page of notebook paper creases nice and neat
[00:25.60]And throw it out the window to the quiet autumn street
[00:30.88]Fly away, paper plane, into the golden air
[00:36.36]Carry all the little words I didn't dare to share
[00:41.64]Goodbye to the summer clouds, hello to the breeze
[00:48.72]Floating past the bicycles and underneath the trees
[01:03.30]Chalk dust on the blackboard fades as afternoon goes by
[01:08.38]Clouds like cotton candy drift across the September sky
[01:13.76]Every simple little thing we took for granted then
[01:18.90]Stays inside a photograph we'll look at once again
[01:24.30]Fly away, paper plane, into the golden air
[01:29.66]Carry all the little words I didn't dare to share
[01:34.90]Goodbye to the summer clouds, hello to the breeze
[01:41.68]Floating past the bicycles and underneath the trees
[01:57.78]Underneath the autumn trees...
[02:02.18]Goodbye, summer days.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/14_Porridge-and-Evening-Smoke.webp" alt="Porridge and Evening Smoke album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Porridge and Evening Smoke <span class="subs-meta">· Acoustic folk · 17 lines · 144 s</span></span><a class="subs-dl" href="/subs/porridge-and-evening-smoke.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-14" src="https://content.hoelee.com/file/hoelee/music/Porridge-and-Evening-Smoke-Mr-Hoelee.mp3"></audio>
    <pre id="subs-14" hidden>[ti:Porridge and Evening Smoke]
[ar:Mr Hoelee]
[00:11.54]Smoke is rising from the roof into the purple sky
[00:17.04]While the evening swallows call and hurry on up high
[00:22.84]Steam upon the kitchen glass is blurring out the view
[00:28.48]Sitting on a wooden stool, I wait for dinner too
[00:34.02]One bowl of warm white rice, a dish of tender greens
[00:39.52]The simplest kind of happiness is what the evening means
[00:45.42]No matter how the outside world is noisy, cold, and wide
[00:50.98]The lamp upon the dining table keeps us warm inside
[01:09.84]Wrinkles on a gentle hand that smooths my messy hair
[01:14.88]Telling me to eat it all and sit up in my chair
[01:20.48]Years will pass like river water flowing to the sea
[01:26.26]But this quiet kitchen light will always stay with me
[01:33.04]One bowl of warm white rice, a dish of tender greens
[01:38.76]The simplest kind of happiness is what the evening means
[01:44.60]No matter how the outside world is noisy, cold, and wide
[01:50.14]The lamp upon the dining table keeps us warm inside
[01:57.40]The lamp is always on for you...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/15_Green-Train-to-the-Sea.webp" alt="Green Train to the Sea album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Green Train to the Sea <span class="subs-meta">· Acoustic folk · 17 lines · 108 s</span></span><a class="subs-dl" href="/subs/green-train-to-the-sea.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-15" src="https://content.hoelee.com/file/hoelee/music/Green-Train-to-the-Sea-Mr-Hoelee.mp3"></audio>
    <pre id="subs-15" hidden>[ti:Green Train to the Sea]
[ar:Mr Hoelee]
[00:28.22]Clickety-clack upon the track, the green train starts to roll
[00:29.42]Leaving all the city gray behind the tunnel hole
[00:30.62]Forehead pressed against the glass, I watch the fields go by
[00:31.82]Golden wheat and windmills waving underneath the sky
[00:33.02]Take me where the spring wind blows across the open blue
[00:34.22]Ten miles of wildflowers blooming just for me and you
[00:38.36]Every station has a name I've never heard before
[00:42.68]Soon the scent of salty air will meet us at the shore
[00:57.62]Strangers sharing oranges and stories in the seat
[01:01.92]Sunlight dancing in the aisle around my dangling feet
[01:06.68]Postcards in my backpack waiting for a stamp and pen
[01:11.50]To tell the people back at home I'm coming back again
[01:16.24]Take me where the spring wind blows across the open blue
[01:21.14]Ten miles of wildflowers blooming just for me and you
[01:26.34]Every station has a name I've never heard before
[01:30.66]Soon the scent of salty air will meet us at the shore
[01:46.82]Look, there is the sea...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/16_Sanctuary-of-the-Dawn.webp" alt="Sanctuary of the Dawn album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Sanctuary of the Dawn <span class="subs-meta">· Choral · 19 lines · 182 s</span></span><a class="subs-dl" href="/subs/sanctuary-of-the-dawn.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-16" src="https://content.hoelee.com/file/hoelee/music/Sanctuary-of-the-Dawn-Mr-Hoelee.mp3"></audio>
    <pre id="subs-16" hidden>[ti:Sanctuary of the Dawn]
[ar:Mr Hoelee]
[00:22.82]Ahh-ahh, sanctus, ahh...
[00:25.64]Shadows fade before the light that rises in the east
[00:31.74]Every troubled storm is stilled and every sorrow ceased
[00:37.76]Through the tall stained-glass windows morning beams descend
[00:44.12]Shining on a quiet road where broken hearts can mend
[00:50.56]Peace be with you, weary soul, lay your burden down
[00:58.74]Where the silver rivers flow beyond the sleeping town
[01:05.08]Lift your eyes unto the hills where golden angels sing
[01:11.46]Rest beneath the gentle shelter of a shining wing
[01:21.16]Like a bell across the valley ringing clear and true
[01:27.18]Every dawn is bringing back a promise made anew
[01:34.18]Walk into the quiet light and leave the dark behind
[01:40.06]There is grace in every breath for all of humankind
[01:48.40]Peace be with you, weary soul, lay your burden down
[01:54.64]Where the silver rivers flow beyond the sleeping town
[02:01.02]Lift your eyes unto the hills where golden angels sing
[02:07.48]Rest beneath the gentle shelter of a shining wing
[02:15.30]Peace be with you... forevermore.
[02:26.48]Amen, ahh...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/17_Look-Upon-Your-Path.webp" alt="Look Upon Your Path album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Look Upon Your Path <span class="subs-meta">· Choral · 17 lines · 122 s</span></span><a class="subs-dl" href="/subs/look-upon-your-path.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-17" src="https://content.hoelee.com/file/hoelee/music/Look-Upon-Your-Path-Mr-Hoelee.mp3"></audio>
    <pre id="subs-17" hidden>[ti:Look Upon Your Path]
[ar:Mr Hoelee]
[00:09.64]Look upon the winding path where fallen leaves are blown
[00:14.64]Children walking hand in hand so no one is alone
[00:19.64]Even when the winter wind is cold against your face
[00:25.04]Every step you take today is leading to a place
[00:31.52]Shine your light into the dark, let your voice be heard
[00:36.60]Rising like the morning lark, a brave and little bird
[00:41.60]Though the night is long and deep, the stars are watching still
[00:46.68]Spring will wake the seeds asleep upon the quiet hill
[00:57.00]Write a song upon the wind for everyone who strays
[01:02.16]Hope is like a tiny spark that sets the sky ablaze
[01:07.48]Hold the hand beside you tight and never look below
[01:11.88]There are gardens waiting where the wildest rivers flow
[01:21.30]Shine your light into the dark, let your voice be heard
[01:26.36]Rising like the morning lark, a brave and little bird
[01:31.34]Though the night is long and deep, the stars are watching still
[01:36.44]Spring will wake the seeds asleep upon the quiet hill
[01:45.98]Look upon your path... the dawn is near.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/18_Bells-of-the-Winter-Star.webp" alt="Bells of the Winter Star album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Bells of the Winter Star <span class="subs-meta">· Choral · 18 lines · 177 s</span></span><a class="subs-dl" href="/subs/bells-of-the-winter-star.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-18" src="https://content.hoelee.com/file/hoelee/music/Bells-of-the-Winter-Star-Mr-Hoelee.mp3"></audio>
    <pre id="subs-18" hidden>[ti:Bells of the Winter Star]
[ar:Mr Hoelee]
[00:08.50]Ding-dong, ding-dong, ring out the bells...
[00:18.20]Snowflakes falling from the sky in silence pure and white
[00:25.44]Dressing every cedar bough in robes of silver light
[00:34.56]Listen to the steeple bells across the frozen square
[00:43.42]Sending out a melody upon the midnight air
[00:52.92]Ring, silver bells, across the winter snow
[01:01.30]Tell all the world the star has begun to glow
[01:11.76]Gloria, gloria, hear the angels sing
[01:19.28]Peace on the earth this holy night will bring
[01:32.12]Candles in the frosted glass are shining warm and clear
[01:40.42]Guiding every traveler home at the end of year
[01:49.68]Every chime is like a prayer ascending to the sky
[01:58.26]Watched by all the quiet stars that never pass us by
[02:09.62]Ring, silver bells, across the winter snow
[02:18.22]Tell all the world the star has begun to glow
[02:28.00]Gloria, gloria, hear the angels sing
[02:37.14]Peace on the earth this holy night will bring
[02:49.30]Gloria... peace on the earth.</pre>
  </div>
</figure>
<script>
// subs-track: scroll LRC in step with <audio>; current line stays centred
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
  const raw = document.getElementById(audio.dataset.lrc).textContent;
  const lines = raw.split('\n').map((l) => {
    const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
    return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
  }).filter(Boolean);
  const box = document.createElement('div');
  box.className = 'subs-box';
  lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
  audio.parentNode.insertBefore(box, audio.nextSibling);
  let cur = -1;
  audio.addEventListener('timeupdate', () => {
    const now = audio.currentTime;
    // last line whose timestamp has passed; -1 before the first line starts
    let i = -1;
    for (let k = 0; k < lines.length; k++) { if (lines[k].t <= now) i = k; else break; }
    if (i !== cur) {
      if (cur >= 0) box.children[cur].classList.remove('on');
      cur = i;
      if (cur >= 0) {
        const p = box.children[cur];
        p.classList.add('on');
        const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
        box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
      } else {
        box.scrollTop = 0;
      }
    }
  });
});
</script>

🇬🇧 英文版原文 (English Markdown Primary)

---
title: "Generating Subtitles for Songs That Ship Without Timestamps"
description: "AI music tools give you audio and nothing else — no line timings, no karaoke. Here is the pipeline I use to produce LRC and SRT subtitles for 18 songs, why plain transcription fails, and the two bugs that made the lyrics box scroll to the bottom."
pubDate: 2026-10-20
category: tutorials
blueprint: ai-agent-automation
blueprintLayer: L3-local-gpu-inference
stream: creative-lab
tags: ["subtitles", "lrc", "srt", "whisper", "forced-alignment", "ai-music", "python"]
ogImage: "/og/generating-subtitles-for-songs-without-timestamps.jpg"
banner: "/banners/generating-subtitles-for-songs-without-timestamps.jpg"
draft: false
---
Every AI music tool I have tried returns the same shape of output: an audio
file. Not one of them tells you *when* each line is sung. That is fine if you
only want to listen, and useless the moment you want lyrics on screen — a
karaoke line, a video caption, a scrolling block under a player.

Timing is the whole problem. The audio is the easy part.

This is the pipeline I ended up with, running on 18 English songs generated
locally, plus the two places it broke in the browser. Everything below is
reproducible: the only thing it needs from you is the **lyrics you already
wrote**.

## What you actually need

Two subtitle formats, one shared data structure:

| Format | Used by | Shape |
|---|---|---|
| **LRC** | music players, karaoke blocks, `<audio>` overlays | `[mm:ss.xx]lyric line` |
| **SRT** | VLC, YouTube, Premiere, ffmpeg burn-in | index / `hh:mm:ss,mmm --> hh:mm:ss,mmm` / text |

Both are just *line → start time* (+ end time for SRT). So the real task is:
**given a list of lyric lines and an audio file, find where each line starts.**

## Why "just transcribe it" fails

The obvious approach: run speech recognition, take its segments, write them
out. It fails for two reasons.

**1. Recognition of singing is much worse than recognition of speech.** The
model is fighting instruments, reverb, and vowel stretching. On my batch the
word error rate on sung Mandarin hovered around 25–35%; dense electronic
mixes were worse.

**2. It drops whole sections.** On a 140 BPM remix, one song's entire first
verse simply never appeared in the transcript — the vocal is buried and the
model moved on. Two other songs lost their outros.

A transcript with holes cannot become subtitles with no holes. But here is the
reframe that fixes everything:

> You are not transcribing. You are **aligning**. You already know the exact
> words — they are your lyrics. You only need the audio for *anchors*.

## The inversion that makes it work

Feed the recogniser to get word-level timestamps, then align its output
against your known lyrics with a longest-common-subsequence diff. You do not
care whether the recogniser got the words right — you care that the words it
*did* get are in the right order, because each match is a timestamp anchor.

```python
import difflib

ref = [u for line in lines for u in units(line)]      # your lyrics
hyp = [w[0] for w in asr_words]                        # what it heard
sm = difflib.SequenceMatcher(a=ref, b=hyp, autojunk=False)

for tag, i1, i2, j1, j2 in sm.get_opcodes():
    if tag != "equal":
        continue            # mismatches are just gaps, not errors
    for k in range(i2 - i1):
        matched[ref_index[i1 + k]] = asr_words[j1 + k][1:]   # (start, end)
```

Each lyric line then takes the timestamp of its **first matched unit**. This
is robust in a way transcription is not: 60–70% word accuracy is plenty,
because every correctly-placed word pins the line it belongs to.

Measured coverage on this batch — the share of lines that got at least one
real anchor:

| Language | Unit | Mean anchor coverage | Lines fully anchored |
|---|---|---:|---|
| English | word | **0.96** | 9/18 songs |
| Mandarin | character | 0.70 | 11/11 songs |

English aligns better for a boring reason: words are delimited by spaces, so
each ASR token maps cleanly onto one lyric token. Mandarin needs each
character spread across the token's time span:

```python
span = w.end - w.start
for i, ch in enumerate(hanzi_in_word):
    chars.append((ch, w.start + span * i / n, w.start + span * (i + 1) / n))
```

## The governor: three rules that stop nonsense timings

Anchors get you most of the way. Left alone, the gaps between them produce
timings that are physically impossible, and two of those bit me for real:

**Failure A — a line at 0.0 s.** One song's first lyric landed at exactly
zero, before the intro had even finished, because the anchor search matched
a stray word in the opening shout.

**Failure B — four lines in 0.6 s.** A song whose outro the recogniser missed
entirely had its remaining lines compressed to 0.18 s apart. Twelve
characters cannot be sung in 0.18 s.

So after alignment, run three rules:

1. **Estimate the song's own rate** from neighbouring anchored pairs
   (units ÷ seconds, take the median). Every line's expected duration is
   `units / rate`, floored at ~1.8 s. Rates across one batch ranged from
   0.96 to 2.33 words/s in English, and 2.3 to 4.1 characters/s in Mandarin —
   you cannot use a global constant.
2. **Refill unanchored runs proportionally**, not with a fixed spacing. If a
   run sits between two anchors, distribute it by expected duration; if there
   is not enough room, compress proportionally.
3. **Reject impossible anchors and enforce a minimum gap.** An anchor whose
   start is earlier than `5 s + everything before it could possibly take` is
   a false match — demote it and re-interpolate. Then sweep forward so no two
   lines start less than 1.2 s apart, and pull back from the end if the tail
   overruns the audio.

That last sweep is what turns "looks plausible" into "cannot flicker".

## Emitting both formats

Same timing array, two writers:

```python
def lrc(ts, lines):                      # [mm:ss.xx]
    return "\n".join(f"[{m:02d}:{s:05.2f}]{l}" for (m, s), l in zip(ts, lines))

def srt(spans, lines):                   # hh:mm:ss,mmm
    out = []
    for i, (line, (a, b)) in enumerate(zip(lines, spans), 1):
        out.append(f"{i}\n{stamp(a)} --> {stamp(b)}\n{line}\n")
    return "\n".join(out)
```

LRC needs only the start; SRT needs start **and** end, so use the *next*
line's start as the current line's end (this is why the governor's minimum gap
matters — without it, SRT blocks get zero duration and some players skip
them entirely).

## Verification: the part I got wrong twice

The subtitles were correct long before the display was. Two bugs, both in the
20-line player script, both invisible to a code read:

**Bug 1 — the box started at the bottom.** To find the current line I wrote
`lines.findIndex(l => l.t > now) - 1`. Before the first line, `findIndex`
returns `0`, so `i` became `-1`, and the guard `if (i < 0) i = lines.length - 1`
turned "nothing yet" into "the last line". Playback opened scrolled to the
outro.

**Bug 2 — it then scrolled to the bottom on the first chord.** Fixing bug 1, I
centred the active line with `p.offsetTop`. `offsetTop` is measured from the
nearest **positioned** ancestor — and the lyric box had no `position: relative`,
so it returned the line's distance from the top of the *page*, thousands of
pixels. `scrollTop` clamped to `maxScroll`: straight to the bottom.

The correct form does not care about positioning contexts:

```js
const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
```

And the lesson about *testing* matters more than the fix. I verified the index
logic in Node — it passed. I looked at a screenshot — I misread which line was
highlighted. Neither could see a layout bug. What worked was a numeric probe
in headless Chrome: stub `currentTime`, dispatch `timeupdate`, then read the
real DOM back out.

```bash
chrome --headless=new --dump-dom --virtual-time-budget=8000 \
       --window-size=760,520 "file:///probe.html"
```

```js
Object.defineProperty(a, 'currentTime', { get: () => 100, configurable: true });
a.dispatchEvent(new Event('timeupdate'));
// then report: active line index, its offset inside the box, scrollTop, maxScroll
```

The pass condition is a number, not an opinion: the active line's offset
inside the box should be about half the box height (I got 124 px in a 274 px
box), `scrollTop = 0` with nothing highlighted at t = 0, and `scrollTop =
maxScroll` only for the final lines.

## Results

Eighteen songs, none of which shipped a timestamp. Every one now has line-level
LRC and SRT, generated from lyrics plus audio alone.

| | |
|---|---:|
| Songs | 18 |
| Subtitle lines | 342 |
| Mean anchor coverage | 0.96 |
| Songs with every line anchored | 9 / 18 (every line got a time) |
| Processing time | 63–90 s per song, CPU only |
| GPU used | none |

Each player below scrolls its lyrics, keeps the current line centred, and has
a **.srt download** next to it if you want to burn the subtitles into a video
or open them in VLC.

<style>
.subs-track{margin:2.4rem 0;display:flex;gap:1.6rem;align-items:flex-start;padding:1.25rem;border-radius:12px;background:var(--card,#1e2025);border:1px solid var(--border,#3a3f47);box-shadow:0 4px 16px rgba(0,0,0,.1);box-sizing:border-box;width:100%;max-width:100%;overflow:hidden}
.subs-cover{flex:0 0 38%;width:38%;max-width:325px;min-width:220px;aspect-ratio:1/1;border-radius:10px;overflow:hidden;border:1px solid var(--border,#3a3f47);background:#000;box-sizing:border-box}
.subs-cover img{width:100%;height:100%;object-fit:cover;display:block}
.subs-content{flex:1 1 0;min-width:0;width:100%;max-width:100%;box-sizing:border-box}
.subs-content figcaption{display:flex;align-items:flex-start;justify-content:space-between;gap:.35rem .6rem;margin-bottom:.55rem;width:100%;box-sizing:border-box}
.subs-title{flex:1 1 auto;min-width:0;font-size:1.08em;font-weight:700;color:var(--foreground,#f3f4f6);line-height:1.35;word-break:break-word;overflow-wrap:break-word}
.subs-meta{display:block;width:100%;font-weight:400;opacity:.75;font-size:.85em;line-height:1.45;margin-top:.2rem;white-space:normal;word-break:break-word;overflow-wrap:break-word}
.subs-dl{flex:0 0 auto;margin-left:auto;font-size:.8em;font-weight:600;text-decoration:none;border:1px solid var(--accent,#22c55e);border-radius:6px;padding:.18rem .55rem;color:var(--accent,#22c55e);background:transparent;white-space:nowrap;transition:all .15s}
.subs-dl:hover{background:rgba(34,197,94,.14)}
.subs-dl::before{content:"↓ "}
.subs-content audio{width:100%;margin-top:.35rem;box-sizing:border-box}
.subs-box{position:relative;max-height:220px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6;background:rgba(0,0,0,.25);box-sizing:border-box}
.subs-box p{margin:.18rem 0;opacity:.55}
.subs-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
@media(max-width:680px){
  .subs-track{flex-direction:column;align-items:stretch;gap:1.2rem;padding:1rem;margin:1.8rem 0}
  .subs-cover{flex:none;width:100%;max-width:100%;min-width:0;aspect-ratio:1/1;margin:0;border-radius:8px}
  .subs-content{width:100%;min-width:0}
  .subs-title{font-size:1.02em}
}
</style>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/01_Tiny-Borrowed-Shoes.webp" alt="Tiny Borrowed Shoes album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Tiny Borrowed Shoes <span class="subs-meta">· Celtic folk · 21 lines · 139 s</span></span><a class="subs-dl" href="/subs/tiny-borrowed-shoes.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-1" src="https://content.hoelee.com/file/hoelee/music/Tiny-Borrowed-Shoes-Mr-Hoelee.mp3"></audio>
    <pre id="subs-1" hidden>[ti:Tiny Borrowed Shoes]
[ar:Mr Hoelee]
[00:11.56]Underneath the floorboards where the shadows play
[00:14.60]I tied a little ribbon at the break of day
[00:18.32]A button for a table, a thimble for a cup
[00:22.02]I climbs the ivy ladder when the sun comes up
[00:27.76]Sugar on the windowsill, a feather in my hand
[00:31.42]The garden is a jungle and a wonderland
[00:35.22]Careful not to wake the cat sleeping by the door
[00:40.00]Tip-toe in my borrowed shoes across the wooden floor
[00:44.46]Fly away, little breeze, carry me along
[00:48.10]Through the clover and the leaves, singing my own song
[00:51.90]Though the world is wide and tall, and I am very small
[00:57.58]In the secret morning light, I can have it all
[01:09.62]Dewdrops on the dandelion shining like a crown
[01:12.24]I catch a floating parachute and spin around and down
[01:15.98]If you hear a tiny laugh hiding in the wall
[01:19.62]That is just my secret song, answering your call
[01:27.26]Fly away, little breeze, carry me along
[01:30.72]Through the clover and the leaves, singing my own song
[01:34.58]Though the world is wide and tall, and I am very small
[01:40.32]In the secret morning light, I can have it all
[01:51.98]La-la-la, in the morning light...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/02_Mist-Over-Skellig.webp" alt="Mist Over Skellig album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Mist Over Skellig <span class="subs-meta">· Celtic folk · 18 lines · 193 s</span></span><a class="subs-dl" href="/subs/mist-over-skellig.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-2" src="https://content.hoelee.com/file/hoelee/music/Mist-Over-Skellig-Mr-Hoelee.mp3"></audio>
    <pre id="subs-2" hidden>[ti:Mist Over Skellig]
[ar:Mr Hoelee]
[00:21.73]Where the gray Atlantic meets the abbey stone
[00:28.56]White birds circle high above the cliff alone
[00:33.56]Carved into the stairway of a thousand years
[00:42.16]Every drop of ocean rain whispers in my ears
[00:49.16]Sail into the silver mist, beyond the western sea
[00:57.96]Where the ancient lanterns glow for you and me
[01:06.32]Hush now, hear the harp strings ring across the foam
[01:14.74]Through the fog and falling stars, the tide will bring us home
[01:25.36]Candles in the chapel window flicker in the gale
[01:33.04]Ink upon the parchment tells a forgotten tale
[01:57.48]Little hands upon the rock, looking at the sky
[02:05.18]Watching all the centuries gently passing by
[02:16.98]Sail into the silver mist, beyond the western sea
[02:22.94]Where the ancient lanterns glow for you and me
[02:30.46]Hush now, hear the harp strings ring across the foam
[02:38.98]Through the fog and falling stars, the tide will bring us home
[02:57.02]Oceans rise and oceans fall...
[03:04.34]The tide will bring us home.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/03_Willow-by-the-Silver-Stream.webp" alt="Willow by the Silver Stream album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Willow by the Silver Stream <span class="subs-meta">· Celtic folk · 19 lines · 197 s</span></span><a class="subs-dl" href="/subs/willow-by-the-silver-stream.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-3" src="https://content.hoelee.com/file/hoelee/music/Willow-by-the-Silver-Stream-Mr-Hoelee.mp3"></audio>
    <pre id="subs-3" hidden>[ti:Willow by the Silver Stream]
[ar:Mr Hoelee]
[00:23.83]Mmm-mmm, ahh-ahh...
[00:26.70]Silver runs the quiet stream beneath the willow tree
[00:33.64]Golden is the evening star shining down on me
[00:40.54]All the little meadow flowers fold their petals tight
[00:46.36]Waiting for the moon to weave a blanket of the night
[00:53.02]May the wind be gentle on your weary wings
[00:59.76]May you hear the lullaby the river softly sings
[01:06.28]Close your eyes and drift away where the shadows gleam
[01:13.52]Peace will walk beside you in the valley of a dream
[01:27.32]Fireflies are dancing slow among the reeds and moss
[01:33.14]Showing every wandering heart the stepping stones to cross
[01:39.46]Nothing in the dark can harm the light inside your chest
[01:46.66]Lay your head upon the grass and let the world rest
[01:53.62]May the wind be gentle on your weary wings
[01:59.76]May you hear the lullaby the river softly sings
[02:06.30]Close your eyes and drift away where the shadows gleam
[02:13.42]Peace will walk beside you in the valley of a dream
[02:50.86]In the valley of a dream...
[02:56.54]Sleep in peace tonight.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/04_Clockwork-Starlight.webp" alt="Clockwork Starlight album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Clockwork Starlight <span class="subs-meta">· Japanese healing · 21 lines · 160 s</span></span><a class="subs-dl" href="/subs/clockwork-starlight.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-4" src="https://content.hoelee.com/file/hoelee/music/Clockwork-Starlight-Mr-Hoelee.mp3"></audio>
    <pre id="subs-4" hidden>[ti:Clockwork Starlight]
[ar:Mr Hoelee]
[00:19.30]Ticking in the quiet tower made of porcelain and glass
[00:23.44]I can see the painted clouds and paper swallows pass
[00:32.36]Every gear is turning slow beneath the velvet sky
[00:36.72]Counting all the shooting stars that never say goodbye
[00:46.12]Someone left a silver key beside the windowpane
[00:50.52]Unlocking all the memories hidden in the rain
[00:59.86]Sing for the dawn across the endless night
[01:04.66]Spinning the threads of shadow into light
[01:09.20]Even if the clock should stop and time fades away
[01:13.70]I will keep this little wish until the break of day
[01:22.28]La-la-lu-la, hear the distant bell
[01:31.34]Breaking the silent spell
[01:38.14]Pages of an ancient book are fluttering like wings
[01:42.50]Telling me of emerald seas and forgotten kings
[01:52.16]With my small and trembling hands I open up the door
[01:56.38]Stepping out into a world I've never seen before
[02:05.70]Sing for the dawn across the endless night
[02:10.56]Spinning the threads of shadow into light
[02:15.02]Even if the clock should stop and time fades away
[02:21.12]I will keep this little wish until the break of day
[02:33.64]Until the break of day...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/05_Waltz-of-the-Dandelion.webp" alt="Waltz of the Dandelion album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Waltz of the Dandelion <span class="subs-meta">· Japanese healing · 18 lines · 179 s</span></span><a class="subs-dl" href="/subs/waltz-of-the-dandelion.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-5" src="https://content.hoelee.com/file/hoelee/music/Waltz-of-the-Dandelion-Mr-Hoelee.mp3"></audio>
    <pre id="subs-5" hidden>[ti:Waltz of the Dandelion]
[ar:Mr Hoelee]
[00:17.62]One, two, three, the summer breeze is knocking at my door
[00:22.56]Dancing with the sunlit dust upon the wooden floor
[00:30.62]Out across the grassy hill the dandelions grow
[00:38.40]Waiting for a little breath to tell them where to go
[00:48.08]La-la-la, wherever you may fly
[00:53.28]Underneath the big and gentle sky
[00:57.28]Even when the seasons change and winter turns to spring
[01:06.06]I will still remember every little song we sing
[01:27.36]Drops of rain upon a leaf are rolling into one
[01:34.62]Shining like a diamond in the afternoon sun
[01:42.46]If you ever lose your way or feel a little sad
[01:50.26]Think about the quiet days and all the smiles we had
[02:00.18]La-la-la, wherever you may fly
[02:05.28]Underneath the big and gentle sky
[02:09.14]Even when the seasons change and winter turns to spring
[02:17.90]I will still remember every little song we sing
[02:25.96]La-la-la, la-la-la...
[02:29.46]See you again someday.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/06_Whale-in-the-Milky-Way.webp" alt="Whale in the Milky Way album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Whale in the Milky Way <span class="subs-meta">· Japanese healing · 20 lines · 164 s</span></span><a class="subs-dl" href="/subs/whale-in-the-milky-way.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-6" src="https://content.hoelee.com/file/hoelee/music/Whale-in-the-Milky-Way-Mr-Hoelee.mp3"></audio>
    <pre id="subs-6" hidden>[ti:Whale in the Milky Way]
[ar:Mr Hoelee]
[00:12.10]Ah-ah-ah, ooh-ooh...
[00:15.70]Up above the sleeping town where all the rooftops end
[00:21.86]Swims a giant silver whale that wants to be my friend
[00:27.94]Ripples in the Milky Way are washing on the shore
[00:34.34]Leaving little seashells by my bedroom door
[00:40.98]Can you hear the quiet song echoing in space?
[00:47.28]Every lonely little star has found a hiding place
[00:59.60]Float away, float away, into the velvet blue
[01:06.26]I am sending all my gentle lullabies to you
[01:12.74]Through the clouds of stardust and the galaxies afar
[01:19.59]You are never lost because I see how bright you are
[01:38.82]Paper boats upon the sea of infinite delight
[01:44.56]Carrying our tiny dreams across the quiet night
[01:51.08]Hold my hand and do not fear the deep and silent sky
[01:57.42]We can ride the silver whale and watch the world go by
[02:04.14]Float away, float away, into the velvet blue
[02:10.26]I am sending all my gentle lullabies to you
[02:16.74]Through the clouds of stardust and the galaxies afar
[02:23.52]You are never lost because I see how bright you are
[02:32.74]Goodnight, little star...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/07_Puddle-Jumping-Parade.webp" alt="Puddle Jumping Parade album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Puddle Jumping Parade <span class="subs-meta">· Japanese cute · 21 lines · 105 s</span></span><a class="subs-dl" href="/subs/puddle-jumping-parade.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-7" src="https://content.hoelee.com/file/hoelee/music/Puddle-Jumping-Parade-Mr-Hoelee.mp3"></audio>
    <pre id="subs-7" hidden>[ti:Puddle Jumping Parade]
[ar:Mr Hoelee]
[00:06.76]One, two! Ready, go!
[00:08.68]Put on my yellow boots and grab my red umbrella too
[00:12.30]The rain has stopped and left the sky a shiny, happy blue!
[00:16.08]A giant puddle in the street is looking back at me
[00:20.08]I take a giant running leap—one, two, and three!
[00:23.86]Splash! Goes the water, jumping to the sky
[00:27.66]Even little sparrows stop to watch us marching by!
[00:32.66]Hooray, hooray! Join the happy parade!
[00:36.20]Dancing in the sunshine that the morning made!
[00:40.16]Clap your hands together, stomp your little feet
[00:44.44]Every single puddle makes the melody complete!
[00:49.30]La-la-la, step in time, smiling ear to ear!
[00:56.42]A snail upon the garden wall is waving with its horn
[01:00.10]The brightest, biggest rainbow in the neighborhood is born!
[01:03.90]We'll share a pocketful of sweets and sing a silly tune
[01:07.72]And march around the playground till we reach the afternoon!
[01:13.06]Hooray, hooray! Join the happy parade!
[01:16.24]Dancing in the sunshine that the morning made!
[01:20.12]Clap your hands together, stomp your little feet
[01:23.90]Every single puddle makes the melody complete!
[01:29.20]Jump! Splash! Hooray!</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/08_Strawberry-Cloud-Bakery.webp" alt="Strawberry Cloud Bakery album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Strawberry Cloud Bakery <span class="subs-meta">· Japanese cute · 19 lines · 100 s</span></span><a class="subs-dl" href="/subs/strawberry-cloud-bakery.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-8" src="https://content.hoelee.com/file/hoelee/music/Strawberry-Cloud-Bakery-Mr-Hoelee.mp3"></audio>
    <pre id="subs-8" hidden>[ti:Strawberry Cloud Bakery]
[ar:Mr Hoelee]
[00:08.32]Ding-dong rings the little bell above the bakery door
[00:13.48]Flour on the apron and confetti on the floor
[00:16.54]Butter in the mixing bowl and sugar in the pan
[00:20.34]Baking up the fluffiest creations that I can!
[00:24.20]Wait a little minute as the oven starts to glow
[00:29.12]Watch the little loaves of bread begin to rise and grow!
[00:34.04]Welcome to the bakery upon the strawberry cloud!
[00:39.24]Where singing in the kitchen is definitely allowed!
[00:42.76]A croissant for the puppy and a muffin for the cat
[00:46.20]With a dollop of vanilla cream upon a beret hat!
[00:57.82]Pour a cup of warm milk tea and sit beside the window
[01:01.68]Wave hello to everyone walking down below
[01:05.98]If your day is sour like a lemon on a tree
[01:09.78]Take a bite of honey toast and share a smile with me!
[01:13.90]Welcome to the bakery upon the strawberry cloud!
[01:19.26]Where singing in the kitchen is definitely allowed!
[01:22.48]A croissant for the puppy and a muffin for the cat
[01:26.20]With a dollop of vanilla cream upon a beret hat!
[01:33.58]Fresh out of the oven! Yum, yum!</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/09_Captain-Marshmallow.webp" alt="Captain Marshmallow album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Captain Marshmallow <span class="subs-meta">· Japanese cute · 22 lines · 95 s</span></span><a class="subs-dl" href="/subs/captain-marshmallow.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-9" src="https://content.hoelee.com/file/hoelee/music/Captain-Marshmallow-Mr-Hoelee.mp3"></audio>
    <pre id="subs-9" hidden>[ti:Captain Marshmallow]
[ar:Mr Hoelee]
[00:08.06]Attention please! Captain on the deck!
[00:10.64]Sofa is my pirate ship, the carpet is the sea
[00:13.92]Pillow fort is waiting there for teddy bear and me
[00:17.52]Put a paper triangle upon my curly head
[00:20.96]No one has to take a nap or go to sleep in bed!
[00:25.68]Left turn! Right turn! Watch out for the shark!
[00:31.08]Wait—that's just the fluffy dog barking in the dark!
[00:36.78]Aye, aye, Captain Marshmallow!
[00:38.42]Sailing where the candy breezes blow!
[00:42.04]Cookies in the treasure chest, chocolate on the map
[00:45.56]Give a little salute and a double-double clap! (Clap-clap!)
[00:50.74]We rule the living room today!
[00:56.34]Homework is forbidden by the royal pirate law
[00:59.60]Every single citizen must draw a dinosaur!
[01:03.12]More pudding for the afternoon, and juice inside the cup
[01:07.28]When the clock strikes dinner time, we'll eat the treasure up!
[01:13.80]Aye, aye, Captain Marshmallow!
[01:15.28]Sailing where the candy breezes blow!
[01:19.00]Cookies in the treasure chest, chocolate on the map
[01:22.46]Give a little salute and a double-double clap! (Clap-clap!)
[01:26.82]We rule the living room today!
[01:33.00]Mission accomplished! Yay!</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/10_Lanterns-on-the-Lotus-Pond.webp" alt="Lanterns on the Lotus Pond album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Lanterns on the Lotus Pond <span class="subs-meta">· Guofeng fusion · 19 lines · 131 s</span></span><a class="subs-dl" href="/subs/lanterns-on-the-lotus-pond.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-10" src="https://content.hoelee.com/file/hoelee/music/Lanterns-on-the-Lotus-Pond-Mr-Hoelee.mp3"></audio>
    <pre id="subs-10" hidden>[ti:Lanterns on the Lotus Pond]
[ar:Mr Hoelee]
[00:10.69]Patter goes the summer rain upon the lotus leaf
[00:15.29]Washing all the dusty world and bringing cool relief
[00:19.89]Stepping on the wet bluestone beside the willow bridge
[00:24.49]Dragonflies are resting on the painted wooden ridge
[00:28.58]Fold a little paper boat and light a candlewick
[00:33.16]Make a wish before the current carries it so quick
[00:40.46]Floating down the lotus pond, a hundred lanterns glow
[00:46.38]Mirrored in the ripples where the silver fishes go
[00:49.62]Listen to the bamboo flute across the misty stream
[00:54.22]In the courtyard of the moon, we share a summer dream
[01:03.96]Silk fan in my little hand to chase the fireflies
[01:08.72]Red string braided in my hair beneath the lantern skies
[01:13.36]Grandma tells a fairy tale of rabbits in the moon
[01:18.22]While the sweet osmanthus flowers blossom in the June
[01:25.54]Floating down the lotus pond, a hundred lanterns glow
[01:30.02]Mirrored in the ripples where the silver fishes go
[01:34.62]Listen to the bamboo flute across the misty stream
[01:39.36]In the courtyard of the moon, we share a summer dream
[02:06.76]Red lanterns drifting far away...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/11_Wings-of-the-Kunpeng.webp" alt="Wings of the Kunpeng album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Wings of the Kunpeng <span class="subs-meta">· Guofeng fusion · 19 lines · 213 s</span></span><a class="subs-dl" href="/subs/wings-of-the-kunpeng.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-11" src="https://content.hoelee.com/file/hoelee/music/Wings-of-the-Kunpeng-Mr-Hoelee.mp3"></audio>
    <pre id="subs-11" hidden>[ti:Wings of the Kunpeng]
[ar:Mr Hoelee]
[00:25.18]Deep beneath the silent waves where no one ever sees
[00:31.60]Sleeps a tiny silver fish dreaming of the breeze
[00:38.16]Counting every falling tear that drops into the tide
[00:44.50]Waiting for the heavy gates of heaven to divide
[00:51.16]Do not fear the rising storm or shadows in the deep
[00:57.24]There is still a ancient promise that the ocean keeps
[01:06.34]Spread your wings and rise above the sea of clouds and rain
[01:13.18]Never let the mortal world bind you with a chain
[01:19.74]Fly across the mountain peaks where immortal rivers flow
[01:25.96]I will watch you turn to wind and let your spirit go
[01:49.10]Cycles of a thousand years are written in the stars
[01:54.96]Time will heal the broken shells and wash away the scars
[02:01.32]When the red camellia blooms upon the snowy shore
[02:07.58]We will meet beyond the sky and part ways nevermore
[02:16.86]Spread your wings and rise above the sea of clouds and rain
[02:23.52]Never let the mortal world bind you with a chain
[02:30.18]Fly across the mountain peaks where immortal rivers flow
[02:36.26]I will watch you turn to wind and let your spirit go
[02:48.84]Ah-ah-ah, fly into the wind...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/12_The-Puppeteer's-Thread.webp" alt="The Puppeteer's Thread album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">The Puppeteer's Thread <span class="subs-meta">· Guofeng fusion · 19 lines · 221 s</span></span><a class="subs-dl" href="/subs/the-puppeteer-s-thread.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-12" src="https://content.hoelee.com/file/hoelee/music/The-Puppeteers-Thread-Mr-Hoelee.mp3"></audio>
    <pre id="subs-12" hidden>[ti:The Puppeteer's Thread]
[ar:Mr Hoelee]
[00:24.12]Carved from a willow branch, painted with a smile
[00:34.16]Dressed in tiny embroidered silk, traveling mile by mile
[00:46.00]Ten thin threads of crimson silk tied upon my hands
[00:55.50]Dancing in the market square across the distant lands
[01:07.80]Crowds will gather when the gong begins to softly ring
[01:13.12]Laughing at the little doll that cannot speak or sing
[01:21.66]Dance in the lantern light, spin around the stage
[01:26.70]Telling all the legends of a long-forgotten age
[01:32.42]Though my heart is made of wood and cannot feel the rain
[01:37.66]Every pull upon the thread remembers joy and pain
[02:10.68]Autumn leaves are falling down upon the empty street
[02:15.82]Only echoes remain of the applause so sweet
[02:21.36]Keep me in the wooden box beside your warm fireplace
[02:26.98]Even when the paint fades off my little smiling face
[02:36.74]Dance in the lantern light, spin around the stage
[02:42.86]Telling all the legends of a long-forgotten age
[02:48.56]Though my heart is made of wood and cannot feel the rain
[02:54.02]Every pull upon the thread remembers joy and pain
[03:05.04]Ten thin threads of crimson silk...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/13_Paper-Airplane-in-September.webp" alt="Paper Airplane in September album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Paper Airplane in September <span class="subs-meta">· Acoustic folk · 18 lines · 128 s</span></span><a class="subs-dl" href="/subs/paper-airplane-in-september.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-13" src="https://content.hoelee.com/file/hoelee/music/Paper-Airplane-in-September-Mr-Hoelee.mp3"></audio>
    <pre id="subs-13" hidden>[ti:Paper Airplane in September]
[ar:Mr Hoelee]
[00:10.42]Sunlight on the wooden desk is moving inch by inch
[00:15.10]Sparrows on the telegraph wire never seem to flinch
[00:20.88]I fold a page of notebook paper creases nice and neat
[00:25.60]And throw it out the window to the quiet autumn street
[00:30.88]Fly away, paper plane, into the golden air
[00:36.36]Carry all the little words I didn't dare to share
[00:41.64]Goodbye to the summer clouds, hello to the breeze
[00:48.72]Floating past the bicycles and underneath the trees
[01:03.30]Chalk dust on the blackboard fades as afternoon goes by
[01:08.38]Clouds like cotton candy drift across the September sky
[01:13.76]Every simple little thing we took for granted then
[01:18.90]Stays inside a photograph we'll look at once again
[01:24.30]Fly away, paper plane, into the golden air
[01:29.66]Carry all the little words I didn't dare to share
[01:34.90]Goodbye to the summer clouds, hello to the breeze
[01:41.68]Floating past the bicycles and underneath the trees
[01:57.78]Underneath the autumn trees...
[02:02.18]Goodbye, summer days.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/14_Porridge-and-Evening-Smoke.webp" alt="Porridge and Evening Smoke album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Porridge and Evening Smoke <span class="subs-meta">· Acoustic folk · 17 lines · 144 s</span></span><a class="subs-dl" href="/subs/porridge-and-evening-smoke.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-14" src="https://content.hoelee.com/file/hoelee/music/Porridge-and-Evening-Smoke-Mr-Hoelee.mp3"></audio>
    <pre id="subs-14" hidden>[ti:Porridge and Evening Smoke]
[ar:Mr Hoelee]
[00:11.54]Smoke is rising from the roof into the purple sky
[00:17.04]While the evening swallows call and hurry on up high
[00:22.84]Steam upon the kitchen glass is blurring out the view
[00:28.48]Sitting on a wooden stool, I wait for dinner too
[00:34.02]One bowl of warm white rice, a dish of tender greens
[00:39.52]The simplest kind of happiness is what the evening means
[00:45.42]No matter how the outside world is noisy, cold, and wide
[00:50.98]The lamp upon the dining table keeps us warm inside
[01:09.84]Wrinkles on a gentle hand that smooths my messy hair
[01:14.88]Telling me to eat it all and sit up in my chair
[01:20.48]Years will pass like river water flowing to the sea
[01:26.26]But this quiet kitchen light will always stay with me
[01:33.04]One bowl of warm white rice, a dish of tender greens
[01:38.76]The simplest kind of happiness is what the evening means
[01:44.60]No matter how the outside world is noisy, cold, and wide
[01:50.14]The lamp upon the dining table keeps us warm inside
[01:57.40]The lamp is always on for you...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/15_Green-Train-to-the-Sea.webp" alt="Green Train to the Sea album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Green Train to the Sea <span class="subs-meta">· Acoustic folk · 17 lines · 108 s</span></span><a class="subs-dl" href="/subs/green-train-to-the-sea.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-15" src="https://content.hoelee.com/file/hoelee/music/Green-Train-to-the-Sea-Mr-Hoelee.mp3"></audio>
    <pre id="subs-15" hidden>[ti:Green Train to the Sea]
[ar:Mr Hoelee]
[00:28.22]Clickety-clack upon the track, the green train starts to roll
[00:29.42]Leaving all the city gray behind the tunnel hole
[00:30.62]Forehead pressed against the glass, I watch the fields go by
[00:31.82]Golden wheat and windmills waving underneath the sky
[00:33.02]Take me where the spring wind blows across the open blue
[00:34.22]Ten miles of wildflowers blooming just for me and you
[00:38.36]Every station has a name I've never heard before
[00:42.68]Soon the scent of salty air will meet us at the shore
[00:57.62]Strangers sharing oranges and stories in the seat
[01:01.92]Sunlight dancing in the aisle around my dangling feet
[01:06.68]Postcards in my backpack waiting for a stamp and pen
[01:11.50]To tell the people back at home I'm coming back again
[01:16.24]Take me where the spring wind blows across the open blue
[01:21.14]Ten miles of wildflowers blooming just for me and you
[01:26.34]Every station has a name I've never heard before
[01:30.66]Soon the scent of salty air will meet us at the shore
[01:46.82]Look, there is the sea...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/16_Sanctuary-of-the-Dawn.webp" alt="Sanctuary of the Dawn album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Sanctuary of the Dawn <span class="subs-meta">· Choral · 19 lines · 182 s</span></span><a class="subs-dl" href="/subs/sanctuary-of-the-dawn.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-16" src="https://content.hoelee.com/file/hoelee/music/Sanctuary-of-the-Dawn-Mr-Hoelee.mp3"></audio>
    <pre id="subs-16" hidden>[ti:Sanctuary of the Dawn]
[ar:Mr Hoelee]
[00:22.82]Ahh-ahh, sanctus, ahh...
[00:25.64]Shadows fade before the light that rises in the east
[00:31.74]Every troubled storm is stilled and every sorrow ceased
[00:37.76]Through the tall stained-glass windows morning beams descend
[00:44.12]Shining on a quiet road where broken hearts can mend
[00:50.56]Peace be with you, weary soul, lay your burden down
[00:58.74]Where the silver rivers flow beyond the sleeping town
[01:05.08]Lift your eyes unto the hills where golden angels sing
[01:11.46]Rest beneath the gentle shelter of a shining wing
[01:21.16]Like a bell across the valley ringing clear and true
[01:27.18]Every dawn is bringing back a promise made anew
[01:34.18]Walk into the quiet light and leave the dark behind
[01:40.06]There is grace in every breath for all of humankind
[01:48.40]Peace be with you, weary soul, lay your burden down
[01:54.64]Where the silver rivers flow beyond the sleeping town
[02:01.02]Lift your eyes unto the hills where golden angels sing
[02:07.48]Rest beneath the gentle shelter of a shining wing
[02:15.30]Peace be with you... forevermore.
[02:26.48]Amen, ahh...</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/17_Look-Upon-Your-Path.webp" alt="Look Upon Your Path album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Look Upon Your Path <span class="subs-meta">· Choral · 17 lines · 122 s</span></span><a class="subs-dl" href="/subs/look-upon-your-path.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-17" src="https://content.hoelee.com/file/hoelee/music/Look-Upon-Your-Path-Mr-Hoelee.mp3"></audio>
    <pre id="subs-17" hidden>[ti:Look Upon Your Path]
[ar:Mr Hoelee]
[00:09.64]Look upon the winding path where fallen leaves are blown
[00:14.64]Children walking hand in hand so no one is alone
[00:19.64]Even when the winter wind is cold against your face
[00:25.04]Every step you take today is leading to a place
[00:31.52]Shine your light into the dark, let your voice be heard
[00:36.60]Rising like the morning lark, a brave and little bird
[00:41.60]Though the night is long and deep, the stars are watching still
[00:46.68]Spring will wake the seeds asleep upon the quiet hill
[00:57.00]Write a song upon the wind for everyone who strays
[01:02.16]Hope is like a tiny spark that sets the sky ablaze
[01:07.48]Hold the hand beside you tight and never look below
[01:11.88]There are gardens waiting where the wildest rivers flow
[01:21.30]Shine your light into the dark, let your voice be heard
[01:26.36]Rising like the morning lark, a brave and little bird
[01:31.34]Though the night is long and deep, the stars are watching still
[01:36.44]Spring will wake the seeds asleep upon the quiet hill
[01:45.98]Look upon your path... the dawn is near.</pre>
  </div>
</figure>

<figure class="subs-track">
  <div class="subs-cover">
    <img src="/covers/18_Bells-of-the-Winter-Star.webp" alt="Bells of the Winter Star album cover" loading="lazy" width="160" height="160" />
  </div>
  <div class="subs-content">
    <figcaption><span class="subs-title">Bells of the Winter Star <span class="subs-meta">· Choral · 18 lines · 177 s</span></span><a class="subs-dl" href="/subs/bells-of-the-winter-star.srt" download>.srt</a></figcaption>
    <audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="subs-18" src="https://content.hoelee.com/file/hoelee/music/Bells-of-the-Winter-Star-Mr-Hoelee.mp3"></audio>
    <pre id="subs-18" hidden>[ti:Bells of the Winter Star]
[ar:Mr Hoelee]
[00:08.50]Ding-dong, ding-dong, ring out the bells...
[00:18.20]Snowflakes falling from the sky in silence pure and white
[00:25.44]Dressing every cedar bough in robes of silver light
[00:34.56]Listen to the steeple bells across the frozen square
[00:43.42]Sending out a melody upon the midnight air
[00:52.92]Ring, silver bells, across the winter snow
[01:01.30]Tell all the world the star has begun to glow
[01:11.76]Gloria, gloria, hear the angels sing
[01:19.28]Peace on the earth this holy night will bring
[01:32.12]Candles in the frosted glass are shining warm and clear
[01:40.42]Guiding every traveler home at the end of year
[01:49.68]Every chime is like a prayer ascending to the sky
[01:58.26]Watched by all the quiet stars that never pass us by
[02:09.62]Ring, silver bells, across the winter snow
[02:18.22]Tell all the world the star has begun to glow
[02:28.00]Gloria, gloria, hear the angels sing
[02:37.14]Peace on the earth this holy night will bring
[02:49.30]Gloria... peace on the earth.</pre>
  </div>
</figure>
<script>
// subs-track: scroll LRC in step with <audio>; current line stays centred
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
  const raw = document.getElementById(audio.dataset.lrc).textContent;
  const lines = raw.split('\n').map((l) => {
    const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
    return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
  }).filter(Boolean);
  const box = document.createElement('div');
  box.className = 'subs-box';
  lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
  audio.parentNode.insertBefore(box, audio.nextSibling);
  let cur = -1;
  audio.addEventListener('timeupdate', () => {
    const now = audio.currentTime;
    // last line whose timestamp has passed; -1 before the first line starts
    let i = -1;
    for (let k = 0; k < lines.length; k++) { if (lines[k].t <= now) i = k; else break; }
    if (i !== cur) {
      if (cur >= 0) box.children[cur].classList.remove('on');
      cur = i;
      if (cur >= 0) {
        const p = box.children[cur];
        p.classList.add('on');
        const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
        box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
      } else {
        box.scrollTop = 0;
      }
    }
  });
});
</script>

文章 02 / 05:购买主机前,如何核实一家服务商的真实情况

✍️ 顾问审阅意见区 (Review Feedback)

【审阅结论】:[ ] 通过  [ ] 需微调  [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:

🇨🇳 中文版原文 (Chinese Markdown Twin)

---
title: "购买主机前,如何核实一家服务商的真实情况"
description: "一份可复用的核查清单,帮你把真实的主机声明和营销话术分开:对比域名年龄与「运营多年」的说法、在 AUP 里找出隐藏的禁止条款、并用第三方渠道交叉验证口碑。"
pubDate: 2025-11-26
updatedDate: 2026-09-09
category: tutorials
blueprint: sme-digital-suite
blueprintLayer: L4-managed-ops-handover
tags: [hosting, vps, due-diligence, whois, devops]
ogImage: /og/zh-how-to-verify-a-hosting-provider-before-you-buy.jpg
banner: /banners/how-to-verify-a-hosting-provider-before-you-buy.jpg
---

每家主机服务商的首页都是一串承诺:_「自 2005 年起运营」_、_「保证在线率」_、
_「注重隐私」_、_「不限流量」_。其中有些是真的,但出人意料地大量是假的——
而且在你刷信用卡、把 DNS 指向他们服务器之前,没人会去核实。

最近我要为一个有具体要求的项目(隐私司法辖区、付款方式、流量上限)筛选一批
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程。它确实揪出了问题——
有的服务商声称「运营多年」,但域名比我的手机还新;有的服务商自己条款里就静悄悄地
禁止了我想跑的东西;还有的上了第三方黑名单。

下面这份清单,就是我按顺序跑的流程。全程不需要付费工具。

## 1. 把域名年龄和「自 X 年起」的说法做对比

一家说 _「自 2012 年起值得信赖」_ 的服务商,它的域名应该比我的上一单咖啡还老。
要是域名才四个月,「自 2012 年起」要么是个换了马甲的壳,要么就是撒谎——无论哪种,
它都告诉了你这家公司是怎么描述自己的。

最快的办法是查 WHOIS/RDAP。RDAP 是 WHOIS 的现代替代品,返回 JSON,更好解析:

```bash
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
```

在 `events` 数组里找 `registration` 事件——那是**最初**的注册日期,不是最近一次续费。
(续费日期和注册商变更会排在时间线的后面,你要找的是*第一次*注册那条。)

误判规则要双向看:一个全新的域名*可能*是一家正当的新公司。但「自很久以前起」的
说法配上一个年轻的域名,永远是个红旗——它意味着这个历史声明无法独立验证,于是
那个页面上的其他话我也都会用同样的怀疑眼光看待。

## 2. 读 AUP(可接受使用政策)——不是特性页

这是信号最强、却最常被跳过的一步。特性页告诉你他们*允许你付钱买什么*,
AUP 告诉你他们*会以什么理由封停你*。这是两份不同的清单。

否决项通常藏在「禁止活动」那一节,而且措辞是关键:

- **「TOR 节点」「Tor 中继」或「出口节点」**——如果你打算跑匿名工具,这就是硬性
  否决,而且它可能藏在一份*看起来*只针对滥用行为的清单里。
- **「反向代理」「匿名化服务」「隧道」**——它禁掉的东西远超你的想象。很多正当架构
  (缓存代理、GitOps webhook 中继)在字面上都会踩中这种措辞。
- **司法辖区与身份条款**——「必须提供真实身份」「配合当地执法」、或者 AUP 绑定某个
  具体国家的法律。如果你选这家服务商就是为了司法辖区的距离,这条就直接抵消了。

一个实用技巧:把 AUP 链接打开,直接搜你关心的关键词。我曾经花十分钟通读了整份 AUP,
后来才意识到一次 `Ctrl-F` 搜「tor」五秒钟就能得到答案。

## 3. 交叉核对口碑——用第三方渠道,不是他们的官网

服务商自己网站上的客户评价只是装饰。你要找的是服务商**删不掉差评**的地方:

- **Trustpilot**——但要看*趋势*,不是平均值。3.5 分带一条长尾很正常;但如果是
  3.5 分、而最近半年全是一星「他们封停了我的服务器」评论,那就是真信号。
- **Reddit,尤其 r/hosting、r/webhosting、r/sysadmin**——搜品牌名。社区对
  卷款跑路和大规模封停事件有长记忆,而且通常直白得很,会直接说哪家是「知名骗子」。
- **WHTop / HostAdvice**——评分本身很嘈杂,但「1.8/10 且反复出现同一种投诉」和
  「1.8 分但只是零散的零星差评」是两码事。

我找到的最有用的单一信号是:**封停报告。** 一家服务商遇到滥用投诉时的第一反应是
「先封停」、第二反应是「收『恢复费』」,这会在某条评论里白纸黑字写出来。那是
商业模式的红旗,不是一次客服事故。

## 4. 如果你在意隐私,就去核实「隐私」这个说法

在你核实到具体文档之前,「注重隐私」和「离岸」都只是营销词:

- **隐私政策里的留存条款。** 一家说「我们会依据法律/税务/会计需要,尽可能长期保留
  记录」的服务商,等于在告诉你他们保留日志。句号。
- **付款通道。** 到底是谁在真正处理加密货币,而且那是不是一家本身就要求 KYC 的
  第三方网关?一个没有外部身份验证步骤的自建网关,其隐私姿态和「通过 KYC 转售商
  买比特币」完全不同。
- **注册实体。** 如果公司注册在、并且受你威胁模型内某个国家的法律约束,那么
  「离岸托管,自 2000 年起」几乎一文不值。

## 5. 核对流量条款——「不限」很少真的不限

「防计量(unmetered)」这个词有特定含义,和「不限(unlimited)」不一样。严格意义的
unmetered = 无论传输多少都按固定价收费,但*端口速率*封死了上限。一个带 1 TB 计量
上限的计划上写「无限」,只是营销罢了。

如果你要按流量付费(而不是固定价),签约前先算真实数字:一个既要*接收*又要*转发*
文件的节点,要为这份流量付两次钱。一个看着「含 1 TB」很便宜的计划,一旦你把实际
传输量翻倍,反而成了最贵的选择。

## 整段流程的样子

按这个顺序跑——域名年龄、AUP、口碑、隐私文档、流量条款——然后对每家服务商用同样
的几个简单数字打分。目标不是完美,而是把你*真正依赖的*那几条声明显式地列出来、
变成可核查的,这样你就不会在凌晨两点被一封封停邮件告知真相。

这和写单元测试是同一个直觉:先把假设写下来,然后试着推翻它。我淘汰的大多数服务商,
都不是人能凭肉眼从首页看出来的那种撒谎——而是那种**十分钟清单**就能抓出来的撒谎。

🇬🇧 英文版原文 (English Markdown Primary)

---
title: "How to Verify a Hosting Provider Before You Buy"
description: "A reusable checklist for separating real hosting claims from marketing: check the domain age against the 'since' claim, read the AUP for silent disqualifiers, and cross-check reputation on third-party sources."
pubDate: 2025-11-26
updatedDate: 2026-09-09
category: tutorials
blueprint: sme-digital-suite
blueprintLayer: L4-managed-ops-handover
tags: [hosting, vps, due-diligence, whois, devops]
ogImage: /og/how-to-verify-a-hosting-provider-before-you-buy.jpg
banner: /banners/how-to-verify-a-hosting-provider-before-you-buy.jpg
---

Every hosting provider's homepage is a list of promises: *"in business since 2005"*,
*"guaranteed uptime"*, *"privacy-friendly"*, *"unlimited bandwidth"*. Some of it is
true. A surprising amount of it isn't — and none of it is checked before you hand
over a card number and point DNS at their servers.

Recently I had to shortlist a pool of providers for a project with specific
requirements (privacy jurisdiction, payment methods, traffic limits). I couldn't
afford to trust the marketing, so I built a quick verification pass. It caught
real problems — providers whose "operating for years" claim was younger than my
last haircut, providers whose own terms quietly banned the exact thing I wanted
to run, and providers on third-party blacklists.

Here's the checklist, in the order I run it. None of it needs a paid tool.

## 1. Check the domain age against the "since" claim

A provider that says *"trusted since 2012"* should have a domain older than my
coffee order. If the domain is four months old, "since 2012" is either a
re-branded shell or a lie — either way it tells you something about how the
company describes itself.

The quickest check is a WHOIS/RDAP lookup. RDAP is the modern replacement for
WHOIS and returns JSON, which is easier to parse:

```bash
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
```

Look at the `events` array for the `registration` event — that's the original
creation date, not the last renewal. (Renewal dates and registrar changes will
hide further down the timeline; the *first* registration is the one you want.)

The false-negative rule cuts both ways: a brand-new domain *can* be a legitimate
new company. But an old "since" claim on a young domain is always a flag — it
means the historical claim isn't independently verifiable, and I treat anything
else on that page with the same skepticism.

## 2. Read the AUP (Acceptable Use Policy) — not the features page

This is the highest-signal step and the one most people skip. The features page
tells you what they *allow you to pay for*. The AUP tells you what they'll
suspend you for. Those are different lists.

The disqualifiers are usually in the "Prohibited activities" section, and the
phrasing is what matters:

- **"TOR nodes", "Tor relays", or "exit nodes"** — if you plan to run anonymity
  tooling, this is a hard no, and it can hide in a list that *looks* like it's
  only about abuse.
- **"reverse proxies", "anonymizing services", "tunnels"** — this bans far more
  than you'd think. A lot of legitimate architecture (a caching proxy, a
  GitOps webhook relay) technically trips this wording.
- **Jurisdiction and identity clauses** — "must provide accurate identity",
  "complies with local law enforcement", or the AUP being bound to a specific
  country's law. If your whole reason for choosing the provider is jurisdictional
  distance, this line voids it.

The One Weird Trick: paste the AUP URL and search for the word you care about.
I spent ten minutes reading a whole AUP once before realizing a single
`Ctrl-F` for "tor" would have answered my question in five seconds.

## 3. Cross-check reputation — but on third-party sources, not their page

Testimonials on the provider's own site are decoration. You want places where
the provider can't delete the bad reviews:

- **Trustpilot** — but read the *trend*, not the average. A 3.5 with a long tail
  is fine; a 3.5 where the last six months are all 1-star "they suspended my
  server" reviews is a real signal.
- **Reddit, especially r/hosting, r/webhosting, r/sysadmin** — search the brand
  name. The community has a long memory for exit-scams and mass-suspension
  events, and it's usually blunt about which providers are "a well-known
  scammer."
- **WHTop / HostAdvice** — the ratings are noisy, but a 1.8/10 with a pattern of
  the same complaint repeated is different from a 1.8 with a handful of one-off
  gripes.

The single most useful signal I've found: **suspension reports.** A provider that
responds to abuse complaints by suspending first and charging a "reinstatement
fee" second will say so in black and white in someone's review. That's a
business-model red flag, not a support incident.

## 4. Verify the "privacy" claim if it matters to you

"Privacy-friendly" and "offshore" are marketing words until they're reflected in
the actual documents:

- **The privacy policy's retention section.** A provider that says "we retain
  records as long as necessary for legal / tax / accounting" is telling you they
  keep logs. Full stop.
- **The payment rail.** Who actually processes crypto, and is it a third-party
  gateway that itself requires KYC? A self-hosted gateway with no external
  identity step is a very different privacy posture than "bitcoin via a
  KYC-reseller."
- **The incorporated entity.** "Offshore hosting since 2000" is worth almost
  nothing if the company is registered in, and bound by the law of, a country in
  your threat model.

## 5. Check the traffic terms — "unlimited" rarely means unlimited

The word "unmetered" has a specific meaning that "unlimited" doesn't. Formally
unmetered = you pay a flat rate regardless of transfer, but the *port speed*
caps the ceiling. "Unlimited" on a plan with a metered 1 TB cap is just
marketing.

If you're paying for traffic (as opposed to a flat rate), model the real number
before you sign up: a relay that both receives *and* forwards a file pays for it
twice. A plan that looked cheap on "1 TB included" can be the expensive option
once you double the actual transit.

## The shape of the whole pass

Run these in order — domain age, AUP, reputation, privacy docs, traffic terms —
and score each provider on the same simple numbers. The goal isn't perfection;
it's to make the *claims you're actually relying on* explicit and checkable, so
you're not discovering the truth from a suspension email at 2 AM.

It's the same instinct as writing a unit test: state the assumption, then try to
break it. Most of the providers I eliminated weren't lying in a way a human
could spot from the homepage — they were lying in a way a *ten-minute checklist*
caught.

✍️ 顾问审阅意见区 (Review Feedback)

【审阅结论】:[ ] 通过  [ ] 需微调  [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:

🇨🇳 中文版原文 (Chinese Markdown Twin)

---
title: "一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约"
description: "基于 Chainlink VRF v2.5 与 Automation 构建彩票合约:为什么开奖无法被操纵、完整的合约讲解,以及那个让开奖挂掉的 mock 订阅资金不足 bug。"
pubDate: 2024-12-10
updatedDate: 2026-09-29
category: web3
tags: [solidity, chainlink, vrf, foundry, hardhat, blockchain]
ogImage: /og/zh-chainlink-vrf-v2-lottery-contract.jpg
banner: /banners/chainlink-vrf-v2-lottery-contract.jpg
draft: false
---

你能在链上运行一个没人能操纵的彩票吗——玩家不能、矿工不能,连部署它
的人也不能?这就是我写 `Raffle.sol` 时想回答的问题,也是这个项目要
用区块链预言机(oracle)的根本原因。

简短的回答是可以,但带着两个诚实的条件。随机数必须来自合约本身无法
预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定
「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约
本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。
这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来,
因为这篇文章的可信度就靠它。

## 为什么这是预言机少数真正有用的场景之一

合约无法自己产生随机数。当前区块的 `blockhash` 可以预测,
`block.timestamp` 由挖出区块的人决定,任何纯 Solidity 的「随机」函数
都是确定性的——每个玩家都能重算出来。链上彩票因此需要一个预言机,
而这是少数几个预言机不是弱点、反而是全部意义所在的场景。

Chainlink VRF(可验证随机函数)返回一个随机数,并附带一份合约在链上
验证的证明。合约在数字到达之前无法预测它,到达之后也无法重掷——
数字在开奖之前就已承诺,而计算出它的模块看不到谁参加了。这正是彩票
需要的性质,也是任何区块哈希都给不了的性质。

另一半是 Chainlink Automation。节点按定时器观察合约,调用
`checkUpkeep`;当它说「对,现在开奖」,节点就调用 `performUpkeep`。
合约自己的文档注释把目标说得很清楚:

```solidity
// Enter the lottery (paying some amount)
// Pick a random winner (verifiably random)
// Winner to be selected every X minutes -> completely automated
// Chainlink Oracle -> Randomness, Automated Execution (Chainlink Keeper)
```

没有 keeper 节点,没有人类——合约回答「现在该抽出赢家吗?」这个问题,
预言机按答案行动。

## 我试了什么,以及那个让开奖挂掉的 bug

项目叫 `hardhat-smartcontract-lottery`:一个 `Raffle` 合约,
用 Hardhat 和 Foundry 两套工具测试,部署并验证在 Sepolia 上。
搭建过程是常规的
VRF v2.5 流程:在 vrf.chain.link 创建订阅、充值、部署 consumer、把它
加进订阅,然后在 30 秒间隔上注册一个 Automation upkeep(间隔、0.01 ETH
入场费、50 万 gas 的回调上限,都放在 `HelperConfig` 里)。

开奖挂了,而且在合约内部完全看不见。在本地,VRF mock 毫无怨言地接受
了请求——然后 fulfill 回滚了。仓库自己的历史记录了这次失败——修复落
在一个标题为 "Fixed InsufficientBalance VRF Mock" 的提交里——痕迹至今
留在测试文件里。Foundry 测试里 `fulfillRandomWords` 调用旁的注释只写
着 `// InsuficientBalance()`,hardhat 测试里有一条更长、更惨的:
`// Here cannot run, always InsufficientBalance()`。

我当初没明白的是:mock 并不比主网宽松——它执行着同样的不变量。在它
愿意服务请求之前,订阅必须存在、必须有余额、必须把彩票合约注册为
consumer,而且 LINK 必须真的流动。mock 和 coordinator 一样记录订阅
余额——JS 测试会读 `getSubscription(...).balance` 回来确认——而在
真实链上,你的钱包用 `transferAndCall` 把 LINK 发给 coordinator,
订阅才会入账。我的订阅要么是空的,要么 consumer 还没挂上,于是
fulfill 被合理地弹了回来。

修复在测试的 setUp 里:Foundry 的 `setUp()` 在*任何测试运行之前*铸造
100 个 LINK 并为订阅充值(`LINK_BALANCE = 100 ether`):

```solidity
vm.startPrank(msg.sender);
if (block.chainid == LOCAL_CHAIN_ID) {
    link.mint(msg.sender, LINK_BALANCE);
    VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription(
        subscriptionId,
        LINK_BALANCE
    );
}
link.approve(vrfCoordinatorV2_5, LINK_BALANCE);
vm.stopPrank();
```

部署脚本在真实链上做同样的事——`FundSubscription` 用 `transferAndCall`
送出 `FUND_AMOUNT`(3 个 LINK):

```solidity
LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));
```

hardhat 部署脚本会给刚创建的订阅充值。这次教训花了我一个晚上:
**VRF mock 要求订阅先有资金才愿意服务请求**,而 checkUpkeep 自己的
文档注释也隐晦地这么写——"Implicity, your subscription is
funded with LINK." 随机数不是免费的,连在 mock 里也不是。

## 修复:逐段讲解合约

`Raffle` 继承自两个 Chainlink 合约,都来自 v2.5 线:

```solidity
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
import {AutomationCompatibleInterface} from "@chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol";

contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
```

构造函数接收 coordinator 地址、订阅 ID、gas lane(key hash)、间隔、
入场费和回调 gas 上限,除了必须变化的状态外全部 `immutable`。两个
常量很关键:`REQUEST_CONFIRMATIONS = 3` 和 `NUM_WORDS = 1`——
开奖只需要一个随机词,且要等三个区块确认。

**入场。** `enterRaffle` 刻意保持无聊:要么付足够的钱,否则回滚
`Raffle__NotEnoughETHEntered`;要么处于 `OPEN` 状态,否则回滚
`Raffle__RaffleNotOpen`。然后把发送者压栈,发出事件:

```solidity
if (msgValue < i_entranceFee) {
    revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
    revert Raffle__RaffleNotOpen();
}
s_players.push(payable(msg.sender));
emit RaffleEnter(msg.sender);
```

**决定是否开奖。** `checkUpkeep` 用一行写完了整个 Automation 合约:

```solidity
bool isOpen = RaffleState.OPEN == s_raffleState;
bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval);
bool hasPlayers = s_players.length > 0;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```

**开奖。** `performUpkeep` 只能被网络调用,但它仍然重新检查
`checkUpkeep`——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。
检查失败时它回滚一个自定错误,错误里自带证据:

```solidity
revert Raffle__UpkeepNotNeeded(
    address(this).balance,
    s_players.length,
    uint256(s_raffleState)
);
```

错误数据本身就会告诉你哪个条件失败了。然后状态翻转,VRF 请求发出:

```solidity
s_raffleState = RaffleState.CALCULATING;

VRFV2PlusClient.RandomWordsRequest memory req = VRFV2PlusClient.RandomWordsRequest({
    keyHash: i_gasLane,
    subId: i_subscriptionId,
    requestConfirmations: REQUEST_CONFIRMATIONS,
    callbackGasLimit: i_callbackGasLimit,
    numWords: NUM_WORDS,
    extraArgs: VRFV2PlusClient._argsToBytes(
        VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
    )
});

uint256 requestId = s_vrfCoordinator.requestRandomWords(req);
emit RequestedRaffleWinner(requestId);
```

`nativePayment: false` 表示这次请求用订阅里的 LINK 付费——这正是资金
bug 之所以要紧的原因。

**锁。** `RaffleState` 是一个枚举,`OPEN` 和 `CALCULATING`。从
`performUpkeep` 翻转它那一刻起,到 `fulfillRandomWords` 把它重置为止,
彩票处于 `CALCULATING`,`enterRaffle` 一律回滚。没人能在请求与回调
之间溜进来,所以被随机数索引的数组,恰好就是随机数抽取所面对的玩家
集合。防操纵的全部故事,就藏在这一个枚举里。

**抽赢家。** VRF coordinator 会回调 `fulfillRandomWords`,
合约覆写它:

```solidity
uint256 indexOfWinner = randomWords[0] % s_players.length;
address payable recentWinner = s_players[indexOfWinner];
s_recentWinner = recentWinner;
s_players = new address payable[](0);
s_lastTimeStamp = block.timestamp;
s_raffleState = RaffleState.OPEN;
emit WinnerPicked(recentWinner);

(bool success, ) = recentWinner.call{value: address(this).balance}("");
if (!success) {
    revert Raffle__TransferFailed();
}
```

这个顺序是故意的,它就是 Checks-Effects-Interactions 模式(合约自己的
注释里点了名)。赢家、玩家数组、时间戳、彩票状态全部在 ETH 转账*之前*
重置。如果赢家恰好是个合约,它的 fallback 想重新入场:没什么可重入的
对象了——状态已经全新,转账失败则由自定错误处理,而不是一句无声的
`require` 字符串。

## 它是怎么被测试的——一套故意保留的混合测试套件

仓库为同一个合约保留了两套单元测试,因为它们抓的是不同的东西。
Hardhat/JS 套件(`Raffle.test.js`)用 hardhat-deploy
fixture、命名账户和 ethers 事件断言——它甚至在完整的端到端测试里监听
`WinnerPicked`。
Foundry 套件(`RaffleTest.t.sol`)是纯 Solidity:它 prank
coordinator mock,用
`vm.warp(block.timestamp + interval + 1)` 拨
时间、roll 区块,然后用同一种语言断言一切。两套测试独立地断言
相同的行为——hardhat 测试甚至检查
`consumers.includes(raffle.address)` 来证明订阅确实
连到了合约。

完整的开奖流程是端到端覆盖的:四个玩家入场、执行 `performUpkeep`、
从发出的日志里抓 `requestId`、经由 mock 完成 fulfill,然后断言赢家拿到
整个奖池、彩票回到 `OPEN`、时间戳前进了。

单元层周围是让「基于 mock 的测试」保持诚实的设施:一个 `LinkToken`
mock(ERC-677,带 `transferAndCall`)、Chainlink
`VRFCoordinatorV2_5Mock`,以及 `HelperConfig`,它按链构建
`NetworkConfig`。在 `LOCAL_CHAIN_ID`
(31337) 上它会自己部署 mock 并创建订阅;在 Sepolia 和主网上它持有
真实的 coordinator、gas lane 和 LINK 地址。两套工具链各有一份部署脚本:
`deploy/01-deploy-raffle.js`
(hardhat-deploy),以及 `script/DeployRaffle.s.sol`
(forge,配套 `Interactions.s.sol` 里的 `CreateSubscription`、
`FundSubscription`、`AddConsumer`)。

单元测试覆盖不了的东西,文件夹布局自己就承认了:integration 测试目录
是一份注释骨架,列着完整的金字塔——unit、integration、fork、staging、
fuzzing、formal verification。Mock 控制恰恰是你在真实网络上会失去的
东西,所以单元测试带着 `skipFork` modifier("Testnet cannot
test with Mock, don't have Mock control")——staging 阶段对着真实
Sepolia VRF 跑,练的是真正的订阅、资金和回调延迟,这些是 mock 只能
假装的东西。

最后,`.gas-snapshot` 把每个测试的成本钉死,回归被当作一个数字抓
出来,而不是一种感觉。完整的抽奖测试——从入场到付款的整条开奖流程
——是 335,011 gas:

```text
RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)
```

## 前端

仓库还带了一个纯 HTML 前端 `pages/1/`,它是笔记里列出的七种与合约
交互方式的第一种(HTML/JS,然后是 Next.js + 原生 ethers、web3-react、
react-moralis、web3Modal、useDapp、wagmi)。浏览器不能
`require("ethers")`,所以页面被 browserify 打包成 bundle:

```bash
yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js
```

`ProperCatch` 这个文件名本身就是重点:要修的就是
错误处理。每一个异步交互——connect、store、retrieve——都包在
`try/catch` 里,把错误 log 出来而不是让 promise 无处理地 reject;每条
路径都先检查 `typeof window.ethereum !== "undefined"`,钱包缺失时把
按钮文字换成 "Please install MetaMask"。旁边的 `index.js` 旧版本里一个
`try/catch` 都没有:交易被拒绝时 promise 只会无处理地在控制台里 reject,
而且它直接 `new ethers.providers.Web3Provider(window.ethereum)`,前面没有
任何判断。它是个小页面,但它决定了 demo
在交易被拒绝时是无声死掉,还是告诉你发生了什么。

## 重做时我会改什么

这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。

运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会
消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值——
我会写一个小监控脚本,给订阅补仓、余额太低时报警。

合约内部我会改四件事。第一,用 `randomWords[0] % s_players.length` 选
赢家在数组长度不能整除 2^256 时有模偏差——玩家少时可忽略,但真彩票
应该用拒绝采样,或者多要几个词。第二,回调忽略了 `requestId`——
`CALCULATING` 锁让重放不现实,但我会存下未完成的请求,并在
`fulfillRandomWords` 里校验。第三,赢家拿走全部余额,运营者一分不赚;
真彩票需要内置手续费或所有者分成。第四,没有暂停或紧急停止——一旦
出 bug,资金要卡到下一次开奖。

## 结果

一个数字,来自 gas snapshot:完整的开奖——四人入场、upkeep 触发、
VRF mock fulfill、赢家收款——在 Foundry 套件里跑 **335,011 gas**,
hardhat 套件又把同一个端到端行为断言了一遍。合约已部署并验证在
Sepolia 上(JS 部署的 0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4,
hardhat 部署的 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd)。我开头的
问题有了一个能指给人看的答案:随机数来自合约无法预测或重掷的模块,
状态机在开奖进行时锁住彩票,而自动化决定何时开奖。

我是全栈 Web 开发者和 DevOps 工程师,我构建完整的应用——
前端、后端、部署,以及那些必须持续跑下去的部分。如果你需要
一个全栈 Web 项目,或想让现有项目被可靠地部署,来找我:[WhatsApp](https://wa.me/60127972969)
· [[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com)。

🇬🇧 英文版原文 (English Markdown Primary)

---
title: "An Automated Lottery Contract on Chainlink VRF v2.5 and Automation"
description: "Building a lottery on Chainlink VRF v2.5 and Automation: why the draw can't be rigged, a full contract walkthrough, and the mock-funding bug that broke it."
pubDate: 2024-12-10
updatedDate: 2026-09-29
category: web3
tags: [solidity, chainlink, vrf, foundry, hardhat, blockchain]
ogImage: /og/chainlink-vrf-v2-lottery-contract.jpg
banner: /banners/chainlink-vrf-v2-lottery-contract.jpg
draft: false
---

Can you run a lottery on-chain that nobody can rig — not the players, not the
miners, and not even the person who deployed it? That was the question I was
trying to answer when I built `Raffle.sol`, and it is the reason this project
uses a blockchain oracle at all.

The short answer is yes, with two honest conditions. The randomness has to
come from somewhere the contract itself cannot predict or reroll, and the
draw has to happen without a human pressing a button — because a human with
a button can choose *when* to draw, and "when" is already an attack. This
post walks through the contract, the bug that broke the draw, how I test it,
and where I would do things differently. It is a learning project, not
production money-handling code — I say that up front because the post's
credibility depends on it.

## Why this is one of the few genuinely good uses of an oracle

A contract cannot produce its own randomness. `blockhash` of the current
block is predictable, `block.timestamp` is chosen by whoever mines the
block, and any pure-Solidity "random" function is deterministic — every
player can recompute it. An on-chain lottery therefore needs an oracle, and
this is one of the few cases where an oracle is not a weakness but the whole
point.

Chainlink VRF (Verifiable Randomness Function) returns a random number with a
proof that the contract verifies on-chain. The contract cannot predict the
number before it arrives and cannot reroll it after — the number is committed
before the draw, and the module that produced it cannot see who entered. That
is exactly the property a lottery needs and the property no hash of the block
can give you.

The second half is Chainlink Automation. Nodes watch the contract and call
`checkUpkeep` on a timer; when it says "yes, draw now", they call
`performUpkeep`. The contract's own docstring describes the goal better than I
can:

```solidity
// Enter the lottery (paying some amount)
// Pick a random winner (verifiably random)
// Winner to be selected every X minutes -> completely automated
// Chainlink Oracle -> Randomness, Automated Execution (Chainlink Keeper)
```

No keeper nodes, no human — the contract answers the question "should a winner
be drawn right now?" and the oracle acts on the answer.

## What I tried, and the bug that broke the draw

The project is `hardhat-smartcontract-lottery`: one `Raffle` contract, tested
with both Hardhat and Foundry, deployed and verified on Sepolia. The setup
was the usual VRF v2.5 dance: create a subscription at vrf.chain.link, fund
it, deploy the consumer, add it to the subscription, then register an
Automation upkeep on a 30-second interval (the interval, the 0.01 ETH
entrance fee, and the 500,000-gas callback limit all live in `HelperConfig`).

The draw broke, and invisibly from inside the contract. Locally, the VRF mock
accepted the request without complaint — then the fulfillment reverted. The
repo's own history records the failure — the fix landed in a commit message
that literally reads "Fixed InsufficientBalance VRF Mock" — and the traces
are still in the test files. Next to the `fulfillRandomWords` calls in the
Foundry tests there is a comment that just says `// InsuficientBalance()`,
and the hardhat test has a longer, sadder one:
`// Here cannot run, always InsufficientBalance()`.

What I had not understood is that the mock is not laxer than mainnet — it
enforces the same invariant. Before it will serve a request, the subscription
must exist, hold a balance, and have the raffle registered as a consumer, and
the LINK has to actually move. The mock tracks the subscription's balance the
way the coordinator does — the JS test reads `getSubscription(...).balance`
back to confirm it — and on a real chain your wallet sends LINK to the
coordinator with `transferAndCall` so the subscription is credited. My
subscription was either empty or the consumer was not yet attached, and the
fulfillment legitimately bounced.

The fix lives in the test setup: the Foundry `setUp()` mints 100 LINK and
funds the subscription *before any test runs* (`LINK_BALANCE = 100 ether`):

```solidity
vm.startPrank(msg.sender);
if (block.chainid == LOCAL_CHAIN_ID) {
    link.mint(msg.sender, LINK_BALANCE);
    VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription(
        subscriptionId,
        LINK_BALANCE
    );
}
link.approve(vrfCoordinatorV2_5, LINK_BALANCE);
vm.stopPrank();
```

The deploy scripts fund real chains the same way — `FundSubscription` sends
`FUND_AMOUNT` (3 LINK) with `transferAndCall`:

```solidity
LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));
```

The hardhat deploy funds the subscription it just created. The lesson cost
me an evening: **a VRF mock needs a funded subscription before it will serve a
request, and checkUpkeep's own docstring says it implicitly — "Implicity, your
subscription is funded with LINK."** Randomness is not free, even in a mock.

## The fix, walked through the contract

`Raffle` inherits from two Chainlink contracts, both from the v2.5 line:

```solidity
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
import {AutomationCompatibleInterface} from "@chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol";

contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
```

The constructor takes the coordinator address, the subscription id, the gas
lane (key hash), the interval, the entrance fee, and the callback gas limit,
all `immutable` except the state that must change. Two constants matter:
`REQUEST_CONFIRMATIONS = 3` and `NUM_WORDS = 1` — the draw needs one random
word, confirmed three blocks.

**Entering.** `enterRaffle` is deliberately boring: pay >= the fee or revert
`Raffle__NotEnoughETHEntered`, and be in the `OPEN` state or revert
`Raffle__RaffleNotOpen`. Then push the sender and emit an event:

```solidity
if (msgValue < i_entranceFee) {
    revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
    revert Raffle__RaffleNotOpen();
}
s_players.push(payable(msg.sender));
emit RaffleEnter(msg.sender);
```

**Deciding whether to draw.** `checkUpkeep` is the whole Automation
contract in one line:

```solidity
bool isOpen = RaffleState.OPEN == s_raffleState;
bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval);
bool hasPlayers = s_players.length > 0;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```

**Drawing.** `performUpkeep` is only callable by the network, but it re-checks
`checkUpkeep` anyway — an upkeep can be triggered with stale data, and defense
in depth costs nothing. If the check fails it reverts with a custom error that
embeds the evidence:

```solidity
revert Raffle__UpkeepNotNeeded(
    address(this).balance,
    s_players.length,
    uint256(s_raffleState)
);
```

The error data itself tells you which condition failed. The state then flips
and the VRF request goes out:

```solidity
s_raffleState = RaffleState.CALCULATING;

VRFV2PlusClient.RandomWordsRequest memory req = VRFV2PlusClient.RandomWordsRequest({
    keyHash: i_gasLane,
    subId: i_subscriptionId,
    requestConfirmations: REQUEST_CONFIRMATIONS,
    callbackGasLimit: i_callbackGasLimit,
    numWords: NUM_WORDS,
    extraArgs: VRFV2PlusClient._argsToBytes(
        VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
    )
});

uint256 requestId = s_vrfCoordinator.requestRandomWords(req);
emit RequestedRaffleWinner(requestId);
```

`nativePayment: false` means the request is paid in LINK from the subscription
— which is why the funding bug mattered.

**The lock.** `RaffleState` is an enum, `OPEN` and `CALCULATING`. From the
moment `performUpkeep` flips it until `fulfillRandomWords` resets it, the
raffle is `CALCULATING`, and `enterRaffle` reverts. Nobody can sneak in
between the request and the callback, so the array the randomness indexes is
exactly the set of players the randomness was drawn against. This is the
whole rig-proofing story in one enum.

**Picking the winner.** The VRF coordinator calls back into
`fulfillRandomWords`, which the contract overrides:

```solidity
uint256 indexOfWinner = randomWords[0] % s_players.length;
address payable recentWinner = s_players[indexOfWinner];
s_recentWinner = recentWinner;
s_players = new address payable[](0);
s_lastTimeStamp = block.timestamp;
s_raffleState = RaffleState.OPEN;
emit WinnerPicked(recentWinner);

(bool success, ) = recentWinner.call{value: address(this).balance}("");
if (!success) {
    revert Raffle__TransferFailed();
}
```

The order is deliberate and it is the Checks-Effects-Interactions pattern,
which the contract names in its own comment. The winner, the player array, the
timestamp, and the raffle state are all reset **before** the ETH transfer. If
the winner turns out to be a contract whose fallback tries to re-enter the
raffle, there is nothing left to re-enter against — the state is already fresh
and the transfer failure is handled by a custom error, not a silent `require`
string.

## How it is tested — a hybrid suite, on purpose

The repo keeps two unit suites for the same contract, because they catch
different things. The Hardhat/JS suite (`Raffle.test.js`) uses hardhat-deploy
fixtures, named accounts, and ethers event assertions — it even listens for
`WinnerPicked` in the full end-to-end test. The Foundry suite
(`RaffleTest.t.sol`) is native Solidity: it pranks the coordinator mock, warps
time with `vm.warp(block.timestamp + interval + 1)`, rolls blocks, and asserts
everything in one language. Both suites assert the same behavior
independently — the hardhat test even checks
`consumers.includes(raffle.address)` to prove the subscription is wired.

The full draw is covered end to end: enter four players, run
`performUpkeep`, grab the `requestId` from the emitted log, fulfill it
through the mock, then assert the winner got the whole pot, the raffle is
`OPEN` again, and the timestamp moved.

Around the unit layer sits the infrastructure that makes a mock-based suite
honest: a `LinkToken` mock (ERC-677, with `transferAndCall`), the
Chainlink `VRFCoordinatorV2_5Mock`, and `HelperConfig`, which builds a
per-chain `NetworkConfig`. On `LOCAL_CHAIN_ID` (31337) it deploys the mocks
and creates a subscription itself; on Sepolia and mainnet it holds the real
coordinator, gas lane, and LINK addresses. Deploy scripts exist for both
`deploy/01-deploy-raffle.js` (hardhat-deploy) and `script/DeployRaffle.s.sol`
(forge, with `CreateSubscription` / `FundSubscription` / `AddConsumer` in
`Interactions.s.sol`).

What unit tests cannot cover is acknowledged by the folder layout: the
integration tests directory is a comment skeleton naming the full pyramid —
unit, integration, fork, staging, fuzzing, formal verification. Mock control
is exactly what you lose on a real network, so the unit tests carry the
`skipFork` modifier ("Testnet cannot test with Mock, don't have Mock
control") — staging runs against real Sepolia VRF exercise the real
subscription, funding, and callback latency that a mock can only fake.

Finally, `.gas-snapshot` pins the cost of every test so a regression is caught
as a number, not a feeling. The full winner-selection test — the entire draw,
from entrance through payment — is 335,011 gas:

```text
RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)
```

## The front end

The repo also carries a plain-HTML front end, `pages/1/`, which is method
one of seven the notes list for talking to a contract (HTML/JS, then Next.js
with raw ethers, web3-react, react-moralis, web3Modal, useDapp, wagmi). The
browser cannot `require("ethers")`, so the page is browserified into a
bundle:

```bash
yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js
```

The error-handling page is the `ProperCatch` variant, and the name is the
point: error handling was the thing being fixed. Every async interaction —
connect, store, retrieve — is wrapped in `try/catch` that logs the error
instead of letting the promise reject unhandled, and every path checks
`typeof window.ethereum !== "undefined"` first, flipping the button text to
"Please install MetaMask" when the wallet is missing.
The earlier `index.js` sitting next to it has no `try/catch` at all, so a
rejected transaction promise simply goes unhandled in the console, and it
builds `new ethers.providers.Web3Provider(window.ethereum)` with no guard in
front of it. It is a small page, but it is the difference between a demo that
dies silently on a rejected transaction and one that tells you what happened.

## What I'd do differently

This is a testnet learning project and I want the limits on the record: no
audit, no economic attack modeling, no mainnet money. The README calls it "a
learning project note" and that is exactly what it is.

Operationally, a real lottery has an ongoing cost that a learning project
doesn't: the VRF subscription drains LINK with every request, and the
Automation upkeep needs its own LINK balance. Nobody funds that automatically
— I'd build a small watch script that tops up the subscription and alerts
when balances run low.

In the contract itself I would change four things. First, picking the winner
with `randomWords[0] % s_players.length` has modulo bias whenever the array
length does not divide 2^256 — negligible at small player counts, but a real
lottery should use rejection sampling or more than one word. Second, the
callback ignores `requestId` — the `CALCULATING` lock makes a replay
impractical, but I would store the outstanding request and verify it in
`fulfillRandomWords`. Third, the winner takes the entire balance, so the
operator earns nothing; a real lottery needs a fee or owner share built in.
Fourth, there is no pause or emergency stop — a bug would leave funds
stuck until the next draw.

## The result

One number, from the gas snapshot: the complete draw — four players entering,
the upkeep firing, the VRF mock fulfilling, the winner paid — runs at
**335,011 gas** in the Foundry suite, and the hardhat suite asserts the
same end-to-end behavior a second time. The contract is deployed and
verified on Sepolia (0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4 via the JS
deploy and 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd via hardhat deploy),
and the question I started with has an answer I can point at: the random
number comes from a module the contract cannot predict or reroll, the state
machine locks the raffle while the draw is in flight, and the automation
decides when.

I'm a full-stack web developer and DevOps engineer, and I build complete
applications — front end, back end, deployment, the parts that have to keep
running. If you need a full-stack web project built or an existing one
reliably deployed, get in touch: [WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development) ·
[hoelee.com](https://hoelee.com).

文章 04 / 05:全链上 SVG NFT:把艺术作品放进合约里

✍️ 顾问审阅意见区 (Review Feedback)

【审阅结论】:[ ] 通过  [ ] 需微调  [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:

🇨🇳 中文版原文 (Chinese Markdown Twin)

---
title: "全链上 SVG NFT:把艺术作品放进合约里"
description: "如何用 Foundry 铸造全链上 SVG NFT:把 SVG 图片 base64 编码进合约,tokenURI() 动态生成 data URI 元数据,并从链上状态切换表情。"
pubDate: 2024-10-08
updatedDate: 2026-09-29
category: web3
tags: [solidity, foundry, erc721, nft, svg, base64, onchain]
ogImage: /og/zh-fully-on-chain-svg-nfts.jpg
banner: /banners/fully-on-chain-svg-nfts.jpg
draft: false
---

每个 NFT 都有两半。代币本身 —— 余额、授权、所有权 —— 住在合约里,
和链一样永久。艺术是另一回事:`tokenURI()` 返回一个字符串,而大多数
集合里,这个字符串指向*别处*:一个 `ipfs://` CID,一个 HTTPS URL。
代币是永久的,它指向的东西,却是一个没有任何合约能控制的外部依赖。

这是一个学习项目,不是生产代码 —— README 里写得很明白 —— 而且这里的
一切都跑在 Sepolia 测试网上。我把它写下来,是因为全链上这个模式确实
有用,下面的数字都是真实的,代码也小到可以一口气读完。

## 如何铸造一个艺术作品永不消失的 NFT?

如果你是个刚学 Solidity 的新手,拿这个问题去搜,大部分答案都会把你
引向 IPFS,再配一句关于 pinning 的 shrug。对小尺寸的艺术品,有更好的
答案:把艺术*放进合约里*,编码好,然后给钱包一个完整的 `data:` URI,
让元数据 JSON 和图片都在同一个字符串里。这样一来,艺术作品就是链上
的字节,和代币本身没有区别。

做法有两种。你可以把原始 SVG 存进合约,每次调用 `tokenURI()` 时才做
base64 编码 —— 部署更便宜,每次读取稍微多花点 gas。或者,你可以在
部署时一次性编码,存下成品 `data:image/svg+xml;base64,` 字符串。我的
`MoodNft` 用的是第二种,因为它还要根据链上状态在两张图之间切换,
而这一切正是**动态 NFT** 的最佳入门。

## 我先尝试的做法:一个只存 URI 的 ERC-721

起点是常规的 NFT 合约。我的叫 `BasicNft`:

```solidity
contract BasicNft is ERC721 {
    error BasicNft__TokenUriNotFound();
    uint256 private s_tokenCounter;
    mapping(uint256 => string) private s_tokenIdToUri;

    constructor() ERC721("Hoelee", "HOE") {
        s_tokenCounter = 0;
    }

    function mintNft(string memory tokenUri) public {
        s_tokenIdToUri[s_tokenCounter] = tokenUri;
        _safeMint(msg.sender, s_tokenCounter);
        s_tokenCounter = s_tokenCounter + 1;
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        if (ownerOf(tokenId) == address(0)) {
            revert BasicNft__TokenUriNotFound();
        }
        return s_tokenIdToUri[tokenId];
    }
}
```

它刻意做得非常小。`mintNft(tokenUri)` 把你给的字符串按代币存起来,
`tokenURI` 原样返回;如果代币从未被铸造,就触发一个自定义错误
(比带字符串的 `require` 更省 gas,也自带文档)。我用自己的名字命名
集合,因为重点是光明正大地学习:`ERC721("Hoelee", "HOE")`。

这个合约对 URI 指向什么没有任何意见 —— OpenZeppelin 的 `ERC721` 基类
也没有 —— 所以艺术住在 URI 所在的地方。

## IPFS 托管艺术的诚实问题

对 `BasicNft` 来说,流程是标准的:把元数据 JSON 放到 IPFS 上,把它的
`ipfs://<CID>` 交给合约,让钱包和市场通过网关去解析。渲染能成功,依赖
两件事一直成立:**有人持续 pin 住那个 CID**,并且**某个网关持续提供
它**。这两件事都在任何合约的控制范围之外。pin 一旦掉了,代币还在 ——
只是再也没有艺术了。

我自己的铸造脚本就是这有多随意的最佳证据。`Interactions.s.sol` 里带了
四个示例 URI:两个原生的 `ipfs://` CID,两个
`https://gateway.pinata.cloud/ipfs/...` URL。
README 在这个话题上的原话是:优先用原生 CID 形式,这样资产不依赖任何
单一网关。原文是:
"so the asset resolves independent of any single gateway"
网关 URL 是彻头彻尾的单一故障点。原生 CID 好一点,但它仍然依赖某个 pin 存在。

而保存下来的铸造日志显示,`BasicNft` 的 0 号代币实际存进去的是:

```text
ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png
```

这个 CID 指向的是一个我自己都没在跑的 pin。我没法保证那个节点 —— 或者
现在 pin 着它的任何人 —— 一直在线。「代币是永久的」和「艺术只是一句
承诺」之间的这道裂缝,正是我想要消除的东西。

## 修复方案:部署时把艺术编码进合约

`MoodNft` 把艺术本身存进合约。编码在部署脚本里完成:它读取 `img/`
下的两个 SVG 文件,在构造函数被调用之前,就把每一个都变成
`data:image/svg+xml;base64,` URI:

```solidity
function run() external returns (MoodNft) {
    string memory svgSmile = vm.readFile("img/smile.svg");
    string memory svgSad = vm.readFile("img/sad.svg");
    string memory imageUriSmile = svgToImageUri(svgSmile);
    string memory imageUriSad = svgToImageUri(svgSad);

    vm.startBroadcast();
    MoodNft moodNft = new MoodNft(imageUriSmile, imageUriSad);
    vm.stopBroadcast();

    return moodNft;
}

function svgToImageUri(
    string memory svg
) public pure returns (string memory) {
    string memory baseURL = "data:image/svg+xml;base64,";
    string memory svgBase64Encoded = Base64.encode(
        bytes(string(abi.encodePacked(svg)))
    );

    return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
```

两个细节很关键。`vm.readFile` 是 Foundry 的 cheatcode,在脚本运行期间
从磁盘读文件,所以合约里永远不会出现手写生成再粘贴进去的 base64 块
—— 链上的艺术可以被证明就是 `img/` 里的艺术。而 `Base64` 来自
OpenZeppelin 的 utils,我也不用自己写编码器。

构造函数随后把两个现成的 URI 存为状态:

```solidity
constructor(
    string memory happySvgImageUri,
    string memory sadSvgImageUri
) ERC721("MoodNft", "MN") {
    s_tokenCounter = 0;
    s_sadSvgImageUri = sadSvgImageUri;
    s_happySvgImageUri = happySvgImageUri;
}
```

`mintNft()` 不收任何 URI:它 `_safeMint` 铸造,把新代币记为
`Mood.HAPPY`,存进 `mapping(uint256 => Mood)`,然后计数器加一。
艺术在部署那一刻就定下来了,而不是铸造的时候。

## tokenURI 动态构建整个 NFT

这就是模式的核心:

```solidity
function tokenURI(
    uint256 tokenId
) public view override returns (string memory) {
    string memory imageURI;
    if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
        imageURI = s_happySvgImageUri;
    } else {
        imageURI = s_sadSvgImageUri;
    }

    string memory tokenMetadata = string.concat(
        '{"name":"',
        name(), // You can add whatever name here
        '", "description":"An NFT that reflects the mood of the owner, 100% on Chain!", ',
        '"attributes": [{"trait_type": "moodiness", "value": 100}], "image":"',
        imageURI,
        '"}'
    );

    string memory tokenURIJson = string(
        abi.encodePacked(
            _baseURI(),
            Base64.encode(
                bytes(abi.encodePacked(tokenMetadata))
            )
        )
    );
    return tokenURIJson;
}

function _baseURI() internal pure override returns (string memory) {
    return "data:application/json;base64,";
}
```

跟着走一遍它返回的东西:JSON 里的 `"image"` 字段本身就是一个
`data:image/svg+xml;base64,` URI —— 是艺术本身,不是指向艺术的指针。
然后整个元数据 JSON 再做一次 base64 编码,`_baseURI()` 在前面加上
`data:application/json;base64,`。于是 `tokenURI()` 返回一个完全
自包含的字符串。钱包解码它,就同时拿到名字、描述、属性和图片字节,
没有任何东西还需要去取。整条链路里没有 IPFS、没有 HTTPS、没有网关。

## flipMood:由链上状态驱动的动态 NFT

因为情绪就是*状态*,改变状态就改变了艺术:

```solidity
function flipMood(uint256 tokenId) public {
    if (
        getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender
    ) {
        revert MoodNft__NotOwnerOfToken();
    }
    if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
        s_tokenIdToMood[tokenId] = Mood.SAD;
    } else {
        s_tokenIdToMood[tokenId] = Mood.HAPPY;
    }
}
```

门槛是持有者*或*被授权地址 —— `getApproved()` 来自 `ERC721`,免费获得
—— 其他人一律得到 `MoodNft__NotOwnerOfToken()`。翻转之后,同一个
token id 的 `tokenURI` 就开始返回另一张图。同一个代币,不同的面孔,
全部在链上。这是最小可能的动态 NFT,也是思考「由游戏状态或链上事件
驱动的艺术」时很好的起点。

## 测试教会我的 Solidity 细节

**Solidity 里不能用 `==` 比较两个 `string`。** Solidity 只能比较值
类型;`string` 是动态字节数组。惯用修法是比较哈希,我的测试文件甚至
用注释写明了这一点:

```solidity
// string is array of bytes can't directly compare
// we can compare: bool, uint256, address, bytes32
assert(
    keccak256(abi.encodePacked(expectedName)) ==
        keccak256(abi.encodePacked(actualName))
);
```

统一用哈希比较:
`keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b))`
这个模式在测试套件里到处都是,也是链上需要比较字符串时伸手就该拿的。

**测试分两层。** 数一遍仓库里的测试函数:四个测试合约,一共六个测试。
`MoodNftTest` 是纯单元测试:直接用常量 base64 URI 构造 `MoodNft`。
另外三个 —— `DeployMoodNftTest`、`BasicNftTest`、
`MoodNftIntegrationTest` —— 实例化部署脚本并调用
`deployer.run()`,走的都是真实路径:在真正的 `img/*.svg` 上执行
`vm.readFile`、编码、部署。也就是说,连「单元」部署测试都在拿真实的
艺术文件验证 base64 流水线。

**身份伪装是两个 cheatcode。** `makeAddr("HOELEE")` 是从标签推导出的
确定性假地址 —— 不需要管理任何密钥。`vm.prank(USER)` 让*下一次*调用
看起来来自那个地址。两者配合,不需要钱包就能走通正常路径:

```solidity
function testFlipTokenToSad() public {
    vm.prank(USER);
    moodNft.mintNft();

    vm.prank(USER);
    moodNft.flipMood(0);

    assertEq(
        keccak256(abi.encodePacked(moodNft.tokenURI(0))),
        keccak256(abi.encodePacked(SAD_SVG_URI))
    );
}
```

**铸造脚本应该找到合约,而不是硬编码地址。** `Interactions.s.sol` 用
`DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid)`,
它会读 `broadcast/` 日志,返回当前链上该合约最近一次部署的地址:

```solidity
address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
    "BasicNft",
    block.chainid
);
mintNftOnContract(mostRecentDeployed);
```

我自己的脚本里还留着注释掉的 Sepolia 和 Anvil 硬编码地址 —— 这正是
这个模式存在要消灭的习惯。地址要从部署日志里拿,否则总有一天你会
给一个过期的合约铸造。

## 如果重来,我会怎么做

**链上艺术要付部署 gas,而且是永久的。** 两张 SVG 的每一个字节都在
部署时一次性付清,然后以构造函数字符串的形式永远住在合约存储里。
这是一次性成本,但它是真实的,并且随艺术尺寸增长。

**能放进去的艺术有上限。** EIP-170 把合约代码限制在 24,576 字节以内,
艺术字节要和逻辑共用这个预算。笑脸和哭脸这种矢量 SVG 是理想选择 ——
小、清晰、可缩放。照片或 3D 模型永远塞不进去,硬塞也只是浪费 gas。
我的 `BasicNft` 部署返回 `4102 bytes of code`;
`MoodNft` 的字节码还要再叠上两张 SVG。

**不是每个项目都该这么做。** 如果艺术很大、集合需要可变元数据、或者
部署预算紧张,IPFS 或文件存储加一个*可设置的* base URI 才是务实的默认
选择 —— `ERC721` 的 `_baseURI()` 钩子让这个模式也很容易。该问的正确
问题是:*这份艺术必须在所有 pin 服务都消亡后依然存活吗?* 如果是,
而且它放得进合约,那就上链。否则,别为这些字节付钱。

**锁死 SVG 的行尾。** 仓库通过 `.gitattributes` 把
`img/*.svg` 固定为 LF,保证编码后的字节是确定的;在 Windows 上,
`core.autocrlf` 会悄悄注入 CRLF,改变编码后的艺术。这种「艺术就是
字节」的模式,对这种隐形 bug 毫不留情。

**从第一天起就用 DevOpsTools。** 对单机学习来说,注释里的硬编码地址
没关系;一旦有第二次部署,它们就是陷阱。

## 量化的结果

仓库里保存了一份部署日志,是 `BasicNft` 部署到 Sepolia 的记录,所以
下面这些数字和文件里完全一致:

| Item | Value |
|---|---|
| Chain | `11155111` |
| Constructor trace | `[868596] → new BasicNft` |
| Deployed bytecode | `4102 bytes of code` |
| Deployment tx | `993568 gas * 0.538650187 gwei` = `0.000535185588997216 ETH` |
| Block | `6522146` |
| Deployed at | `0x84F0Ee970BD49FCf1b8Cd637EF4e4755DBE74e0E`, auto-verified on Etherscan |

把存了 IPFS URI 的 0 号代币铸造出来,花了 `181874 gas` ——
`0.000186204671471742 ETH`。也就是说,在测试网上把 NFT 放上去几乎不
花钱;IPFS 依赖的代价是隐形的,直到某个 pin 死掉的那一天。

而 `MoodNft` 真正重要的结果根本不需要日志:在 MetaMask 里把部署好的
合约和 0 号代币作为 collectible 添加,艺术作品就直接从链上 base64 SVG
渲染出来 —— 没有 IPFS 网关、没有网络查找、没有任何需要维持在线的东西。
同一个代币,`flipMood` 一按就换脸。艺术唯一依赖的「服务器」就是
区块链本身,而任何一个节点都跑着一份完整的链。

## 需要这类工作吗?

我是一个全栈 Web 开发者和 DevOps 工程师。这类链上 demo 是我学习的地方,
但我为企业做的工作是全栈 Web 开发 —— 网站、Web 应用、API,以及它们
背后的自托管栈和部署流水线。如果你需要一个从零开始的项目,或者想
用一个小的链上概念验证来验证想法,告诉我你想做出什么:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Web%20development%20project) ·
[hoelee.com](https://hoelee.com)。

🇬🇧 英文版原文 (English Markdown Primary)

---
title: "Fully On-Chain SVG NFTs: Putting the Artwork Inside the Contract"
description: "How to mint fully on-chain SVG NFTs with Foundry: base64-embed the artwork in the contract, build data URI metadata in tokenURI(), and flip moods on-chain."
pubDate: 2024-10-08
updatedDate: 2026-09-29
category: web3
tags: [solidity, foundry, erc721, nft, svg, base64, onchain]
ogImage: /og/fully-on-chain-svg-nfts.jpg
banner: /banners/fully-on-chain-svg-nfts.jpg
draft: false
---

Every NFT has two halves. The token itself — balances, approvals, ownership —
lives in the contract and is as permanent as the chain. The artwork is a
different story. `tokenURI()` returns a string, and for most collections that
string points *somewhere else*: an `ipfs://` CID, an HTTPS URL. The token is
permanent. The thing it points to is a dependency nobody's contract controls.

This project is a learning repo, not production work — the README says exactly
that — and everything here ran against the Sepolia testnet. I am writing it
up because the fully on-chain pattern is genuinely useful, the numbers below
are real, and the code is small enough to read in one sitting.

## How do you mint an NFT whose artwork cannot disappear?

If you search that question as a new Solidity developer, most answers route
you to IPFS with a shrug about pinning. There is a better answer for small
art: put the art *in* the contract, encoded, and hand the wallet a complete
`data:` URI that contains the metadata JSON and the image in one string. Then
the artwork is just bytes on the chain, no different from the token itself.

There are two ways to do it. You can store the raw SVG in the contract and
base64-encode it every time `tokenURI()` is called — cheaper to deploy, a
little more gas per read. Or you can encode the SVG once at deploy time and
store the finished `data:image/svg+xml;base64,` strings. My `MoodNft` does
the second, because it also flips between two images from on-chain state, and
the whole thing is a gentle introduction to **dynamic NFTs**.

## What I tried first: an ERC-721 that stores a URI

The starting point is the conventional NFT contract. Mine is `BasicNft`:

```solidity
contract BasicNft is ERC721 {
    error BasicNft__TokenUriNotFound();
    uint256 private s_tokenCounter;
    mapping(uint256 => string) private s_tokenIdToUri;

    constructor() ERC721("Hoelee", "HOE") {
        s_tokenCounter = 0;
    }

    function mintNft(string memory tokenUri) public {
        s_tokenIdToUri[s_tokenCounter] = tokenUri;
        _safeMint(msg.sender, s_tokenCounter);
        s_tokenCounter = s_tokenCounter + 1;
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        if (ownerOf(tokenId) == address(0)) {
            revert BasicNft__TokenUriNotFound();
        }
        return s_tokenIdToUri[tokenId];
    }
}
```

It is deliberately tiny. `mintNft(tokenUri)` stores whatever string you
hand it, per token; `tokenURI` returns that string; a custom error fires
if the token was never minted (cheaper than a string `require`, and
self-documenting).
I named the collection after myself, because the point was learning with a
straight face: `ERC721("Hoelee", "HOE")`.

The contract has no opinion about what the URI points to — neither does
OpenZeppelin's `ERC721` base — so the art lives wherever the URI lives.

## The honest problem with IPFS-hosted art

For `BasicNft` the flow is the standard one: put a metadata JSON on IPFS,
hand the contract its `ipfs://<CID>`, and let wallets and marketplaces
resolve it through a gateway. Rendering works as long as two things stay
true: **someone keeps a pin of that CID alive**, and **some gateway keeps
serving it**. Both are outside anyone's contract. If the pin drops, the token
still exists — it just has no art.

My own mint script is exhibit A of how sloppy this gets. `Interactions.s.sol`
carries four example URIs: two raw `ipfs://` CIDs and two
`https://gateway.pinata.cloud/ipfs/...` URLs. The README's own note on the
topic says to prefer the raw CID form "so the asset resolves independent of
any single gateway." A gateway URL is a single point of failure, full stop.
The raw CID is better, but it still depends on a pin existing somewhere.

And the saved mint log shows what actually got stored for token 0 of
`BasicNft`:

```text
ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png
```

That CID is a pointer to a pin I did not run. I cannot promise that node —
or whoever pins it now — stays up. That gap between "the token is permanent"
and "the art is a promise" is exactly what I wanted to remove.

## The fix: encode the artwork at deploy time

`MoodNft` stores the art itself. The deploy script does the encoding, reading
the two SVG files from `img/` and turning each into a
`data:image/svg+xml;base64,` URI before the constructor is even called:

```solidity
function run() external returns (MoodNft) {
    string memory svgSmile = vm.readFile("img/smile.svg");
    string memory svgSad = vm.readFile("img/sad.svg");
    string memory imageUriSmile = svgToImageUri(svgSmile);
    string memory imageUriSad = svgToImageUri(svgSad);

    vm.startBroadcast();
    MoodNft moodNft = new MoodNft(imageUriSmile, imageUriSad);
    vm.stopBroadcast();

    return moodNft;
}

function svgToImageUri(
    string memory svg
) public pure returns (string memory) {
    string memory baseURL = "data:image/svg+xml;base64,";
    string memory svgBase64Encoded = Base64.encode(
        bytes(string(abi.encodePacked(svg)))
    );

    return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
```

Two details matter. `vm.readFile` is a Foundry cheatcode that reads a file
from disk during the script run, so the contract never contains a base64 blob
that was hand-generated and pasted — the art on chain is provably the art in
`img/`. And `Base64` comes from OpenZeppelin's utils, so I am not writing an
encoder myself.

The constructor then stores both ready-made URIs as state:

```solidity
constructor(
    string memory happySvgImageUri,
    string memory sadSvgImageUri
) ERC721("MoodNft", "MN") {
    s_tokenCounter = 0;
    s_sadSvgImageUri = sadSvgImageUri;
    s_happySvgImageUri = happySvgImageUri;
}
```

`mintNft()` takes no URI at all: it `_safeMint`s, records the new token as
`Mood.HAPPY` in a `mapping(uint256 => Mood)`, and increments a counter. The
art was decided at deploy time, not at mint time.

## tokenURI builds the whole NFT on the fly

This is the heart of the pattern:

```solidity
function tokenURI(
    uint256 tokenId
) public view override returns (string memory) {
    string memory imageURI;
    if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
        imageURI = s_happySvgImageUri;
    } else {
        imageURI = s_sadSvgImageUri;
    }

    string memory tokenMetadata = string.concat(
        '{"name":"',
        name(), // You can add whatever name here
        '", "description":"An NFT that reflects the mood of the owner, 100% on Chain!", ',
        '"attributes": [{"trait_type": "moodiness", "value": 100}], "image":"',
        imageURI,
        '"}'
    );

    string memory tokenURIJson = string(
        abi.encodePacked(
            _baseURI(),
            Base64.encode(
                bytes(abi.encodePacked(tokenMetadata))
            )
        )
    );
    return tokenURIJson;
}

function _baseURI() internal pure override returns (string memory) {
    return "data:application/json;base64,";
}
```

Walk through what that returns: the `"image"` field inside the JSON is
itself a `data:image/svg+xml;base64,` URI — the art, not a pointer to art.
The whole metadata JSON is then base64-encoded, and `_baseURI()` prepends
`data:application/json;base64,`, so `tokenURI()` returns one self-contained
string. A wallet decodes it and has the name, the description, the attributes
and the image bytes with nothing left to fetch. There is no IPFS, no HTTPS,
no gateway in the entire chain of custody.

## flipMood: a dynamic NFT from on-chain state

Because the mood is *state*, changing state changes the art:

```solidity
function flipMood(uint256 tokenId) public {
    if (
        getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender
    ) {
        revert MoodNft__NotOwnerOfToken();
    }
    if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
        s_tokenIdToMood[tokenId] = Mood.SAD;
    } else {
        s_tokenIdToMood[tokenId] = Mood.HAPPY;
    }
}
```

The gate is owner *or* approved address — `getApproved()` comes free from
`ERC721` — and anyone else gets `MoodNft__NotOwnerOfToken()`. Flip it, and
`tokenURI` starts returning the other image for the same token id. Same
token, different face, all of it on chain. That is the smallest possible
dynamic NFT, and a good place to start thinking about art driven by
game state or on-chain events.

## Solidity details the tests taught me

**You cannot `==` two `string`s in Solidity.** Solidity only compares value
types; `string` is a dynamic array of bytes. The idiomatic fix is comparing
hashes, and my test file even documents it in a comment:

```solidity
// string is array of bytes can't directly compare
// we can compare: bool, uint256, address, bytes32
assert(
    keccak256(abi.encodePacked(expectedName)) ==
        keccak256(abi.encodePacked(actualName))
);
```

`keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b))` appears
all over the test suite, and it is the pattern to reach for whenever you must
compare strings on-chain.

**The test layout is two layers.** Counting the test functions in the repo
gives six tests across four test contracts. `MoodNftTest` is a pure unit
test: it constructs `MoodNft` directly with constant base64 URIs. The other
three — `DeployMoodNftTest`, `BasicNftTest`, and `MoodNftIntegrationTest` —
instantiate the deploy script and call `deployer.run()`, so they exercise the
real path: `vm.readFile` on the actual `img/*.svg`, encode, deploy. That
means even the "unit" deploy test is proving the base64 pipeline against the
real art files.

**Impersonation is two cheatcodes.** `makeAddr("HOELEE")` is a
deterministic fake address derived from a label — no keypair to manage.
`vm.prank(USER)` makes the *next* call look like it comes from that
address. Together they drive the happy path without a wallet:

```solidity
function testFlipTokenToSad() public {
    vm.prank(USER);
    moodNft.mintNft();

    vm.prank(USER);
    moodNft.flipMood(0);

    assertEq(
        keccak256(abi.encodePacked(moodNft.tokenURI(0))),
        keccak256(abi.encodePacked(SAD_SVG_URI))
    );
}
```

**Mint scripts should find the contract, not hardcode it.** The
`Interactions.s.sol` mint script uses
`DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid)`,
which reads the `broadcast/` logs and returns the latest deployment of
that contract on the current chain:

```solidity
address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
    "BasicNft",
    block.chainid
);
mintNftOnContract(mostRecentDeployed);
```

My own script still carries the old hardcoded Sepolia and Anvil addresses in
comments — evidence of the habit this pattern exists to kill. Get the address
from the deployment logs, or you *will* mint a stale contract someday.

## What I'd do differently

**On-chain art costs deploy gas, forever.** Every byte of both SVGs is paid
for once, at deploy time, and then lives in the contract's storage as
constructor strings. It is a one-time cost, but it is real, and it scales
with the size of the art.

**There is an upper bound on how much art fits.** EIP-170 caps a contract's
code at 24,576 bytes, and the art bytes share that budget with the logic.
Vector SVGs like the smiley and the sad face are ideal — small, crisp, and
they scale. A photograph or a 3D model will never fit, and trying to cram
realistic art in there is a waste of gas. My `BasicNft` deploy returned
`4102 bytes of code`; the `MoodNft` bytecode has two SVGs stacked on top of
that.

**Not every project should do this.** If the art is large, if the collection
needs mutable metadata, or if deploy budget matters, IPFS or a file store
with a *settable* base URI is the pragmatic default — and `ERC721`'s
`_baseURI()` hook makes that pattern easy too. The right question to ask is:
*must this art survive the death of every pinning service?* If yes and it
fits on chain, go on-chain. Otherwise, don't pay for the bytes.

**Pin the SVG line endings.** The repo pins `img/*.svg` to LF via
`.gitattributes` so the encoded bytes are deterministic; on Windows,
`core.autocrlf` would otherwise inject CRLF and silently change the encoded
art. That is exactly the kind of invisible bug that this pattern — "the art
is the bytes" — makes unforgiving.

**Use DevOpsTools from day one.** Hardcoded addresses in comments are fine
for a solo testnet learner; they are a trap the moment a second deploy
happens.

## The quantified result

The repo keeps one deployment log, and it is for `BasicNft` on Sepolia, so
these figures are exactly as recorded:

| Item | Value |
|---|---|
| Chain | `11155111` |
| Constructor trace | `[868596] → new BasicNft` |
| Deployed bytecode | `4102 bytes of code` |
| Deployment tx | `993568 gas * 0.538650187 gwei` = `0.000535185588997216 ETH` |
| Block | `6522146` |
| Deployed at | `0x84F0Ee970BD49FCf1b8Cd637EF4e4755DBE74e0E`, auto-verified on Etherscan |

The mint that stored the IPFS URI for token 0 cost `181874 gas` —
`0.000186204671471742 ETH`. So the price of putting an NFT on a testnet is
tiny; the price of the IPFS dependency is invisible until the pin dies.

And the result that matters for `MoodNft` needs no log at all: add the
deployed contract and token ID `0` as a collectible in MetaMask, and the
artwork renders straight from the on-chain base64 SVG — no IPFS gateway, no
network lookup, nothing to keep alive. For the same token id, `flipMood`
changes the face. The only "server" the art depends on is the blockchain
itself, and a full copy of that runs on any node.

## Want this kind of work?

I am a full-stack web developer and DevOps engineer. On-chain demos like
this one are where I learn, but the work I do for businesses is full-stack
web development — websites, web apps, APIs, and the self-hosted stacks and
deploy pipelines behind them. If you need a project built from scratch, or a
small on-chain proof of concept to validate an idea, tell me what you are
trying to ship: [WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Web%20development%20project) ·
[hoelee.com](https://hoelee.com).

文章 05 / 05:为什么在 Windows 上克隆后,我的链上 NFT 图像变了

✍️ 顾问审阅意见区 (Review Feedback)

【审阅结论】:[ ] 通过  [ ] 需微调  [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:

🇨🇳 中文版原文 (Chinese Markdown Twin)

---
title: "为什么在 Windows 上克隆后,我的链上 NFT 图像变了"
description: "为什么我在 Windows 上克隆后链上 NFT 的图像变了:core.autocrlf 往 vm.readFile 读取的 SVG 里注入 CRLF,base64 编码随之改变,而一个 eol=lf 规则就修好了它。"
pubDate: 2026-08-19
category: web3
tags: [foundry, solidity, svg, base64, git, windows, crlf]
ogImage: /og/zh-why-my-on-chain-nft-art-changed-on-windows.jpg
banner: /banners/why-my-on-chain-nft-art-changed-on-windows.jpg
draft: false
---

一个完全链上的 NFT,其作品本该是永久的。图像不是某个随时可能被
下架的 URL —— 它是一段 base64 字符串,在部署时写入合约存储,
之后永远无法改变。所以这个问题几乎是自己冒出来的:这样的作品
怎么会变?而且还是静默地变?

在我这个例子里,答案是:作品从来就不是一串固定的字符。它取决于
部署脚本在**执行部署的那台机器**上恰好从磁盘读到的字节 —— 而在
Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。

这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」
NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
两者都编码在合约自身中。图像 URI 在部署时由 `img/` 目录里的源
文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条
坏掉的换行符,如何差点让这幅作品变成「因机器而异」。

## 我遇到的事:部署脚本编码的是「读到的字节」

部署脚本用几行就干完了全部活:

```solidity
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
```

`vm.readFile` 返回一个字符串,`Base64.encode` 把这**一模一样的
字节**变成 `data:image/svg+xml;base64,...` URI,构造函数把两个
URI 永久存入存储。「链上」在这里是字面意思:部署机器上文件当时的
字节,如今就是合约数据 —— 永久地。部署时 SVG 哪怕差一个字节,
就是另一幅作品,在合约存续期内焊死不动。

我担心的这个差异来自 Git 的 `core.autocrlf`。Windows 上的 Git
安装通常会把人工作区里的文本文件改写成 CRLF 换行,即使仓库里存
的是 LF。SVG 是文本文件。CRLF 和 LF 是不同的字节,而 base64 对
不同字节的编码也不同。两行命令就能证明:

```bash
printf 'a\nb' | base64    # YQpi
printf 'a\r\nb' | base64  # YQ0KYg==
```

一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于:
没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。
`git status` 依然干净,因为 Git 在比较文本时先做了换行归一化。
Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何
机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于
执行部署的是哪台机器。

## 修法:.gitattributes 里的一条规则

修法是一个文件、一条规则,而那段注释同样重要:

```gitattributes
# Force LF line endings for asset files read by forge scripts (vm.readFile)
# so the working tree always matches what's stored in git, regardless of
# core.autocrlf / Windows checkout behavior.
img/*.svg text eol=lf
```

为什么有效:`text` 告诉 Git 把这些文件当作文本并做归一化,所以
仓库里它们永远以 LF 存储。`eol=lf` 则把这些人路径的工作区检出
钉死在 LF 上,覆盖任何机器上的 `core.autocrlf` 设置。两个属性
合在一起意味着:在一台 `core.autocrlf=true` 的 Windows 机器上,
`img/*.svg` 依然以 LF 检出 —— 所以 `vm.readFile` 永远返回作者
提交时的那组字节,base64 URI 跨平台可复现。

说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的
归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的,
规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能
发生的分歧。

同一个 commit 还修了第二件静默出错的东西:`foundry.toml` 里
`remappings` 的一个拼写错误。这条映射决定了
`@openzeppelin/contracts/...` 的导入如何解析到子模块,所以一个
错字就会让构建失败,而报错和我写的代码毫无关系:

```toml
remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]
```

## 紧挨着的两个坑

### 坑一:没有 fs_permissions,vm.readFile 拒绝执行

`vm.readFile` 是一个 *fs cheatcode* —— 除非
在 `foundry.toml` 里显式授权路径,否则 Foundry
不允许脚本触碰文件系统:

```toml
fs_permissions = [
    { access = "read", path = "./img/" },
    { access = "read", path = "./broadcast" },
]
```

对 `./img/` 的读权限是为了 SVG;`./broadcast` 是为了让铸造脚本
里的 DevOpsTools 助手能找到最近的部署日志。没有授权,部署会在
第一次读取时就失败 —— 这又是一种近乎静默的失败,因为报错指向
cheatcode,而不是你的代码。

### 坑二:DevOpsTools 需要 ffi = true

交互脚本会导入 `foundry-devops` 里的 DevOpsTools 来定位上一次
部署,而不是硬编码一个地址。这个导入需要启用 Foundry 的 `ffi`
cheatcode,所以配置里带着它,并附了一句说明注释:

```toml
ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol
```

`ffi` 是货真价实的权限授予 —— 它让脚本可以运行任意 shell 命令
—— 所以那句注释是应得的;在开启它之前,值得先弄清楚究竟是哪个
导入需要它。

## 如果重来,我会怎么做

两个习惯能更早抓住这个问题,也能抓住下一条换行符回归:

1. **在测试里断言编码后的字节。** 集成测试已经会跑真正的部署
   脚本,框架是现成的。加一个单元测试,断言
   `vm.readFile("img/smile.svg")` —— 或者最终的图像 URI —— 等于
   预期的 LF 编码 base64 字符串,那么 CRLF 回归就会让 `forge test`
   大声失败,而不是悄悄把另一幅作品送上链。
2. **凡是脚本按字节读取的资源目录,都钉上 `eol=lf`。** 这个坑
   不只属于 SVG。如果脚本要内嵌 JSON 元数据或任何其他文本资源,
   同样的事情照样发生。经验法则:任何用 fs cheatcode 读的东西,
   在脚本提交之前,先给它配一条 `.gitattributes` 规则。

## 结果

修复之后,base64 载荷在 Windows 检出和 Linux 检出上完全一致 ——
同一串字符串,在两台机器上各自算一遍,逐字节相等 —— 所以部署
出来的合约作品是可复现的,而不是因机器而异的。这正是链上作品的
全部意义所在;而它差一点就被一个文件、一条规则、一次 `printf`
就能证明的问题悄悄毁掉。

坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。
但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。

我平时做全栈网站开发 —— 前端、后端、自托管部署 —— 而这一类
字节级、跨平台的排障,正是真正上线软件时会遇到的事。如果你的
项目需要一个不只盯 diff、还盯着字节的开发者,跟我说说:
[WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com)。

🇬🇧 英文版原文 (English Markdown Primary)

---
title: "Why My On-Chain NFT Art Changed When I Cloned It on Windows"
description: "Why my on-chain NFT art changed on Windows: core.autocrlf injects CRLF into the SVG files vm.readFile encodes, the base64 differs, and eol=lf fixes it."
pubDate: 2026-08-19
category: web3
tags: [foundry, solidity, svg, base64, git, windows, crlf]
ogImage: /og/why-my-on-chain-nft-art-changed-on-windows.jpg
banner: /banners/why-my-on-chain-nft-art-changed-on-windows.jpg
draft: false
---

A fully on-chain NFT's artwork is supposed to be permanent. The image is
not a URL someone can unpin — it is a base64 string stored in the
contract's storage at deploy time, and after that it cannot change. So
the question writes itself: how can artwork like that change at all, let
alone silently?

In my case the answer was: the art was never a fixed string. It was
whatever bytes my deploy script happened to read from disk on the machine
that ran the deploy — and on Windows, Git quietly rewrites those bytes
before the script ever sees them.

I hit this in a small Foundry learning project built around a "mood"
NFT: an ERC-721 whose artwork flips between a smiling SVG and a sad SVG,
both encoded in the contract itself. The image URIs are built at deploy
time from source files in `img/`, so the byte-for-byte content of those
files is the artwork. Here is how one bad line ending nearly made that
art machine-dependent.

## What I hit: the deploy encodes whatever bytes it reads

The deploy script does the whole job in a few lines:

```solidity
string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);
```

`vm.readFile` returns a string, `Base64.encode` turns those exact bytes
into a `data:image/svg+xml;base64,...` URI, and the constructor stores
both URIs forever. "On-chain" here is literal: whatever bytes the file
had on the deploying machine are now the contract's data — permanently.
A one-byte difference in the SVG at deploy time is a different artwork,
baked in for the life of the contract.

The difference I was worried about comes from Git's `core.autocrlf`.
A Windows Git install commonly rewrites text files with CRLF line
endings in the working tree, even when the repository stores LF. SVG
files are text. CRLF and LF are different bytes, and base64 encodes
different bytes differently. Two lines prove it:

```bash
printf 'a\nb' | base64    # YQpi
printf 'a\r\nb' | base64  # YQ0KYg==
```

One carriage return changes the encoded payload. Now the nasty part of
this failure class: nothing announces it. The SVG looks identical in
every editor. `git status` stays clean, because Git compares text after
normalising line endings. Foundry does not care either — it is not
parsing the SVG, just encoding bytes — so no error, no warning, on any
machine. The art baked into the contract silently depends on which
machine ran the deploy.

## The fix: one rule in .gitattributes

The fix is a single file with a single rule, and the comment matters:

```gitattributes
# Force LF line endings for asset files read by forge scripts (vm.readFile)
# so the working tree always matches what's stored in git, regardless of
# core.autocrlf / Windows checkout behavior.
img/*.svg text eol=lf
```

Why this works: `text` tells Git to treat those files as text and
normalise them, so in the repository they are always stored with LF.
`eol=lf` then pins the working-tree checkout of those paths to LF,
overriding whatever `core.autocrlf` says on any machine. The two
attributes together mean that on a Windows box with
`core.autocrlf=true`, `img/*.svg` are still checked out with LF — so
`vm.readFile` always returns the same bytes the author committed, and
the base64 URI is deterministic across platforms.

One precision: the rule governs checkout of those paths, and
normalisation when files are added. It did not rewrite the source SVGs
— they were already committed with LF, and the rule does not touch blob
content. What it prevents is the divergence on every checkout after it
lands.

The same commit fixed a second thing that was silently wrong: a typo in
the `remappings` entry in `foundry.toml`. The mapping is what makes
`@openzeppelin/contracts/...` imports resolve to the submodule, so a
typo there breaks the build with an error that has nothing to do with
the code I wrote:

```toml
remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]
```

## Two traps sitting right next to this one

### Trap 1: vm.readFile refuses to run without fs_permissions

`vm.readFile` is an *fs cheatcode* — Foundry will not let a script touch
the filesystem unless the path is explicitly granted in `foundry.toml`:

```toml
fs_permissions = [
    { access = "read", path = "./img/" },
    { access = "read", path = "./broadcast" },
]
```

Read access to `./img/` is for the SVGs; `./broadcast` is there so the
mint script's DevOpsTools helper can find the latest deployment log.
Without the grant the deploy fails at the first read — another
near-silent failure, because the error points at the cheatcode, not at
your code.

### Trap 2: DevOpsTools needs ffi = true

The interaction scripts import DevOpsTools from `foundry-devops` to
locate the last deployment instead of hardcoding an address. That import
needs Foundry's `ffi` cheatcode enabled, so the config carries it with
an explanatory comment:

```toml
ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol
```

`ffi` is a real privilege grant — it lets scripts run arbitrary shell
commands — so it deserves that comment, and it is worth knowing exactly
which import requires it before you enable it.

## What I'd do differently

Two habits would have caught this earlier and would catch the next
line-ending regression:

1. **Assert on the encoded bytes in a test.** The integration tests
   already run the real deploy script, so the harness exists. A unit
   test that asserts `vm.readFile("img/smile.svg")` — or the final image
   URI — equals the expected LF-encoded base64 string would make a CRLF
   regression fail `forge test` loudly, instead of quietly shipping
   different art to a chain.
2. **Pin `eol=lf` for every asset directory a script reads as bytes.**
   The trap is not specific to SVG. If a script embeds JSON metadata or
   any other text asset, the same thing happens. As a rule of thumb:
   anything read with an fs cheatcode gets a `.gitattributes` rule
   before the script is committed.

## The result

After the fix, the base64 payload is identical on a Windows checkout and
a Linux checkout — one string, computed on two machines, byte for byte
equal — so the deployed contract's art is reproducible rather than
machine-dependent. That is the whole point of on-chain art, and it
turned out to be one file, one rule, and one `printf` away from being
quietly broken.

Honest scope: this is a learning project — a small, heavily commented
contract and deploy script run against a local Anvil node and the
Sepolia testnet, not production code. But "works on my machine" is a
bug report, and the fix here is the same discipline a production
deployment needs: know exactly what bytes your tooling is shipping.

I do full-stack web development for a living — front-end, back-end,
self-hosted deployment — and this class of byte-level, cross-platform
debugging is exactly what shipping software involves. If your project
needs a developer who checks the bytes, not just the diff, tell me about
it: [WhatsApp](https://wa.me/60127972969) ·
[[email protected]](mailto:[email protected]?subject=Full-stack%20web%20development)
· [hoelee.com](https://hoelee.com).