docs(review): apply batch-04 consultant fixes and approve 5 tutorial/web3 posts
Deploy / build (push) Successful in 22s
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:
1 parent
01c55805fb
commit
cd6511a0b3
14 files changed
+322
-166
No files matched your search
@@ -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 |
|
||||
@@ -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")。
|
||||
- 后续若再送审,需要新的内容波次而不是旧批次重发。
|
||||
@@ -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.
|
||||
|
||||
@@ -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 余额。没人会自动充值——
|
||||
我会写一个小监控脚本,给订阅补仓、余额太低时报警。
|
||||
|
||||
|
||||
@@ -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 报告,而这个修法背后
|
||||
的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。
|
||||
|
||||
|
||||
Reference in new issue
Block a user