docs(review): apply batch-04 consultant fixes and approve 5 tutorial/web3 posts
Deploy / build (push) Successful in 22s

- performUpkeep: external callable, re-check is mandatory security (not stale-data hygiene)
- BasicNft.tokenURI: ownerOf -> _ownerOf, dead custom error explained
- storage (SSTORE) vs EIP-170 runtime bytecode separated, derived ~1.3M gas for the two SVGs
- subs-15 retimed from a fresh faster-whisper word alignment (10.84s first line, was 28.22s) + SRT regenerated
- RDAP needs curl -L (bootstrap 302), ASN/Spamhaus blocklist check added
- learning-project framing -> Sepolia reference implementation / testnet validation build
- dynamicCta on the two posts that ended with no conversion block
- cross-links between the two NFT posts; tracker 91/91 approved
This commit is contained in:
hoelee committed 2026-10-27 06:13:00 +08:00
1 parent 01c55805fb
commit cd6511a0b3
14 files changed
+322 -166

No files matched your search

+7 -7
View File
@@ -1,7 +1,7 @@
# 全站文章顾问审阅状态总控台 (Audit Tracker)
> **最后同步时间**:2026-10-27 06:10:48
> **全站总文章数**:91 篇 | **已审批完成**:86 篇 (94.5%) | **待审阅**:5 篇
> **最后同步时间**:2026-10-27 06:11:29
> **全站总文章数**:91 篇 | **已审批完成**:91 篇 (100.0%) | **待审阅**:0 篇
## 一、操作命令速查
- `python scripts/manage_review.py list` — 查看待审与已审概览
@@ -98,8 +98,8 @@
| 84 | `tutorials` | `calibrating-intel-quick-sync-hevc-quality` | 校准 Intel Quick Sync HEVC:28 与 30 之间的悬崖,和一个毫无作用的 preset | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 85 | `tutorials` | `does-hevc-actually-shrink-your-files` | 转成 HEVC 到底会不会让文件变小?我实测了 | ✅ 已通过 | Batch-04 | 2026-10-27 05:21 | 外部顾问审阅完成,已采纳并落地全量修改意见(含数字矛盾/OPSEC脱敏/双语CTA/排版折叠/禁用词清理) |
| 86 | `tutorials` | `generating-full-length-songs-with-yue2-comfyui` | 用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断 | ✅ 已通过 | Batch-03 | 2026-10-27 06:04 | 第3批微调项落地并通过:时长校正283.1s/单调时间戳与防死锁/专业人设去二次元俚语/B2B转化增强 |
| 87 | `tutorials` | `generating-subtitles-for-songs-without-timestamps` | 给没有时间戳的歌曲自动生成字幕 | ⏳ 待审阅 | Batch-01 | - | |
| 88 | `tutorials` | `how-to-verify-a-hosting-provider-before-you-buy` | 购买主机前,如何核实一家服务商的真实情况 | ⏳ 待审阅 | Batch-01 | - | |
| 89 | `web3` | `chainlink-vrf-v2-lottery-contract` | 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 | ⏳ 待审阅 | Batch-01 | - | |
| 90 | `web3` | `fully-on-chain-svg-nfts` | 全链上 SVG NFT:把艺术作品放进合约里 | ⏳ 待审阅 | Batch-01 | - | |
| 91 | `web3` | `why-my-on-chain-nft-art-changed-on-windows` | 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 | ⏳ 待审阅 | Batch-01 | - | |
| 87 | `tutorials` | `generating-subtitles-for-songs-without-timestamps` | 给没有时间戳的歌曲自动生成字幕 | ✅ 已通过 | Batch-01 | 2026-10-27 06:11 | Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta |
| 88 | `tutorials` | `how-to-verify-a-hosting-provider-before-you-buy` | 购买主机前,如何核实一家服务商的真实情况 | ✅ 已通过 | Batch-01 | 2026-10-27 06:11 | Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta |
| 89 | `web3` | `chainlink-vrf-v2-lottery-contract` | 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 | ✅ 已通过 | Batch-01 | 2026-10-27 06:11 | Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta |
| 90 | `web3` | `fully-on-chain-svg-nfts` | 全链上 SVG NFT:把艺术作品放进合约里 | ✅ 已通过 | Batch-01 | 2026-10-27 06:11 | Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta |
| 91 | `web3` | `why-my-on-chain-nft-art-changed-on-windows` | 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 | ✅ 已通过 | Batch-01 | 2026-10-27 06:11 | Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta |
+15 -15
View File
@@ -122,10 +122,10 @@
"zh_title": "一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约",
"category": "web3",
"pub_date": "2024-12-10",
"status": "pending",
"status": "approved",
"batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"approved_at": "2026-10-27 06:11",
"notes": "Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta",
"en_chars": 16252,
"zh_chars": 9707
},
@@ -226,10 +226,10 @@
"zh_title": "全链上 SVG NFT:把艺术作品放进合约里",
"category": "web3",
"pub_date": "2024-10-08",
"status": "pending",
"status": "approved",
"batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"approved_at": "2026-10-27 06:11",
"notes": "Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta",
"en_chars": 16205,
"zh_chars": 10281
},
@@ -252,10 +252,10 @@
"zh_title": "给没有时间戳的歌曲自动生成字幕",
"category": "tutorials",
"pub_date": "2026-10-20",
"status": "pending",
"status": "approved",
"batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"approved_at": "2026-10-27 06:11",
"notes": "Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta",
"en_chars": 47576,
"zh_chars": 42677
},
@@ -369,10 +369,10 @@
"zh_title": "购买主机前,如何核实一家服务商的真实情况",
"category": "tutorials",
"pub_date": "2025-11-26",
"status": "pending",
"status": "approved",
"batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"approved_at": "2026-10-27 06:11",
"notes": "Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta",
"en_chars": 7710,
"zh_chars": 3405
},
@@ -1084,10 +1084,10 @@
"zh_title": "为什么在 Windows 上克隆后,我的链上 NFT 图像变了",
"category": "web3",
"pub_date": "2026-08-19",
"status": "pending",
"status": "approved",
"batch_id": "Batch-01",
"approved_at": null,
"notes": "",
"approved_at": "2026-10-27 06:11",
"notes": "Batch-04 review: performUpkeep external-callable correction, _ownerOf dead-code fix, storage vs EIP-170 split, RDAP -L + ASN blocklist, subs-15 retimed from re-run ASR, cross-links, dynamicCta",
"en_chars": 8423,
"zh_chars": 4585
},
@@ -0,0 +1,38 @@
# `blog.hoelee.com` 外部顾问审阅报告 — Batch-04 复审(5 篇 · tutorials + web3)
> **批次**:`Batch-04`(tracker 内部编号) 5 篇:2 篇 `tutorials` + 3 篇 `web3`,双语共 10 个文件
> **顾问结论分布**:需微调 3 篇 · 局部重构 1 篇 · 通过(附微调)1 篇
> **落地状态**:5 篇全部修改完成,`audit-tracker.json` 已标记 `approved`(全库 91/91)
> **验证**:`npm run build` 通过(Pagefind 索引 319 页 / 35,342 词,0 build error)
---
## 逐篇落地执行
| # | Slug | 顾问结论 | 已落地修改 |
| --- | --- | --- | --- |
| 01 | `generating-subtitles-for-songs-without-timestamps` | 需微调 | 1. **Track 15 (`subs-15`) 时间戳重跑修正**:用 `faster-whisper small int8` 对该曲目音频重新做词级对齐,第一行真实起点 **10.84 s**(原稿 28.22 s),整段主歌原本被压成 6×1.20 s 硬下限;LRC 与 `/subs/green-train-to-the-sea.srt` 一并重生成;<br>2. 正文新增 **Failure C** 记录这次夹逼伪影(含重跑前后数字),把 bug 写进规则叙事;<br>3. 覆盖率表头 `Lines fully anchored` → `Songs fully anchored`(值本就是 9/18、11/11 首);<br>4. 明确中文 0.70 / 11 首来自**另一批 11 首中文验证集**,不是本页播放器;<br>5. 新增物理锚点:18 首全部在 **Core i9-13900K**(Windows 虚拟机)CPU int8 对齐,并解释 GPU 留给生成管线的取舍;删除与 `blueprintLayer: L3-local-gpu-inference` 冲突的 `GPU used: none` 行(L3 层定义本身就包含 faster-whisper 对齐,故保留层级、改用文字消解冲突);<br>6. 修正 `I climbs` → `I climb`、`a ancient` → `an ancient`;<br>7. frontmatter 加 `dynamicCta: true` 补齐转化闭环。 |
| 02 | `how-to-verify-a-hosting-provider-before-you-buy` | 需微调 | 1. `curl -s` → `curl -sL`(`rdap.org` 是 bootstrap,302 到注册局),`python` → `python3`,并补一段说明不加 `-L` 会 `Expecting value: line 1 column 1`;<br>2. 第 3 节补 **ASN 归属 + IP 段黑名单(`bgp.he.net` / Spamhaus)** 核查要点,兑现导语承诺;<br>3. 按业主口述改为**不精确量级**表述("一轮下来名单上大多数都被筛掉")并声明未留打分表,不引用顾问虚构的 12→9;<br>4. frontmatter 加 `dynamicCta: true`。 |
| 03 | `chainlink-vrf-v2-lottery-contract` | 需微调 | 1. **技术硬伤修正**:`performUpkeep` 是接口规定的无权限 `external` 函数,链上任何人都能调用,内部重跑 `checkUpkeep` 是防任意外部调用者强行开奖的强制安全边界(原文"只能被网络调用"错误);<br>2. 代码片段 `msgValue` → `msg.value`;<br>3. 人设升级:`learning project` → **Sepolia 参考实现与验证沙盒**(首尾两处),并指向文末四项主网加固。 |
| 04 | `fully-on-chain-svg-nfts` | 局部重构 | 1. **合约死代码修正**:`BasicNft.tokenURI` 的 `ownerOf(tokenId) == address(0)` → `_ownerOf(tokenId)`,并点破 OpenZeppelin 公有 `ownerOf` 会先 revert、自定义错误永远不可达的陷阱(仓库 `D:/dev/foundry-nft/src/BasicNft.sol` 实为原写法,属真实教训);<br>2. **EVM 概念拆清**:构造函数参数写入走 `SSTORE` 进 **Storage**(542 + 1,469 字节 → 17+46 数据槽 ≈ **130 万 gas**),受区块 gas / EIP-3860 `initcode` 49,152 字节约束;**EIP-170 24,576 字节**只约束运行时字节码与 `constant`/`immutable` 内联;<br>3. 量化结果补一段**坦白缺口**:仓库只有 `BasicNft` 部署 trace,`MoodNft` 未保留日志,只给存储算术不给虚构总量;<br>4. 人设去自谦(`learning repo` / `刚学 Solidity 的新手`);<br>5. 与第 05 篇建立内链。 |
| 05 | `why-my-on-chain-nft-art-changed-on-windows` | 通过(附微调) | 1. 点明"变的是链上字节与 `keccak256` 断言,不是浏览器画面"(原文已提编辑器看不出,未提哈希断言);<br>2. 补 `git add --renormalize .` / 重新检出 `img/*.svg` 的存量工作区刷新说明;<br>3. 首段与第 04 篇建立内链;<br>4. `learning project` → 测试网验证工程。 |
---
## 顾问意见的采纳判定(三类)
- **直接采纳并修复**:`performUpkeep` 权限描述、`msg.value`、`ownerOf` 死代码、Storage vs EIP-170、`curl -L`、表头错标、`subs-15` 夹逼、`--renormalize`、双向内链 —— 全部对源码/实测核验为真。
- **修正后采纳(顾问建议本身有误)**:
- 文章 02 的"12 家筛掉 9 家"是顾问编的数字 → 改为不精确量级表述 + 声明无打分表。
- 文章 01 的"8 核 AMD EPYC / Xeon"是顾问编的硬件 → 改用业主真实机器 **i9-13900K**。
- 文章 01 的 `blueprintLayer` 冲突:不降级层级(L3 定义含 faster-whisper 对齐),改为删掉 `GPU used: none` 行 + 正文解释 CPU/GPU 分工。
- 文章 04 "缺 MoodNft gas 表":原文已声明日志只有 `BasicNft`;本次改为"存储算术 + 明确写缺口",不给未实测的总量。
- 顾问补丁里的内链是 `https://www.google.com/search?q=/posts/...` 伪链接 → 全部改为真实站内路径(`/posts/...`、`/posts/zh/...`)。
- **不做**:不做无实测基础的数字包装(MoodNft 总 gas、采购淘汰率)。
---
## 待办 / 后续
- 全库 91 篇已 `approved`,`batches/` 已清空(`manage_review.py generate` 报告 "All posts are approved")。
- 后续若再送审,需要新的内容波次而不是旧批次重发。
+17 -17
View File
@@ -1,67 +1,67 @@
1
00:00:28,220 --> 00:00:29,980
00:00:10,840 --> 00:00:14,280
Clickety-clack upon the track, the green train starts to roll
2
00:00:29,420 --> 00:00:30,620
00:00:14,280 --> 00:00:19,420
Leaving all the city gray behind the tunnel hole
3
00:00:30,620 --> 00:00:31,820
00:00:19,420 --> 00:00:24,200
Forehead pressed against the glass, I watch the fields go by
4
00:00:31,820 --> 00:00:33,020
00:00:24,200 --> 00:00:28,380
Golden wheat and windmills waving underneath the sky
5
00:00:33,020 --> 00:00:34,220
00:00:28,380 --> 00:00:33,780
Take me where the spring wind blows across the open blue
6
00:00:34,220 --> 00:00:38,359
00:00:33,780 --> 00:00:38,820
Ten miles of wildflowers blooming just for me and you
7
00:00:38,359 --> 00:00:42,679
00:00:38,820 --> 00:00:43,460
Every station has a name I've never heard before
8
00:00:42,679 --> 00:00:47,600
00:00:43,460 --> 00:00:57,420
Soon the scent of salty air will meet us at the shore
9
00:00:57,620 --> 00:01:01,479
00:00:57,420 --> 00:01:02,460
Strangers sharing oranges and stories in the seat
10
00:01:01,920 --> 00:01:06,680
00:01:02,460 --> 00:01:07,480
Sunlight dancing in the aisle around my dangling feet
11
00:01:06,680 --> 00:01:11,500
00:01:07,480 --> 00:01:11,500
Postcards in my backpack waiting for a stamp and pen
12
00:01:11,500 --> 00:01:16,239
00:01:11,500 --> 00:01:16,220
To tell the people back at home I'm coming back again
13
00:01:16,239 --> 00:01:21,140
00:01:16,220 --> 00:01:21,520
Take me where the spring wind blows across the open blue
14
00:01:21,140 --> 00:01:26,340
00:01:21,520 --> 00:01:26,800
Ten miles of wildflowers blooming just for me and you
15
00:01:26,340 --> 00:01:30,659
00:01:26,800 --> 00:01:31,400
Every station has a name I've never heard before
16
00:01:30,659 --> 00:01:35,599
00:01:31,400 --> 00:01:46,200
Soon the scent of salty air will meet us at the shore
17
00:01:46,820 --> 00:01:48,359
00:01:46,200 --> 00:01:48,840
Look, there is the sea...
@@ -20,9 +20,10 @@ 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.
and where I would do things differently. It is a Sepolia reference
implementation and verification sandbox, not audited mainnet code — I draw that
boundary up front, and the four hardening steps a real money-handling version
needs are listed at the end.
## Why this is one of the few genuinely good uses of an oracle
@@ -134,7 +135,7 @@ word, confirmed three blocks.
`Raffle__RaffleNotOpen`. Then push the sender and emit an event:
```solidity
if (msgValue < i_entranceFee) {
if (msg.value < i_entranceFee) {
revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
@@ -155,10 +156,14 @@ 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:
**Drawing.** `performUpkeep` runs on Chainlink Automation nodes in normal
operation, but the interface requires it to be an unrestricted `external`
function — any EOA or contract on-chain can call it directly, and a valid
keeper transaction can also land against state that has moved since the
off-chain simulation. Re-checking `checkUpkeep` inside `performUpkeep` is
therefore mandatory security, not a formality: it is what stops an arbitrary
caller from forcing a draw while the conditions are not met. When the check
fails it reverts with a custom error that embeds the evidence:
```solidity
revert Raffle__UpkeepNotNeeded(
@@ -296,12 +301,12 @@ 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.
This is a Sepolia reference implementation, and I want the limits on the
record: no audit, no economic attack modelling, no mainnet money. It exists to
prove a state machine and a keeper contract end to end — not to take deposits.
Operationally, a real lottery has an ongoing cost that a learning project
doesn't: the VRF subscription drains LINK with every request, and the
Operationally, a real lottery has an ongoing cost that a testnet reference
implementation 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.
+43 -23
View File
@@ -16,10 +16,10 @@ 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.
This project is a testnet reference implementation, not production work — the
README says exactly that — and everything here ran against Sepolia. 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?
@@ -59,7 +59,7 @@ contract BasicNft is ERC721 {
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
if (_ownerOf(tokenId) == address(0)) {
revert BasicNft__TokenUriNotFound();
}
return s_tokenIdToUri[tokenId];
@@ -68,11 +68,15 @@ contract BasicNft is ERC721 {
```
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")`.
hand it, per token, and `tokenURI` returns it. The existence check is the
interesting line: it calls OpenZeppelin's *internal* `_ownerOf(tokenId) ==
address(0)`. Writing `ownerOf(tokenId) == address(0)` instead — which is what I
did first — compiles fine and is dead code: the public `ownerOf` reverts inside
OpenZeppelin (`ERC721NonexistentToken` on v5, a `require` on v4) before it can
ever return `address(0)`, so the custom error can never be reached. The
`_ownerOf` variant is what actually fires, and a custom error is cheaper than a
string `require` and self-documenting.
I named the collection after myself: `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.
@@ -301,18 +305,26 @@ 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.
**On-chain art costs storage gas, paid once at deploy.** In `MoodNft` the two
`data:image/svg+xml;base64,` strings arrive as constructor arguments and are
written to contract **storage** with `SSTORE` — roughly 20,000 gas per 32-byte
slot filled from zero. The two encoded SVGs in this repo are 542 and 1,469
bytes, i.e. 17 + 46 data slots (plus one length slot each), so the art alone
costs on the order of **1.3 million gas** at deploy, before a single line of
contract logic runs. It is a one-time cost, but it scales linearly with payload
size.
**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.
**Storage and bytecode are two different ceilings.** Because the art goes in as
constructor arguments, what bounds it is the block gas limit and EIP-3860's
49,152-byte `initcode` cap — not the deployed code size. The 24,576-byte limit
people quote is **EIP-170**, and it applies to the runtime bytecode: the compiled
logic plus anything you inline as a `constant` or `immutable`. If I hardcoded the
same SVGs as `constant` strings to save the `SLOAD` on every read, those bytes
would move into the runtime bytecode and start competing with the logic for the
EIP-170 budget. Compact vector art like these smileys is comfortable either way
(`BasicNft`'s deploy returned `4102 bytes of code`); a photograph or a 3D model
is not, in storage or in bytecode, and cramming one in either place is a waste of
gas.
**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
@@ -325,10 +337,12 @@ fits on chain, go on-chain. Otherwise, don't pay for the bytes.
`.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.
is the bytes" — makes unforgiving. The full debugging story behind that trap,
including the two base64 strings that prove it, is in
[Why My On-Chain NFT Art Changed When I Cloned It on Windows](/posts/why-my-on-chain-nft-art-changed-on-windows/).
**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
for a solo testnet build; they are a trap the moment a second deploy
happens.
## The quantified result
@@ -349,6 +363,12 @@ 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.
`MoodNft` has no deploy trace in the repo — it went out through the same
`forge script` on the same testnet, but I did not keep that log — so the only
figure I can give for it honestly is the storage arithmetic from the previous
section (≈1.3M gas for the two encoded SVGs), not a measured total. I would
rather print that gap than a number I cannot point a file at.
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
@@ -9,6 +9,7 @@ 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"
dynamicCta: true
draft: false
---
Every AI music tool I have tried returns the same shape of output: an audio
@@ -83,10 +84,10 @@ 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 |
| Language | Unit | Mean anchor coverage | Songs fully anchored |
|---|---|---:|---|
| English | word | **0.96** | 9/18 songs |
| Mandarin | character | 0.70 | 11/11 songs |
| English | word | **0.96** | 9/18 |
| Mandarin | character | 0.70 | 11/11 |
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
@@ -111,6 +112,15 @@ a stray word in the opening shout.
entirely had its remaining lines compressed to 0.18 s apart. Twelve
characters cannot be sung in 0.18 s.
**Failure C — six lines pinned to the floor.** One track's entire first verse
landed with no usable anchor, so rule 3 below swept six consecutive lines onto
the minimum gap: exactly 1.20 s each, which is roughly ten words a second. The
output still looked plausible — monotonic, in range, inside the audio — and it
only fell apart when I listened to that track and the whole verse went past in
six seconds. Re-running alignment on that one file put its first line at 10.8 s
instead of 28.2 s: the anchors had not been missing, they had been matched to
the wrong part of the song.
So after alignment, run three rules:
1. **Estimate the song's own rate** from neighbouring anchored pairs
@@ -198,7 +208,10 @@ 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.
LRC and SRT, generated from lyrics plus audio alone. The numbers below are the
English page set; the Mandarin row in the coverage table above comes from a
separate 11-track validation batch I ran through the same alignment code, not
from these players.
| | |
|---|---:|
@@ -206,8 +219,13 @@ LRC and SRT, generated from lyrics plus audio alone.
| 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 |
| Processing time | 63–90 s per song, CPU-only `faster-whisper` |
Running it on CPU is deliberate, not a limitation I worked around: all 18 tracks
were aligned on a Core i9-13900K (in the Windows VM that runs this pipeline),
because the GPU stays reserved for the generation pass that produces these songs, and
63–90 s of `faster-whisper` int8 per track is cheap enough that queuing the
aligner behind a diffusion job would cost more than the CPU time saves.
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
@@ -248,7 +266,7 @@ or open them in VLC.
[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:22.02]I climb 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
@@ -568,7 +586,7 @@ or open them in VLC.
[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
[00:57.24]There is still an 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
@@ -684,23 +702,23 @@ or open them in VLC.
<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
[00:10.84]Clickety-clack upon the track, the green train starts to roll
[00:14.28]Leaving all the city gray behind the tunnel hole
[00:19.42]Forehead pressed against the glass, I watch the fields go by
[00:24.20]Golden wheat and windmills waving underneath the sky
[00:28.38]Take me where the spring wind blows across the open blue
[00:33.78]Ten miles of wildflowers blooming just for me and you
[00:38.82]Every station has a name I've never heard before
[00:43.46]Soon the scent of salty air will meet us at the shore
[00:57.42]Strangers sharing oranges and stories in the seat
[01:02.46]Sunlight dancing in the aisle around my dangling feet
[01:07.48]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>
[01:16.22]Take me where the spring wind blows across the open blue
[01:21.52]Ten miles of wildflowers blooming just for me and you
[01:26.80]Every station has a name I've never heard before
[01:31.40]Soon the scent of salty air will meet us at the shore
[01:46.20]Look, there is the sea...</pre>
</div>
</figure>
@@ -9,6 +9,7 @@ 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
dynamicCta: true
---
Every hosting provider's homepage is a list of promises: *"in business since 2005"*,
@@ -18,10 +19,12 @@ 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.
afford to trust the marketing, so I built a quick verification pass — and when
the run was over, most of the shortlist was gone. I did not keep a scored
spreadsheet, so I will describe what it caught rather than quote a precise
hit rate: 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.
@@ -36,9 +39,14 @@ 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
curl -sL "https://rdap.org/domain/example.tld" | python3 -m json.tool
```
`rdap.org` is a bootstrap service — it answers with a `302` to the registry that
actually holds the record (Verisign for `.com`, and so on), so the `-L` matters:
without it you get an empty body and `json.tool` fails with
`Expecting value: line 1 column 1`.
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.)
@@ -87,6 +95,13 @@ the provider can't delete the bad reviews:
- **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.
- **ASN ownership and IP blocklists (`bgp.he.net`, Spamhaus)** — take the test IP
from the provider's Looking Glass page and look up the Autonomous System it
announces through. A provider that advertises its own data centre but resolves
to a budget upstream's ASN is reselling capacity, not operating it — and the
upstream's abuse handling becomes your abuse handling. If the surrounding
`/24` is sitting on a Spamhaus list, your outbound mail and webhook reputation
is dead on arrival, no matter how clean your own configuration is.
The single most useful signal I've found: **suspension reports.** A provider that
responds to abuse complaints by suspending first and charging a "reinstatement
@@ -20,8 +20,8 @@ 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,
I hit this in the [fully on-chain "mood" NFT](/posts/fully-on-chain-svg-nfts/)
build: 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
@@ -56,7 +56,13 @@ printf 'a\nb' | base64 # YQpi
printf 'a\r\nb' | base64 # YQ0KYg==
```
One carriage return changes the encoded payload. Now the nasty part of
One carriage return changes the encoded payload. To be precise about what
actually changed: not the *rendered image* — the extra `
` sits between XML
tags, so any browser paints the same smiley. What changed is the byte string the
contract stores, which is a different artwork as far as the chain is concerned,
and any `keccak256` assertion over that string — the kind a deploy-safety test
writes — fails against a payload that renders identically. 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
@@ -88,7 +94,10 @@ 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.
lands. A fresh clone after the commit gets LF automatically; an existing
Windows working tree that already checked those files out as CRLF needs one
`git add --renormalize .` (or a re-checkout of `img/*.svg`) to refresh the
bytes on disk — the rule alone does not retroactively rewrite your tree.
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
@@ -161,9 +170,9 @@ 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
Honest scope: this is a testnet validation build — a small, heavily commented
contract and deploy script run against a local Anvil node and Sepolia, 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.
@@ -18,8 +18,9 @@ draft: false
预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定
「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约
本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。
这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来,
因为这篇文章的可信度就靠它。
这是一个部署在 Sepolia 测试网上的**参考实现与验证沙盒**,不是经过审计的
主网资金级代码——我把这条边界先划清楚,而一份真钱版本必须补齐的四项加固,
我列在文末。
## 为什么这是预言机少数真正有用的场景之一
@@ -121,7 +122,7 @@ contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {
`Raffle__RaffleNotOpen`。然后把发送者压栈,发出事件:
```solidity
if (msgValue < i_entranceFee) {
if (msg.value < i_entranceFee) {
revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
@@ -141,7 +142,11 @@ bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);
```
**开奖。** `performUpkeep` 只能被网络调用,但它仍然重新检查
**开奖。** 正常流程下 `performUpkeep` 由 Chainlink Automation 节点触发,但接口
要求它必须是一个无权限修饰符的 `external` 函数 —— 链上任何地址或合约都能直接调用
它,而且一笔有效的 keeper 交易也可能落在链下模拟之后状态已变的区块上。所以在
`performUpkeep` 内部重新执行 `checkUpkeep` 是必须的安全边界,不是形式主义:正是它
挡住了任意外部调用者在条件未满足时强行触发开奖。它仍然重新检查
`checkUpkeep`——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。
检查失败时它回滚一个自定错误,错误里自带证据:
@@ -276,10 +281,11 @@ yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/
## 重做时我会改什么
这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。
这是一个 Sepolia 参考实现,我想把边界写清楚:没有审计、没有经济攻击
建模、没有主网的钱。它存在的目的是端到端验证一套状态机与 keeper 合约,
不是收存款。
运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会
运营上,一个真彩票有个测试网参考实现没有的持续成本:VRF 订阅每次请求都会
消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值——
我会写一个小监控脚本,给订阅补仓、余额太低时报警。
+35 -18
View File
@@ -15,14 +15,14 @@ draft: false
集合里,这个字符串指向*别处*:一个 `ipfs://` CID,一个 HTTPS URL。
代币是永久的,它指向的东西,却是一个没有任何合约能控制的外部依赖。
这是一个学习项目,不是生产代码 —— README 里写得很明白 —— 而且这里的
一切都跑在 Sepolia 测试网上。我把它写下来,是因为全链上这个模式确实
有用,下面的数字都是真实的,代码也小到可以一口气读完。
这是一个测试网参考实现,不是生产代码 —— README 里写得很明白 —— 而且这里
一切都跑在 Sepolia 上。我把它写下来,是因为全链上这个模式确实有用,
下面的数字都是真实的,代码也小到可以一口气读完。
## 如何铸造一个艺术作品永不消失的 NFT?
如果你是个刚学 Solidity 的新手,拿这个问题去搜,大部分答案都会把你
引向 IPFS,再配一句关于 pinning 的 shrug。对小尺寸的艺术品,有更好的
如果你拿这个问题去搜,大部分答案都会把你引向 IPFS,再配一句关于
pinning 的 shrug。
答案:把艺术*放进合约里*,编码好,然后给钱包一个完整的 `data:` URI,
让元数据 JSON 和图片都在同一个字符串里。这样一来,艺术作品就是链上
的字节,和代币本身没有区别。
@@ -56,7 +56,7 @@ contract BasicNft is ERC721 {
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
if (_ownerOf(tokenId) == address(0)) {
revert BasicNft__TokenUriNotFound();
}
return s_tokenIdToUri[tokenId];
@@ -65,9 +65,14 @@ contract BasicNft is ERC721 {
```
它刻意做得非常小。`mintNft(tokenUri)` 把你给的字符串按代币存起来,
`tokenURI` 原样返回;如果代币从未被铸造,就触发一个自定义错误
(比带字符串的 `require` 更省 gas,也自带文档)。我用自己的名字命名
集合,因为重点是光明正大地学习:`ERC721("Hoelee", "HOE")`。
`tokenURI` 原样返回。这里真正有意思的是那行存在性检查:它调用的是
OpenZeppelin 的**内部**函数 `_ownerOf(tokenId) == address(0)`。如果写成
`ownerOf(tokenId) == address(0)`(我第一版就是这么写的),编译照样通过,
但那是一段死代码:公有函数 `ownerOf` 在 OpenZeppelin 内部就直接 revert 了
(v5 抛 `ERC721NonexistentToken`,v4 是 `require`),根本不可能返回
`address(0)`,所以你自定义的错误永远不可能被触达。用 `_ownerOf` 才会真正
生效;而自定义错误比带字符串的 `require` 更省 gas,也自带文档。集合用我
自己的名字命名:`ERC721("Hoelee", "HOE")`。
这个合约对 URI 指向什么没有任何意见 —— OpenZeppelin 的 `ERC721` 基类
也没有 —— 所以艺术住在 URI 所在的地方。
@@ -288,15 +293,21 @@ mintNftOnContract(mostRecentDeployed);
## 如果重来,我会怎么做
**链上艺术要付部署 gas,而且是永久的。** 两张 SVG 的每一个字节都在
部署时一次性付清,然后以构造函数字符串的形式永远住在合约存储里。
这是一次性成本,但它是真实的,并且随艺术尺寸增长。
**链上艺术付的是存储 gas,部署时一次性付清。** 在 `MoodNft` 里,两个
`data:image/svg+xml;base64,` 字符串作为构造函数参数传入,通过 `SSTORE` 写进
合约**存储**(Storage)—— 每个从零写满的 32 字节槽位大约 20,000 gas。仓库里
这两张编码后的 SVG 分别是 542 和 1,469 字节,也就是 17 + 46 个数据槽(每个
字符串再加一个长度槽),所以光艺术本身在部署时就要花掉 **130 万 gas** 量级,
合约逻辑一行都还没跑。这是一次性成本,但它随载荷体积线性增长。
**能放进去的艺术有上限。** EIP-170 把合约代码限制在 24,576 字节以内,
艺术字节要和逻辑共用这个预算。笑脸和哭脸这种矢量 SVG 是理想选择 ——
小、清晰、可缩放。照片或 3D 模型永远塞不进去,硬塞也只是浪费 gas。
我的 `BasicNft` 部署返回 `4102 bytes of code`;
`MoodNft` 的字节码还要再叠上两张 SVG。
**存储与字节码是两个不同的天花板。** 因为艺术是以构造函数参数的形式进去的,
限制它的是区块 gas 上限和 EIP-3860 的 49,152 字节 `initcode` 上限 —— 不是部署
后的代码体积。大家常引用的 24,576 字节是 **EIP-170**,管的是运行时字节码:
编译后的逻辑,加上你内联成 `constant` 或 `immutable` 的任何东西。如果我为了
省掉每次读取的 `SLOAD`,把同样的 SVG 硬编码成 `constant` 字符串,这些字节就
会搬进运行时字节码,开始和逻辑抢 EIP-170 的预算。笑脸这种紧凑的矢量艺术两个
方向都很宽裕(`BasicNft` 部署返回 `4102 bytes of code`);照片或 3D 模型两个
方向都塞不进去,硬塞只是烧 gas。
**不是每个项目都该这么做。** 如果艺术很大、集合需要可变元数据、或者
部署预算紧张,IPFS 或文件存储加一个*可设置的* base URI 才是务实的默认
@@ -307,7 +318,8 @@ mintNftOnContract(mostRecentDeployed);
**锁死 SVG 的行尾。** 仓库通过 `.gitattributes` 把
`img/*.svg` 固定为 LF,保证编码后的字节是确定的;在 Windows 上,
`core.autocrlf` 会悄悄注入 CRLF,改变编码后的艺术。这种「艺术就是
字节」的模式,对这种隐形 bug 毫不留情。
字节」的模式,对这种隐形 bug 毫不留情。这个跨平台字节陷阱的完整排障过程
见[《为什么在 Windows 上克隆后,我的链上 NFT 图像变了》](/posts/zh/why-my-on-chain-nft-art-changed-on-windows/)。
**从第一天起就用 DevOpsTools。** 对单机学习来说,注释里的硬编码地址
没关系;一旦有第二次部署,它们就是陷阱。
@@ -330,6 +342,11 @@ mintNftOnContract(mostRecentDeployed);
`0.000186204671471742 ETH`。也就是说,在测试网上把 NFT 放上去几乎不
花钱;IPFS 依赖的代价是隐形的,直到某个 pin 死掉的那一天。
`MoodNft` 在仓库里没有部署 trace —— 它是在同一个测试网上用同一个
`forge script` 部署的,但那份日志我没有留下 —— 所以我能诚实给出的只有
上一节那笔存储算术(两张编码后的 SVG 约 130 万 gas),而不是一个实测
总量。这个缺口我宁愿写出来,也不愿意印一个指不到具体文件的数字。
而 `MoodNft` 真正重要的结果根本不需要日志:在 MetaMask 里把部署好的
合约和 0 号代币作为 collectible 添加,艺术作品就直接从链上 base64 SVG
渲染出来 —— 没有 IPFS 网关、没有网络查找、没有任何需要维持在线的东西。
@@ -9,6 +9,7 @@ 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"
dynamicCta: true
draft: false
---
我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你**每一句是什么时候唱的**。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。
@@ -84,6 +85,8 @@ for i, ch in enumerate(hanzi_in_word):
**故障 B —— 四行挤在 0.6 秒里。** 有一首歌的尾奏被识别器整段漏掉,剩下的行被压到相隔 0.18 秒。12 个字不可能在 0.18 秒里唱完。
**故障 C —— 六行被死死压在硬下限上。** 有一首的整个第一段主歌完全没有可用锚点,于是第 3 条规则把连续六行扫到了最小间隔:每行正好 1.20 秒,大约相当于每秒十个词。输出看起来依然「合理」—— 单调递增、在音频时长范围内 —— 直到我去听那一首,整段主歌六秒就唱完了。只对这一个文件重跑一次对齐,第一行就落到了 10.8 秒而不是 28.2 秒:锚点不是缺失,而是被匹配到了歌里错误的位置。
所以对齐之后,跑三条规则:
1. **估计这首歌自己的速率** —— 用相邻两行都已锚定的样本(单位数 ÷ 秒数,取中位数)。每行的期望时长 = `单位数 ÷ 速率`,下限约 1.8 秒。同一批里英文是 0.96 到 2.33 词/秒,中文是 2.3 到 4.1 字/秒,**不能用全局常数**。
@@ -141,7 +144,7 @@ a.dispatchEvent(new Event('timeupdate'));
## 结果
18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。
18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。上面覆盖率表里的中文那一行,来自另一批单独跑的 11 首中文验证集,不是这一页的播放器。
| | |
|---|---:|
@@ -149,8 +152,12 @@ a.dispatchEvent(new Event('timeupdate'));
| 字幕行数 | 342 |
| 平均锚定覆盖率 | 0.96 |
| 全行锚定的歌 | 9 / 18(每行都有时间) |
| 处理耗时 | 每首 63–90 秒,纯 CPU |
| 占用显卡 | 无 |
| 处理耗时 | 每首 63–90 秒,纯 CPU 跑 `faster-whisper` |
跑在 CPU 上是刻意的,不是我绕不过去的限制:这 18 首全部在 Core i9-13900K 上
(跑这套流程的那台 Windows 虚拟机)完成对齐,因为 GPU 要留给生成这些歌曲的
生产管线,而 `faster-whisper` int8 每首 63–90 秒已经足够便宜 —— 把对齐任务排在
一个扩散生成任务后面等,省下来的那点 CPU 时间根本不值。
下面每个播放器都会滚动歌词、把当前句保持在中间,旁边还有 **.srt 下载** —— 想烧字进视频或在 VLC 里打开都行。
@@ -189,7 +196,7 @@ a.dispatchEvent(new Event('timeupdate'));
[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:22.02]I climb 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
@@ -509,7 +516,7 @@ a.dispatchEvent(new Event('timeupdate'));
[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
[00:57.24]There is still an 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
@@ -625,23 +632,23 @@ a.dispatchEvent(new Event('timeupdate'));
<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
[00:10.84]Clickety-clack upon the track, the green train starts to roll
[00:14.28]Leaving all the city gray behind the tunnel hole
[00:19.42]Forehead pressed against the glass, I watch the fields go by
[00:24.20]Golden wheat and windmills waving underneath the sky
[00:28.38]Take me where the spring wind blows across the open blue
[00:33.78]Ten miles of wildflowers blooming just for me and you
[00:38.82]Every station has a name I've never heard before
[00:43.46]Soon the scent of salty air will meet us at the shore
[00:57.42]Strangers sharing oranges and stories in the seat
[01:02.46]Sunlight dancing in the aisle around my dangling feet
[01:07.48]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>
[01:16.22]Take me where the spring wind blows across the open blue
[01:21.52]Ten miles of wildflowers blooming just for me and you
[01:26.80]Every station has a name I've never heard before
[01:31.40]Soon the scent of salty air will meet us at the shore
[01:46.20]Look, there is the sea...</pre>
</div>
</figure>
@@ -9,6 +9,7 @@ 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
dynamicCta: true
---
每家主机服务商的首页都是一串承诺:_「自 2005 年起运营」_、_「保证在线率」_、
@@ -16,7 +17,9 @@ _「注重隐私」_、_「不限流量」_。其中有些是真的,但出人
而且在你刷信用卡、把 DNS 指向他们服务器之前,没人会去核实。
最近我要为一个有具体要求的项目(隐私司法辖区、付款方式、流量上限)筛选一批
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程。它确实揪出了问题——
服务商。我承受不起只看营销话术,于是搭了一套快速核查流程 —— 一轮下来,名单上
大多数都被筛掉了。我没有留下打分表,所以下面只说它揪出了什么问题,不报具体
淘汰率。它确实揪出了问题——
有的服务商声称「运营多年」,但域名比我的手机还新;有的服务商自己条款里就静悄悄地
禁止了我想跑的东西;还有的上了第三方黑名单。
@@ -31,9 +34,13 @@ _「注重隐私」_、_「不限流量」_。其中有些是真的,但出人
最快的办法是查 WHOIS/RDAP。RDAP 是 WHOIS 的现代替代品,返回 JSON,更好解析:
```bash
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
curl -sL "https://rdap.org/domain/example.tld" | python3 -m json.tool
```
`rdap.org` 只是个引导服务(bootstrap server)—— 它会用 `302` 把请求转到真正持有
记录的注册局(`.com` 是 Verisign,依此类推),所以 `-L` 不能省:不加就是空响应,
`json.tool` 直接报 `Expecting value: line 1 column 1`。
在 `events` 数组里找 `registration` 事件——那是**最初**的注册日期,不是最近一次续费。
(续费日期和注册商变更会排在时间线的后面,你要找的是*第一次*注册那条。)
@@ -68,6 +75,12 @@ AUP 告诉你他们*会以什么理由封停你*。这是两份不同的清单
卷款跑路和大规模封停事件有长记忆,而且通常直白得很,会直接说哪家是「知名骗子」。
- **WHTop / HostAdvice**——评分本身很嘈杂,但「1.8/10 且反复出现同一种投诉」和
「1.8 分但只是零散的零星差评」是两码事。
- **ASN 归属与 IP 段黑名单(`bgp.he.net`、Spamhaus)**——从服务商的 Looking Glass
页面拿一个测试 IP,查它宣告的自治系统号(ASN)。一家自称自建机房、ASN 却指向
某家廉价上游的服务商,本质是在转售别人的容量,而不是在运营基础设施;上游的
滥用处理方式,也就成了你的滥用处理方式。如果这个 IP 所在的 `/24` 网段常年挂在
Spamhaus 名单上,那你的发信与 webhook 信誉从第一天起就已经报废 —— 你自己的配置
再干净也没用。
我找到的最有用的单一信号是:**封停报告。** 一家服务商遇到滥用投诉时的第一反应是
「先封停」、第二反应是「收『恢复费』」,这会在某条评论里白纸黑字写出来。那是
@@ -18,8 +18,8 @@ draft: false
部署脚本在**执行部署的那台机器**上恰好从磁盘读到的字节 —— 而在
Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。
这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」
NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
这事发生在[全链上「心情」NFT](/posts/zh/fully-on-chain-svg-nfts/)这个练习里:
一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换,
两者都编码在合约自身中。图像 URI 在部署时由 `img/` 目录里的源
文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条
坏掉的换行符,如何差点让这幅作品变成「因机器而异」。
@@ -51,8 +51,13 @@ printf 'a\nb' | base64 # YQpi
printf 'a\r\nb' | base64 # YQ0KYg==
```
一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于:
没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。
一个回车符,就改变了编码后的载荷。这里要说精确:真正变掉的**不是画面** ——
多出来的 `
` 落在 XML 标签之间,浏览器画出来的还是同一个笑脸;变掉的是合约
存储的那串字节,而在链看来那就是另一件作品,任何针对这串字符串的 `keccak256`
断言(部署安全测试会写的那类校验)都会在一个「看起来一模一样」的载荷上失败。
这类失败最讨厌的地方在于:没有任何东西会告诉你。SVG 在任何一个编辑器里看起来
都一模一样。
`git status` 依然干净,因为 Git 在比较文本时先做了换行归一化。
Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何
机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于
@@ -79,7 +84,10 @@ img/*.svg text eol=lf
说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的
归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的,
规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能
发生的分歧。
发生的分歧。规则提交之后新做的 clone 会自动是 LF;但如果你是在一个
已经把这些文件按 CRLF 检出过的 Windows 工作区里更新,需要手动执行一次
`git add --renormalize .`(或重新检出 `img/*.svg`)才能刷新磁盘上的字节
—— 光有规则,不会倒回去改你现有的工作区。
同一个 commit 还修了第二件静默出错的东西:`foundry.toml` 里
`remappings` 的一个拼写错误。这条映射决定了
@@ -146,8 +154,8 @@ ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTo
全部意义所在;而它差一点就被一个文件、一条规则、一次 `printf`
就能证明的问题悄悄毁掉。
坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。
坦白说清范围:这是一个测试网验证工程 —— 小而注释详尽的合约与部署
脚本,跑在本地 Anvil 节点和 Sepolia 上,不是生产代码。
但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。