diff --git a/docs/consultant-review/AUDIT-TRACKER.md b/docs/consultant-review/AUDIT-TRACKER.md
index 8eed326..ee10f27 100644
--- a/docs/consultant-review/AUDIT-TRACKER.md
+++ b/docs/consultant-review/AUDIT-TRACKER.md
@@ -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 |
diff --git a/docs/consultant-review/audit-tracker.json b/docs/consultant-review/audit-tracker.json
index 1cb4e87..7632a57 100644
--- a/docs/consultant-review/audit-tracker.json
+++ b/docs/consultant-review/audit-tracker.json
@@ -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
},
diff --git a/docs/consultant-review/reports/report-batch-04-rereview.md b/docs/consultant-review/reports/report-batch-04-rereview.md
new file mode 100644
index 0000000..818f973
--- /dev/null
+++ b/docs/consultant-review/reports/report-batch-04-rereview.md
@@ -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` 一并重生成;
2. 正文新增 **Failure C** 记录这次夹逼伪影(含重跑前后数字),把 bug 写进规则叙事;
3. 覆盖率表头 `Lines fully anchored` → `Songs fully anchored`(值本就是 9/18、11/11 首);
4. 明确中文 0.70 / 11 首来自**另一批 11 首中文验证集**,不是本页播放器;
5. 新增物理锚点:18 首全部在 **Core i9-13900K**(Windows 虚拟机)CPU int8 对齐,并解释 GPU 留给生成管线的取舍;删除与 `blueprintLayer: L3-local-gpu-inference` 冲突的 `GPU used: none` 行(L3 层定义本身就包含 faster-whisper 对齐,故保留层级、改用文字消解冲突);
6. 修正 `I climbs` → `I climb`、`a ancient` → `an ancient`;
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`;
2. 第 3 节补 **ASN 归属 + IP 段黑名单(`bgp.he.net` / Spamhaus)** 核查要点,兑现导语承诺;
3. 按业主口述改为**不精确量级**表述("一轮下来名单上大多数都被筛掉")并声明未留打分表,不引用顾问虚构的 12→9;
4. frontmatter 加 `dynamicCta: true`。 |
+| 03 | `chainlink-vrf-v2-lottery-contract` | 需微调 | 1. **技术硬伤修正**:`performUpkeep` 是接口规定的无权限 `external` 函数,链上任何人都能调用,内部重跑 `checkUpkeep` 是防任意外部调用者强行开奖的强制安全边界(原文"只能被网络调用"错误);
2. 代码片段 `msgValue` → `msg.value`;
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` 实为原写法,属真实教训);
2. **EVM 概念拆清**:构造函数参数写入走 `SSTORE` 进 **Storage**(542 + 1,469 字节 → 17+46 数据槽 ≈ **130 万 gas**),受区块 gas / EIP-3860 `initcode` 49,152 字节约束;**EIP-170 24,576 字节**只约束运行时字节码与 `constant`/`immutable` 内联;
3. 量化结果补一段**坦白缺口**:仓库只有 `BasicNft` 部署 trace,`MoodNft` 未保留日志,只给存储算术不给虚构总量;
4. 人设去自谦(`learning repo` / `刚学 Solidity 的新手`);
5. 与第 05 篇建立内链。 |
+| 05 | `why-my-on-chain-nft-art-changed-on-windows` | 通过(附微调) | 1. 点明"变的是链上字节与 `keccak256` 断言,不是浏览器画面"(原文已提编辑器看不出,未提哈希断言);
2. 补 `git add --renormalize .` / 重新检出 `img/*.svg` 的存量工作区刷新说明;
3. 首段与第 04 篇建立内链;
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")。
+- 后续若再送审,需要新的内容波次而不是旧批次重发。
diff --git a/public/subs/green-train-to-the-sea.srt b/public/subs/green-train-to-the-sea.srt
index f5d67cf..ce6573c 100644
--- a/public/subs/green-train-to-the-sea.srt
+++ b/public/subs/green-train-to-the-sea.srt
@@ -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...
diff --git a/src/content/posts/chainlink-vrf-v2-lottery-contract.md b/src/content/posts/chainlink-vrf-v2-lottery-contract.md
index 9a34511..1a2c37d 100644
--- a/src/content/posts/chainlink-vrf-v2-lottery-contract.md
+++ b/src/content/posts/chainlink-vrf-v2-lottery-contract.md
@@ -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.
diff --git a/src/content/posts/fully-on-chain-svg-nfts.md b/src/content/posts/fully-on-chain-svg-nfts.md
index bfdc44b..885d4db 100644
--- a/src/content/posts/fully-on-chain-svg-nfts.md
+++ b/src/content/posts/fully-on-chain-svg-nfts.md
@@ -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
diff --git a/src/content/posts/generating-subtitles-for-songs-without-timestamps.md b/src/content/posts/generating-subtitles-for-songs-without-timestamps.md
index d432d10..4e153a8 100644
--- a/src/content/posts/generating-subtitles-for-songs-without-timestamps.md
+++ b/src/content/posts/generating-subtitles-for-songs-without-timestamps.md
@@ -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.
[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...+[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... diff --git a/src/content/posts/how-to-verify-a-hosting-provider-before-you-buy.md b/src/content/posts/how-to-verify-a-hosting-provider-before-you-buy.md index beb8c68..653a874 100644 --- a/src/content/posts/how-to-verify-a-hosting-provider-before-you-buy.md +++ b/src/content/posts/how-to-verify-a-hosting-provider-before-you-buy.md @@ -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 diff --git a/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md b/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md index 7d92188..d3cf75e 100644 --- a/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md +++ b/src/content/posts/why-my-on-chain-nft-art-changed-on-windows.md @@ -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. diff --git a/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md b/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md index aab0a4d..45c9987 100644 --- a/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md +++ b/src/content/posts/zh/chainlink-vrf-v2-lottery-contract.md @@ -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 余额。没人会自动充值—— 我会写一个小监控脚本,给订阅补仓、余额太低时报警。 diff --git a/src/content/posts/zh/fully-on-chain-svg-nfts.md b/src/content/posts/zh/fully-on-chain-svg-nfts.md index 62465f8..b324715 100644 --- a/src/content/posts/zh/fully-on-chain-svg-nfts.md +++ b/src/content/posts/zh/fully-on-chain-svg-nfts.md @@ -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 网关、没有网络查找、没有任何需要维持在线的东西。 diff --git a/src/content/posts/zh/generating-subtitles-for-songs-without-timestamps.md b/src/content/posts/zh/generating-subtitles-for-songs-without-timestamps.md index ff047c0..ac4d76a 100644 --- a/src/content/posts/zh/generating-subtitles-for-songs-without-timestamps.md +++ b/src/content/posts/zh/generating-subtitles-for-songs-without-timestamps.md @@ -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'));
[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...+[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... diff --git a/src/content/posts/zh/how-to-verify-a-hosting-provider-before-you-buy.md b/src/content/posts/zh/how-to-verify-a-hosting-provider-before-you-buy.md index 2e0c866..ee16e50 100644 --- a/src/content/posts/zh/how-to-verify-a-hosting-provider-before-you-buy.md +++ b/src/content/posts/zh/how-to-verify-a-hosting-provider-before-you-buy.md @@ -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 信誉从第一天起就已经报废 —— 你自己的配置 + 再干净也没用。 我找到的最有用的单一信号是:**封停报告。** 一家服务商遇到滥用投诉时的第一反应是 「先封停」、第二反应是「收『恢复费』」,这会在某条评论里白纸黑字写出来。那是 diff --git a/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md b/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md index 33c0106..3f57184 100644 --- a/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md +++ b/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md @@ -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 报告,而这个修法背后 的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。