3707 lines
186 KiB
Markdown
3707 lines
186 KiB
Markdown
# 博客文章顾问审阅资料包 — Batch-04
|
||
|
||
> **批次编号**:`Batch-04`
|
||
> **包含文章数**:5 篇
|
||
> **本批次总字符量**:159,503 字(严格限制 < 200,000 字)
|
||
> **模式**:BILINGUAL
|
||
> **生成时间**:2026-10-27 05:36:05
|
||
|
||
# 外部顾问审阅前置须知与工程背景规范
|
||
|
||
> **本文档目标**:本批次包含 `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:给没有时间戳的歌曲自动生成字幕
|
||
- **Slug**: `generating-subtitles-for-songs-without-timestamps`
|
||
- **英文标题**: Generating Subtitles for Songs That Ship Without Timestamps
|
||
- **分类**: `tutorials` | **发布日期**: 2026-10-20
|
||
- **线上地址**: https://blog.hoelee.com/posts/generating-subtitles-for-songs-without-timestamps/ (EN) | https://blog.hoelee.com/posts/zh/generating-subtitles-for-songs-without-timestamps/ (ZH)
|
||
- **本篇审核状态**: ⏳ 待审核 (Pending)
|
||
|
||
### ✍️ 顾问审阅意见区 (Review Feedback)
|
||
```text
|
||
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
|
||
【事实与数字核对】:
|
||
【人设与用词卫生】:
|
||
【CRO与转化建议】:
|
||
```
|
||
|
||
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
|
||
````markdown
|
||
---
|
||
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)
|
||
````markdown
|
||
---
|
||
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:购买主机前,如何核实一家服务商的真实情况
|
||
- **Slug**: `how-to-verify-a-hosting-provider-before-you-buy`
|
||
- **英文标题**: How to Verify a Hosting Provider Before You Buy
|
||
- **分类**: `tutorials` | **发布日期**: 2025-11-26
|
||
- **线上地址**: https://blog.hoelee.com/posts/how-to-verify-a-hosting-provider-before-you-buy/ (EN) | https://blog.hoelee.com/posts/zh/how-to-verify-a-hosting-provider-before-you-buy/ (ZH)
|
||
- **本篇审核状态**: ⏳ 待审核 (Pending)
|
||
|
||
### ✍️ 顾问审阅意见区 (Review Feedback)
|
||
```text
|
||
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
|
||
【事实与数字核对】:
|
||
【人设与用词卫生】:
|
||
【CRO与转化建议】:
|
||
```
|
||
|
||
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
|
||
````markdown
|
||
---
|
||
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)
|
||
````markdown
|
||
---
|
||
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.
|
||
````
|
||
|
||
---
|
||
|
||
## 文章 03 / 05:一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约
|
||
- **Slug**: `chainlink-vrf-v2-lottery-contract`
|
||
- **英文标题**: An Automated Lottery Contract on Chainlink VRF v2.5 and Automation
|
||
- **分类**: `web3` | **发布日期**: 2024-12-10
|
||
- **线上地址**: https://blog.hoelee.com/posts/chainlink-vrf-v2-lottery-contract/ (EN) | https://blog.hoelee.com/posts/zh/chainlink-vrf-v2-lottery-contract/ (ZH)
|
||
- **本篇审核状态**: ⏳ 待审核 (Pending)
|
||
|
||
### ✍️ 顾问审阅意见区 (Review Feedback)
|
||
```text
|
||
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
|
||
【事实与数字核对】:
|
||
【人设与用词卫生】:
|
||
【CRO与转化建议】:
|
||
```
|
||
|
||
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
|
||
````markdown
|
||
---
|
||
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)
|
||
````markdown
|
||
---
|
||
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:把艺术作品放进合约里
|
||
- **Slug**: `fully-on-chain-svg-nfts`
|
||
- **英文标题**: Fully On-Chain SVG NFTs: Putting the Artwork Inside the Contract
|
||
- **分类**: `web3` | **发布日期**: 2024-10-08
|
||
- **线上地址**: https://blog.hoelee.com/posts/fully-on-chain-svg-nfts/ (EN) | https://blog.hoelee.com/posts/zh/fully-on-chain-svg-nfts/ (ZH)
|
||
- **本篇审核状态**: ⏳ 待审核 (Pending)
|
||
|
||
### ✍️ 顾问审阅意见区 (Review Feedback)
|
||
```text
|
||
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
|
||
【事实与数字核对】:
|
||
【人设与用词卫生】:
|
||
【CRO与转化建议】:
|
||
```
|
||
|
||
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
|
||
````markdown
|
||
---
|
||
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)
|
||
````markdown
|
||
---
|
||
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 图像变了
|
||
- **Slug**: `why-my-on-chain-nft-art-changed-on-windows`
|
||
- **英文标题**: Why My On-Chain NFT Art Changed When I Cloned It on Windows
|
||
- **分类**: `web3` | **发布日期**: 2026-08-19
|
||
- **线上地址**: https://blog.hoelee.com/posts/why-my-on-chain-nft-art-changed-on-windows/ (EN) | https://blog.hoelee.com/posts/zh/why-my-on-chain-nft-art-changed-on-windows/ (ZH)
|
||
- **本篇审核状态**: ⏳ 待审核 (Pending)
|
||
|
||
### ✍️ 顾问审阅意见区 (Review Feedback)
|
||
```text
|
||
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
|
||
【事实与数字核对】:
|
||
【人设与用词卫生】:
|
||
【CRO与转化建议】:
|
||
```
|
||
|
||
### 🇨🇳 中文版原文 (Chinese Markdown Twin)
|
||
````markdown
|
||
---
|
||
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)
|
||
````markdown
|
||
---
|
||
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).
|
||
````
|
||
|
||
---
|
||
|