252 KiB
252 KiB
博客文章顾问审阅资料包 — Batch-01
批次编号:
Batch-01
包含文章数:8 篇
本批次总字符量:174,959 字(严格限制 < 200,000 字)
模式:BILINGUAL
生成时间:2026-10-27 04:23:31
外部顾问审阅前置须知与工程背景规范
本文档目标:本批次包含
blog.hoelee.com的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。
1. 站点定位与双重业务目的
- 受众 A(技术招聘官 / 资深技术主管):寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。
- 受众 B(中小企业主 SME / 商业客户):寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。
2. 核心人设铁律与工程硬约束(必须严格遵守)
- 彻底杜绝业余爱好者/玩具口吻:
- ❌ 绝对禁用词:
homelab,home lab,家用,小主机,玩具,折腾。 - ✅ 标准替代词:
the infrastructure I manage(我管理的私有基础设施)、self-hosted production node(自托管生产节点)、on-premise host(私有化服务器)。
- ❌ 绝对禁用词:
- 严禁捏造虚假指标与性能数字:
- 所有 benchmark 数据必须有明确的测试基准与单设备限制。
- 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。
- 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。
- 去模板化叙事与真实物理锚点:
- 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。
- 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。
- 代码与配置展示规范:
- 超过 20 行的配置或日志采用 HTML
<details><summary>原生折叠,<summary>必须精准标注文档名与行数。
- 超过 20 行的配置或日志采用 HTML
3. 顾问重点开刀维度
- 事实与数字可核性:技术判断与性能数据是否自洽?有无过度夸大?
- 人设与语调纯洁性:有无遗漏的 hobbyist/homelab 口吻?
- 双轨转化引导有效性:是否有清晰的业务价值提炼与行动召唤(CTA)?
- 代码与生产安全性:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置?
本批次文章目录索引
- [ai] Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI (
why-i-still-bought-a-local-gpu) — ~16,735 字符 - [ai] 为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2 (
yue2-on-a-12gb-gpu-songs-that-lost-their-endings) — ~53,369 字符 - [case-studies] 我如何用 FLUX.1 Kontext 打造 AI 家具合成演示 (
ai-furniture-compositing-with-flux-kontext) — ~19,749 字符 - [case-studies] 从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操 (
from-audio-to-a-4-minute-ai-music-video) — ~16,195 字符 - [case-studies] 我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的 (
how-i-built-an-automated-music-video-pipeline) — ~14,196 字符 - [case-studies] 我是如何构建 DigiKedai Telegram AI 客服机器人的 (
how-i-built-the-digikedai-telegram-bot) — ~15,990 字符 - [case-studies] 我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD (
how-i-host-this-blog) — ~10,053 字符 - [case-studies] 我用 Suno AI 做出自己的歌 (
how-i-made-my-own-songs-with-suno-ai) — ~28,672 字符
文章 01 / 08:Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI
- Slug:
why-i-still-bought-a-local-gpu - 英文标题: Why I Still Bought a GPU to Run AI When Claude and GPT Are Obviously Stronger
- 分类:
ai| 发布日期: 2026-09-21 - 线上地址: https://blog.hoelee.com/posts/why-i-still-bought-a-local-gpu/ (EN) | https://blog.hoelee.com/posts/zh/why-i-still-bought-a-local-gpu/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI"
description: "Claude Opus 和 GPT 确实比我能在本地跑的任何模型都强。但我还是买了 GPU——这篇讲成本、控制权与可用性,附实际的判断标准。"
pubDate: 2026-09-21
category: ai
blueprint: ai-agent-automation
blueprintLayer: L3-local-gpu-inference
tags: [local-llm, gpu, llama-cpp, lm-studio, agents, self-hosting, cost]
ogImage: /og/zh-why-i-still-bought-a-local-gpu.jpg
banner: /banners/why-i-still-bought-a-local-gpu.jpg
draft: false
dynamicCta: true
---
在我管理的自托管生产节点(Self-hosted production infrastructure)上,常年运行着全套 Docker 容器栈、NAS 存储、Cloudflare 隧道与自动化调度任务。但我照样在付顶级模型的钱。
所以每次别人知道我也在跑本地模型,问的都是同一句话:*Claude 和 GPT 就是
比较强,本地模型又麻烦、又慢、效果还比较差,那你到底为什么要花钱买 GPU?*
如果只比模型能力,我完全同意。今天要处理一个全新的问题、做复杂推理、规划
产品架构、或者跑一个很长的 Agent 任务,我大部分时间还是先用 Claude、GPT。
我部署本地模型,从来不是因为我觉得它已经打赢闭源模型。
而是因为 AI 越来越好用之后,出现了另一个问题:我做的每一件事,都开始建立
在一个我自己完全不能控制的服务上。
---
## 真正的问题不是能力,是依赖
今天模型可以用多少、方案怎么收费、什么地区能不能用、版本什么时候换掉,
最后都不是使用者决定的。Anthropic 本来就有自己的地区限制,有些市场不能
直接买 Claude 的商业服务;OpenAI 的方案、额度跟模型也一直在调。这些是商业
服务,变动本来就可以预期。
以前 ChatGPT 只是偶尔拿来问问题,今天不能用,我明天再问没什么差别。
但当我开始拿 AI 写程式、跑 Agent、分析文件、处理客服、整理公司资料,甚至
把它接进每天都在跑的工作流程之后,问题就变了。当 AI 从聊天工具变成工作里
的基础设施,你要考虑的就不只是「哪一个模型比较强」,而是:
> 如果我今天很依赖这个模型,但有一天它的使用条件改掉,我原本的工作还能不能继续?
这就是为什么我觉得,闭源模型越强,本地模型的重要性会越来越高。因为闭源
模型太好用,大家就会把越来越多事情交给它;依赖越深的同时,也会越被这些
闭源模型控制。
还有一件事很值得讲清楚。每一家大模型公司都在谈 AI 安全、模型风险,甚至
限制某些内容、地区或使用方式,但同时每一家又都还在拼命把模型做得更强——
因为模型能力本身就是竞争力,你不做,别人还是会做。
所以最后真正被控制的,往往不是 AI 要不要继续发展,而是谁能用、怎么用、
用多少,以及模型公司愿意提供到什么程度。对一般人来说,这可能只是服务
条款;对企业来说,这就是**供应商风险**。假设一间公司的核心流程,加上后面
所有 Agent、工具、资料处理,全部绑在同一个供应商身上,那它其实不只是在买
Token——它是在把一部分工作的主控权交出去。
---
## 「可是本地模型就是比较差啊」——对,但这是错误的比较方式
这是最诚实的反对意见,而且它是事实。拿一个能放进 24GB、32GB 消费级显卡的
模型,直接跟 Claude Opus 全面比较,很多复杂任务就是会输。这没什么好辩的。
但大家很容易用了错误的比较方式。真正该问的不是「Qwen 能不能打赢 Claude」,
而是:
> **这个能力差距,到底有没有大到值得你永远用最贵的模型来做这件事?**
因为这个交换并不对称。二三十 B 的模型确实落后顶级模型一截,但成本低一到
两个数量级,或者直接跑在你已经买的硬体上。比例不同,决策就不同。
### 我把模型能力看成分界线,不是排行榜
如果一个任务低于某个能力门槛,那就是做不了,当然用强模型。但一旦跨过
那条线,后面真正影响我使用方式的,就不再是 benchmark 分数,而是成本、
速度、能不能长时间运行,以及我能不能控制这套系统。
以我的实际分法:
| 任务 | 我用什么 |
|---|---|
| 问题还很模糊、连方向都不知道 | 顶级闭源模型——前面判断错了,后面做再多都没意义 |
| 产品架构、资料格式、验收条件 | 顶级闭源模型 |
| 照著已定规格建专案骨架、改档案、跑测试、看到错误再修 | 本地模型 |
| 长时间 Agent、重试、批次工作、大量内容产出 | 本地模型 |
关键是第二块。一旦方向确定、Agent 只是在执行规格,一个任务可能会呼叫模型
二十次、五十次。这五十次真的每一次都需要市面上最贵的模型吗?通常不需要。
Agent 已经不是在帮我决定产品要怎么做,它是在按照前面已经写好的规格执行。
只要本地模型可以正确理解这些规格、知道工具怎么用,它就已经跨过可以工作的
门槛。
---
## Agent 会把小小的价差一直放大
用聊天,是问一次、回答一次。Claude 一次比本地模型贵很多,你也不会有特别
感受。
但 Agent 不是一次。一个任务可能呼叫模型二十次、五十次,失败之后还会重新
跑。模型单次价格的差距,放进这种连续任务里就会被一直放大。
而真正做过这些流程的人都知道,最花资源的往往还不是最后正式执行,而是前面
测试。你会改 Prompt、换工具、重新切资料、调整工作流程,同一个东西跑十次、
二十次很正常。
每次都是 API,我自己就会很明显感觉到:又在烧钱。用自己那张卡,当然还是有
电费跟硬体成本,但我今天让一个 Agent 跑五次还是五十次,不会每跑一次就多
一笔帐单。这对我来说很重要,因为它会直接影响我敢不敢一直连续测试。很多
真正稳定的 AI workflow,不是第一次写完就成功,而是让它失败很多次之后,
才慢慢找到什么地方容易出问题。
本地算力其中一个很大的价值,就是你可以把这些大量试错搬回自己的固定成本
里。这跟单纯比较「一百万 Token 多少钱」其实是两件事。
算一笔很实在的工程账:一个包含工具调用的典型 Agent 循环,跑完一次完整的代码测试或文件重构,通常要经历 30–50 轮迭代。由于每轮都需要把历史上下文和工具执行输出重新带入,平均单轮上下文在 15k–25k tokens 左右——单次 Agent 运行就要消耗 600k–1.2M tokens。如果你在调试一套新的复杂工作流,一天反复测试优化 20 次,就是 1,500 万到 2,400 万 tokens。按照顶级 API(如 Claude 3.5 Sonnet 的 $3/$15 每百万 token)计算,这一天的调试账单就在 $50 到 $150 美元;若是 Claude Opus,直接突破数百美元。
而在我这台配有 12GB 显存的自托管 GPU 节点上(通过 LM Studio / llama.cpp 本地运行量化版 Qwen 2.5),整机满载功耗不过 170W 左右。一天跑 20 次还是跑 100 次,新增的边际成本只有几毛钱电费。这笔账的本质不是简单的 token 单价对比,而是决定了你在工程探索时,敢不敢给 Agent 足够的试错空间。
---
## 开放模型已经跨过可用的线,而这条线还在移动
三年前,要在消费级显卡上跑一个真的可以帮你工作的模型,限制很多。现在
二三十 B 的模型经过量化之后,24GB、32GB 这种显卡已经可以处理不少工作;
再往更大的记忆体走,语言、图片,甚至部分影片模型都开始可以自己部署。
真正重要的不是「开源终于打赢闭源了」,而是原本只有闭源模型做得到的工作,
正在一个一个掉进本地可以处理、而且可以被接受的范围。假设三年前十个工作
只有两个能放本地,现在已经有四五个可以做——这个比例本身就已经改变了
本地部署的价值。
而且我买 GPU 的时候,不是在买 Qwen 这一颗模型。模型可以一直换,也会一直
出现新的:如果之后出现一个更适合 Coding 的开放模型,我直接换权重;再过
半年有更好的量化方式,同一张卡可能又可以装进原本跑不动的模型。我真正买
下来的,是**一块自己可以控制的运算资源**,后面的模型可以一直替换。
这就是本地跟闭源服务最不一样的地方。闭源模型能力通常比较强,但你用的是
某家公司现在愿意提供给你的版本;本地硬体能力可能没那么高,但只要硬体
撑得住,我可以自己决定今天跑什么模型、换什么版本、要跑多久。
---
## 固定算力 vs. 每次租算力
如果 AI 永远只是拿来问问题,我完全不觉得大家都需要买 GPU。但现在发展方向
很明显不是这样:Agent 会长时间执行工作,图片跟影片模型会需要大量生成,
公司也会把更多固定流程交给 AI。我们不是单纯使用模型的次数变多,而是模型
开始变成一个持续工作的运算单位。
这时候,「固定拥有一部分算力」跟「每一次执行都向外面租」,就会开始变成
两种不同的成本结构。使用量少,租一定比较方便;使用量够大、够固定之后,
自己有算力就能更好控制与估算成本。
它还多了一个闭源服务给不了的东西:主控权。当机器是我的,我要跑内部资料、
接自己的工具、长时间执行 Agent,都不需要每次先确认供应商的方案、额度或者
限制有没有改。尤其公司真的开始把 AI 接进内部流程之后,这件事就不只是省
Token——有些资料本来就不适合离开自己的环境,有些 Agent 权限也不可能直接
全部交给外部服务。当模型跑在自己的环境里,资料、工具、权限跟执行方式可以
一起设计。这才是「控制权」真正指的东西,不只是把权重下载下来而已。
---
## 我现在实际怎么判断
我不会再用「它比 Claude 笨,所以没有价值」这种方式判断一个本地模型。我会
依序看四件事:
1. **这颗模型能不能跨过我这个工作的门槛?**
2. **这个任务会执行多少次?**
3. **是不是需要大量试错,或长时间的 Agent?**
4. **这些资料跟系统,到底要不要留在自己的环境?**
这几个条件一叠起来,本地模型即使不是最强,还是可能比顶级闭源模型更适合
那一段工作。而且随著开放模型越来越强,这个范围只会一直变大。
所以我真正看好的,不是某一颗模型,也不是某一张 GPU,而是这个趋势:
**可以在自己硬体上完成的 AI 工作正在变多。** 以前买一张消费级显示卡,
可能只能拿来跑一些语言模型;现在开始可以接 Agent、图片、影片,后面模型
跟推理框架继续优化,同样的硬体又可能得到新的用途。
硬体当然会折旧,新卡也可能越来越贵,但至少模型不是绑死的。只要这台机器
还有足够的运算能力,我就可以一直换新的开放模型。
---
## 一句话版本
我现在已经不会只看哪个模型比较聪明。我比较的是:**这件工作到底需要多强的
模型、它要被执行多少次,以及我愿意把多少控制权留在外部服务手上。**
需要顶级推理的地方,我照样花钱用最强的模型。但当工作开始变成大量、连续、
固定,而且本地模型的能力已经跨过可使用门槛时,我就没有理由每一个步骤都
继续用最贵的闭源模型。
浓缩成一句:
> 用顶级模型决定要做什么,用本地算力把它做上一千次。
这就是我买 GPU 的原因。
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "Why I Still Bought a GPU to Run AI When Claude and GPT Are Obviously Stronger"
description: "Claude Opus and GPT are stronger than anything I can run locally. Here is the cost, control, and uptime argument for owning a GPU anyway — with real numbers."
pubDate: 2026-09-21
category: ai
blueprint: ai-agent-automation
blueprintLayer: L3-local-gpu-inference
tags: [local-llm, gpu, llama-cpp, lm-studio, agents, self-hosting, cost]
ogImage: /og/why-i-still-bought-a-local-gpu.jpg
banner: /banners/why-i-still-bought-a-local-gpu.jpg
draft: false
dynamicCta: true
---
Across the self-hosted production infrastructure I manage — Docker stacks, NAS
storage, tunnels, and automated cron pipelines — I still pay for frontier models.
So when people find out I run local models too, the question is always the same:
*Claude and GPT are stronger. Local models are a pain to set up, slower, and
often just worse. So why did you spend money on a GPU?*
If you are only comparing raw model capability, I agree with you completely.
When I hit a genuinely new problem, do complex reasoning, plan a product
architecture, or kick off a long agent run, I still reach for Claude or GPT
first. I did not deploy local models because I think they have beaten the
closed frontier.
I did it because of a different problem that only showed up *after* AI got good
enough to depend on. Everything I built started sitting on a service I do not
control.
---
## The problem is not capability — it is dependency
How many tokens you get, what a plan costs, which regions can buy the commercial
service, when the model version changes under you — none of that is the user's
decision. Anthropic has regional restrictions of its own; some markets cannot
buy Claude's commercial service directly. OpenAI's plans, quotas and model
lineup keep shifting. These are commercial services, so of course they change.
That was fine when ChatGPT was something I opened occasionally to ask a
question. If it was unavailable today, I would ask tomorrow and lose nothing.
It stopped being fine once I started using AI to write code, run agents,
analyze documents, handle support, organize company data, and wire models into
workflows that run every day. When AI moves from *chat tool* to *infrastructure*,
the question changes. It is no longer "which model is smarter?" It becomes:
> "If I depend on this model today, and its terms change tomorrow, does my work
> still run?"
That is why I think the stronger the closed models get, the more valuable a
local model becomes. Precisely *because* they are so good, people hand them more
and more work. And the deeper the dependency, the more of your operation someone
else controls.
There is a second-order version of this that rarely gets said plainly. Every big
lab talks about AI safety, model risk, and restricting certain content, regions
or use cases — while all of them keep racing to make models stronger, because
capability *is* the competitive advantage. Nobody stops, because if you stop,
someone else does not.
So what actually gets controlled is rarely whether AI keeps developing. It is
who may use it, how, how much, and how far the vendor is willing to go. For an
individual, that reads like a terms-of-service page. For a business, it is
**supplier risk**. If your core process and every agent, tool and data pipeline
around it are pinned to one vendor, you are not just buying tokens — you are
handing over part of the steering wheel.
---
## "But local models are worse" — yes, and that is the wrong comparison
This is the most honest objection, and it is true. Put a model that fits on a
24GB or 32GB consumer card against Claude Opus and it loses on complex tasks. No
argument.
But the comparison most people run is wrong. The question is not *can Qwen beat
Claude*. It is:
> **Is the capability gap large enough to justify using the most expensive model
> for this task, forever?**
Because the trade is not symmetric. A 20–30B model is meaningfully behind the
frontier — and costs one to two orders of magnitude less per token, or runs on
hardware you already own. Different ratio, different decision.
### I treat capability as a threshold, not a ranking
If a task is below some capability line, it cannot be done at all — use the
strong model. But once a model crosses that line, the things that decide how I
actually work stop being benchmark scores and become cost, speed, whether it can
run for hours, and whether I control the system.
Concretely, here is how it splits for me:
| Task | What I reach for |
|---|---|
| Vague problem, no clear direction yet | Frontier model — a wrong early call invalidates everything downstream |
| Product architecture, data model, acceptance criteria | Frontier model |
| Scaffolding a project to a fixed spec, editing files, running tests, fixing failures | Local model |
| Long-running agents, retries, batch jobs, bulk content | Local model |
The important shift is the second block. Once direction is settled and the agent
is *executing a spec*, a single task may call a model 20–50 times. Do all fifty
calls need the most expensive model on the market? Usually not. The agent is not
deciding what the product should be — it is following specifications that are
already written. If a local model understands those specs and knows how to use
the tools, it has crossed the working threshold.
---
## Agents turn a small price difference into a large one
With chat, you ask once and get one answer. If Claude costs many times more than
a local model, you barely notice.
Agents are not one call. A task can invoke the model twenty times, fifty times;
it fails and reruns. The per-token price gap gets multiplied by every call.
And anyone who has actually built these pipelines knows the expensive part is
not the final run — it is the testing before it. You rewrite the prompt, swap
tooling, re-chunk the data, adjust the workflow, and run the same thing ten or
twenty times. That is normal.
On metered API calls, I feel it: *I am burning money right now.* On my own card,
there is still electricity and hardware cost, but running an agent five times or
fifty times does not add a line to an invoice. That difference changes my
behavior — it decides whether I dare keep testing. Most stable AI workflows are
not written correctly the first time. They get there after failing enough times
that you find out where they break.
The value of local compute is that you move that trial-and-error into a fixed
cost. That is a different question from "how much does a million tokens cost."
Here is the math on a real engineering workload: a typical agent loop with tool-use iterating through tests and refactoring takes 30–50 turns. Because each turn carries previous tool outputs and conversation history, an average turn easily loads 15k–25k tokens of context — consuming 600k to 1.2M tokens in a single agent run. If you are tuning a new autonomous workflow and test it 20 times a day, you burn 15M to 24M tokens. On frontier APIs like Claude 3.5 Sonnet ($3/$15 per million tokens), that single day of debugging costs $50–$150; on Opus, it crosses several hundred dollars.
On my own self-hosted GPU node (a dedicated 12 GB VRAM card running quantized Qwen 2.5 via LM Studio / llama.cpp), the entire rig pulls under 200W at full load. Running 20 iterations or 100 iterations adds cents in electricity and $0 in marginal API bills. The real math is not just "cost per million tokens" — it is whether you can afford to let an agent fail enough times to become reliable.
---
## Open models crossed the useful line, and the line keeps moving
Three years ago, running a model on a consumer card that could genuinely do work
for you was a research project. Today, 20–30B models — quantized — handle real
work on 24GB and 32GB cards. Go up in VRAM and language, image, and now portions
of video generation become self-hostable too.
The important part is not "open source finally won." It is that work which used
to be closed-model-only keeps falling inside the range that local hardware can
do acceptably. If three years ago two of ten jobs could run locally, and today
four or five can, that ratio alone has changed the value of owning the hardware.
And when I buy a GPU, I am not buying a specific model. Models change. If a
better coding model ships next month, I swap weights. If a better quantization
scheme lands in six months, the same card suddenly fits a model it could not run
before. What I actually bought is **compute I control**; the models on top of it
are disposable.
That is the structural difference. A closed model is stronger — and also exactly
the version one company currently chooses to offer you, at terms it currently
chooses. Local hardware is weaker — but as long as the card holds up, I decide
which model runs today, which version I switch to, and how long it runs.
---
## Fixed compute vs. renting compute
If AI stays a question-and-answer tool, I do not think most people need a GPU at
all. But that is clearly not the direction. Agents run for long stretches. Image
and video models want bulk generation. Companies hand more fixed processes to
AI. We are not just *using models more often* — models are becoming a continuously
working compute unit.
At that point, owning part of the capacity and renting every execution become
**two different cost structures**. Low usage: renting is obviously more
convenient. High, steady usage: owning lets you control and predict cost.
It also buys something no API can sell you — control. When the machine is mine,
I can point it at internal data, connect my own tools, and run agents for hours
without checking whether a vendor's plan, quota or limits changed overnight.
Once AI is inside real company processes, this stops being about saving tokens.
Some data should not leave the environment it lives in. Some agent permissions
should not be handed wholesale to an external service. If the model runs on my
own hardware, data, tools, permissions and execution can be designed together.
*That* is what I mean by control — not merely having the weights on disk.
---
## What I actually evaluate now
I no longer judge a local model by "it is dumber than Claude, therefore
worthless." Four questions, in order:
1. **Does this model clear my working threshold** for this specific job?
2. **How many times will this task execute?**
3. **Does it need heavy trial-and-error, or long agent runs?**
4. **Does the data or system need to stay in my own environment?**
When those stack up, a local model — even one that is not the strongest — can be
the better tool for that slice of work. And as open models improve, that slice
keeps getting bigger.
So what I am bullish on is not one model or one GPU. It is the trend: **more AI
work becomes doable on hardware you own.** A consumer card that used to run only
language models now runs agents, image generation, video. Better models and
better inference frameworks keep giving the same metal new jobs.
Hardware depreciates, new cards get more expensive — that is real. But the model
is not locked in. As long as the machine has enough compute, I can keep swapping
in newer open models.
---
## The short version
I no longer compare "which model is smarter." I compare **how much capability
this job needs, how many times it will run, and how much control I am willing to
leave in someone else's hands.**
Where I need frontier reasoning, I pay for the frontier. But when work becomes
high-volume, continuous and predictable — and a local model has crossed the
usable threshold — there is no reason to keep paying frontier prices for every
single step.
In one line:
> Use the frontier model to decide what to build. Use local compute to build it
> a thousand times.
That is why I bought the GPU.
文章 02 / 08:为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2
- Slug:
yue2-on-a-12gb-gpu-songs-that-lost-their-endings - 英文标题: Why My AI Songs Kept Losing Their Endings: Running YuE2 on a 12 GB GPU
- 分类:
ai| 发布日期: 2026-10-20 - 线上地址: https://blog.hoelee.com/posts/yue2-on-a-12gb-gpu-songs-that-lost-their-endings/ (EN) | https://blog.hoelee.com/posts/zh/yue2-on-a-12gb-gpu-songs-that-lost-their-endings/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2"
description: "12 首中文歌时长全都恰好 240.000 秒,尾奏不见了。原因不在歌词 —— 是一道硬上限、一堵显存天花板,和一个不看 BPM 的规划器。"
pubDate: 2026-10-20
category: ai
blueprint: ai-agent-automation
blueprintLayer: L4-aigc-media-pipelines
stream: creative-lab
tags: ["yue2", "comfyui", "ai-music", "local-ai", "gpu", "vram", "mandarin"]
ogImage: "/og/zh-yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
banner: "/banners/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
draft: false
dynamicCta: true
---
YuE2 是一个 30 亿参数的开源音乐模型,能按风格提示词和歌词唱中文。我把它放在自己那台 12GB 显存的 RTX 3060 上用 ComfyUI 本地跑,一次排了 12 首原创歌 —— 结果第一批出来,**每一首的时长都恰好是 240.000 秒**。这个数字不是巧合,而且是个坏消息。
## 每首都恰好 240.000 秒
12 个文件,时长精确到毫秒完全一致:
```
01_A1_..._糖画小仙.mp3 duration=240.000000
02_A2_..._溪边捉云记.mp3 duration=240.000000
03_A3_..._寒露拾桂子.mp3 duration=240.000000
...
```
我设的是 `max_duration=240`,因为参考指南说 12 段结构大约会落在三分半左右。结果它精确落在 240 秒 —— 这意味着模型从来没有"决定结束",它是把时间用光了。
## 整数是一种自白
YuE2 用 ABC 规划器来编排:读取歌词,给每一段分配小节数,生成一份符号化的曲谱草稿。音乐生成器照着这份草稿唱,**草稿结束它就结束**。
所以一首真正唱完的歌,输出时长一定是**小于**你设的上限的 —— 它自己停下来了。
判断方法只要一条命令:
```bash
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 take.mp3)
python -c "print(abs(float('$d') - 240.0) <= 0.005)" # True = 被硬切
```
如果时长和 `max_duration` 相差不到几毫秒,这次渲染就是被截断的。歌词需要的时间超过了上限允许的范围,切点那一句被拦腰砍断。
## 确认尾奏真的丢了
光看时长只能知道"被切了",不知道"丢了什么"。所以我把每首的尾巴切出来,用本地语音模型(SenseVoice,跑在 CPU 上,GPU 渲染时不用停)转写:
```bash
ffmpeg -v error -y -i take.mp3 -ss 232 -t 20 -ar 16000 -ac 1 -c:a pcm_s16le t.wav
curl -s -X POST http://127.0.0.1:20129/v1/audio/transcriptions \
-F "[email protected]" -F "language=zh"
```
(对于连末遍副歌都没唱到的曲目,我将切片窗口向前扩展到了最后 30 秒即 `-ss 210`,以准确定位截断发生时的唱段。)结果没有歧义:
| 歌 | 唱到哪 | 缺了什么 |
|---|---|---|
| 糖画小仙 | 末遍副歌最后一句 | **整段 `[Outro]`** |
| 溪边捉云记 | 215 秒还停在 `[Bridge]` | Solo + 末遍副歌 + Outro |
| 寒露拾桂子 | 215 秒还停在 `[Bridge]` | 末遍副歌 + Outro |
| 纸鸢与旧木偶 | 末遍副歌第 5 句 | 副歌后半 + Outro |
所以不是混音问题、也不是淡出 —— 是结构不完整。
## 试过但没用的三件事,和半有用的一件
### 1. 提高 BPM —— 完全无效
直觉上:节奏更快、每秒塞进更多字、同样的窗口能装更多歌词。我在整批上验过:117 BPM 的国风组和 140 BPM 的 DJ 燃曲组,**吞吐几乎一样**。
YuE2 的时长来自 ABC 规划器分配的小节数,不看你风格提示词里的 BPM 标签。BPM 只影响"感觉",买不到时间。别在它身上调时长。
### 2. 同 seed 重跑 —— 静悄悄地什么都没做
ComfyUI 是用 `--cache-classic` 启动的,所以重新提交一个逐字节相同的任务(同样的风格、同样的歌词、同样的 seed)会直接命中节点缓存。任务"完成"于 10 秒左右,还你一份一模一样的截断音频,文件名加个 `_00002`。
看起来像一次又快又成功的重试。实际上是命中了一个已知有问题 take 的缓存。
### 3. 换 seed —— 四次里能救三次
规划器分配小节的方式依赖 seed,所以换个 seed 确实可能给出更短、能唱完的版本。这一招救回了我四首里的三首:
| 歌 | 原始 | 新 seed | 结果 |
|---|---|---|---|
| 溪边捉云记 | 卡在 281 秒 | 20002 | 279.7 秒,完整 |
| 外婆的绿搪瓷碗 | 卡在 300 秒 | 20007 | 251.9 秒,完整 |
| 北溟有小鱼 | 卡在 300 秒 | 20005、30005 | 再次撞顶 |
## 预算是"每秒几个字",而这个数还在变
我试过把它做成算术:数字数、除以吞吐、得出时长。实测不配合。
| 歌 | 汉字数 | 上限 | 实际 | 装下了吗 |
|---|---|---|---|---|
| 糖画小仙 | 556 | 272 秒 | 258.7 秒 | ✅ |
| 纸鸢与旧木偶 | 572 | 300 秒 | 279.9 秒 | ✅ |
| 溪边捉云记 | 578 | 281 秒 | **281.0 秒** | ❌ 被切 |
| 小楼檐下听雨眠 | 580 | 300 秒 | 256.3 秒 | ✅ |
| 画卷小锦鲤 | 634 | 300 秒 | 285.9 秒 | ✅ |
| 三楼窗台的蒲公英 | 693 | 329 秒 | 247.4 秒 | ✅ |
| 弄堂口的竹蜻蜓 | 692 | 300 秒 | 250.0 秒 | ✅ |
把最后两行和第三行对比着看:**693 个字在 247 秒内唱完了,578 个字却撑爆了 281 秒。** 字数换算时长的公式在这里根本不存在 —— 实测吞吐落在每秒 2.0 到 2.8 个汉字之间,取决于规划器怎么切段落。
所以字数只适合用来**预警预算**,不适合用来掐时间。
## 真正卡住的瓶颈是显存
一旦接受"给它足够空间、让它自己收尾",上限自然想设大一点。但在 12GB 卡上设不大。
渲染过程中每 5 秒采一次 `nvidia-smi` 得到的峰值显存:
| `max_duration` | 峰值显存 |
|---|---|
| 240 秒 | 10.3 GB |
| 272 秒 | 11.65 GB |
| 278 秒 | 11.07 GB |
| 281 秒 | 12.06 GB |
| 300 秒 | 12.03 GB |
显存总量为 12,288 MiB(12 GB)。**300 秒就是实际天花板** —— 再往上,你离"渲染到第六分钟时因为一个浏览器标签页而 OOM 崩掉"只差一步。
## 解法
三处改动,都很便宜:
1. **把上限设到 300 秒,让规划器自己收尾。** 不要追目标时长;给足余量,它会在音乐结束的地方结束。
2. **被切的 take 归档,不要混进成品。** 时长等于上限的 take 移到 `未自然结尾\` 目录。成品目录里就只有完整的歌,而且重跑永远不会覆盖掉一个可用的 take。
3. **换 seed 最多试三次。** 换 seed 有用,但如果一首歌的歌词真的超过显存能撑的时长,它会永远失败。三次不成就动歌词 —— 别烧一小时 GPU 假装下一个 seed 会中。
## 那首始终装不下的歌
12 首里有一首没做成。**北溟有小鱼,593 字,失败了五次:**
| 尝试 | 参数 | 结果 |
|---|---|---|
| 1 | seed 10005,上限 287 秒 | 撞 287 秒 |
| 2 | seed 10005,上限 300 秒 | 撞 300 秒 |
| 3 | seed 20005,上限 300 秒 | 撞 300 秒 |
| 4 | seed 30005,上限 300 秒 | 撞 300 秒 |
| 5 | seed 40005,删掉两段纯器乐 | 撞 300 秒 |
我甚至把 `[Interlude]` 和 `[Instrumental Solo]` 两个标记删掉 —— 从 12 段降到 10 段,一个唱词都没动 —— 它还是溢出。它的规划器要的时间超过了这张卡能给的上限。诚实的解法是砍掉大约 40 个字再重跑;我宁可告诉你这件事,也不想交一首结尾被咬掉一块的第十二首歌。
## 在这之前踩到的一个坑
值得单独警告:**YuE2 会把你歌词里括号内的中文制作备注唱出来。**
如果你贴的歌词长这样(大多数歌词稿就是这么写的):
```
[Intro]
(重鼓点起,急促琵琶轮指,唢呐破空,合成器铺底)
嘿!哈!山海震!
```
那么那行括号内容就成了一句歌词。模型不知道那是舞台说明。提交前先清掉:
```python
clean = "\n".join(l for l in raw.split("\n")
if not re.match(r"^\s*[((].*[))]\s*$", l))
```
配器和动态写进风格提示词。歌词框里只留段落标签和要唱的字。
## 我会怎么改
- **预警做在歌词阶段,不在渲染之后。** 先数字数。12GB 卡上超过约 600 字就要有心理准备,超过 550 字就该准备删点什么。
- **永远不要把 `max_duration` 设成目标时长。** 设得比估算宽松,让它自己收尾。上限是安全护栏,不是旋钮。
- **把"时长 == 上限"当成流水线里的硬失败**,而不是三天后觉得副歌怪怪的才发现。
- **先换 seed,再动歌词** —— 免费,而且大多数时候有效。然后三次为止。
## 十一首歌
下面这些是自然收尾的成品,女童声、中文、跑在一张 12GB 显卡上,成本比一年的云端音乐额度还低。每个播放器都带同步歌词 —— 因为模型不输出时间戳,时间轴是用转写结果和歌词原文对齐反推出来的。
<style>
.yue2-track{margin:2.2rem 0}
.yue2-track audio{width:100%;max-width:560px}
.yue2-box{position:relative;max-height:250px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6}
.yue2-box p{margin:.18rem 0;opacity:.55}
.yue2-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
</style>
### 1. 糖画小仙 — Sugar-Painting Fairy
*Guofeng electronic* · 556 characters · 259 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-1" src="https://content.hoelee.com/file/hoelee/music/糖画小仙-Mr-Hoelee.mp3"></audio>
<pre id="lrc-1" hidden>[00:16.38]青石板路亮起红灯笼
[00:19.74]我踮起脚尖钻进人海中
[00:24.50]铜锅里的麦芽糖咕嘟咕嘟响
[00:28.20]甜丝丝的风吹红我脸庞
[00:31.22]老爷爷手腕轻轻晃一晃
[00:36.52]金黄的丝线落在石板上
[00:40.22]左一圈是尾巴右一勾是翅膀
[00:43.68]我数着星星等它亮晶晶登场
[00:48.76]竹签儿转呀转出小月亮
[00:52.46]笛声悠悠飘过青瓦墙
[00:55.20]快看那糖画眨了眨眼眶
[00:59.36]悄悄对我说今夜去飞翔
[01:04.04]我是提着灯笼的糖画小仙
[01:07.92]骑着金丝小龙飞过屋檐
[01:11.36]琵琶声哒哒敲打着云烟
[01:15.26]把甜味洒满整条长安街
[01:19.70]蝴蝶在发梢跳起圆圈舞
[01:23.30]小凤凰衔来春天的露珠
[01:27.06]一口咬下月亮的甜脆边角
[01:31.58]连梦里都带着桂花的味道
[01:52.64]小铜勺画出一只小花猫
[01:54.62]胡须翘翘对着我喵喵叫
[01:56.52]旁边的弟弟馋得直跺脚
[01:58.50]我举高竹签偏偏不给瞧
[02:00.72]鼓点像心跳蹦蹦停不了
[02:03.04]石榴裙摆跟着风儿摇
[02:05.20]糖人儿突然拉住我的手
[02:06.64]带我穿过灯火去银河游一游
[02:08.78]竹签儿转呀转出小月亮
[02:12.46]笛声悠悠飘过青瓦墙
[02:15.14]快看那糖画眨了眨眼眶
[02:19.70]悄悄对我说今夜去飞翔
[02:24.12]我是提着灯笼的糖画小仙
[02:28.50]骑着金丝小龙飞过屋檐
[02:31.28]琵琶声哒哒敲打着云烟
[02:35.20]把甜味洒满整条长安街
[02:39.70]蝴蝶在发梢跳起圆圈舞
[02:43.68]小凤凰衔来春天的露珠
[02:47.66]一口咬下月亮的甜脆边角
[02:51.56]连梦里都带着桂花的味道
[02:56.46]不怕夜风凉也不怕路遥远
[03:00.62]童话就藏在一勺甜里边
[03:04.22]只要心里还有亮晶晶的光
[03:08.08]每一朵云彩都是麦芽糖
[03:28.10]我是提着灯笼的糖画小仙
[03:32.00]骑着金丝小龙飞向天边
[03:36.48]古筝声铮铮唤醒了星辰
[03:39.22]万家灯火都为我敞开门
[03:43.42]蝴蝶在发梢跳起圆圈舞
[03:47.26]小凤凰衔来春天的露珠
[03:51.10]一口咬下月亮的甜脆边角
[03:55.52]全世界的开心都装进荷包
[04:01.56]糖画转呀转
[04:02.76]灯火闪呀闪
[04:05.74]小仙女困了
[04:09.72]枕着甜梦晚安</pre>
</figure>
### 2. 溪边捉云记 — Catching Clouds by the Stream
*Guofeng electronic* · 578 characters · 280 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-2" src="https://content.hoelee.com/file/hoelee/music/溪边捉云记-Mr-Hoelee.mp3"></audio>
<pre id="lrc-2" hidden>[00:18.38]阵雨刚刚洗干净小山坡
[00:22.40]荷叶上滚着圆圆的水珠儿
[00:25.18]我卷起裤脚拎着小竹篓
[00:30.10]光着脚丫踩碎溪里的云朵
[00:35.44]小螃蟹吐着泡泡横着走
[00:38.16]红蜻蜓停在芦苇尖上头
[00:42.22]山风吹过竹林沙沙地唱歌
[00:45.88]水底的太阳公公看着我笑呵呵
[00:50.40]竹笛吹开山谷里的薄雾
[00:54.42]水花溅湿我的小布鞋
[00:58.50]伸出手想把白云装进篓
[01:02.16]它却化作浪花溜出指缝间
[01:06.12]空山新雨后呀溪水哗啦啦
[01:10.32]我是山里最忙的小娃娃
[01:15.36]东边追小鱼西边数落花
[01:18.48]把彩虹借来当我的发卡
[01:24.20]古筝弹得水波一圈一圈晃
[01:26.62]松果掉进水里咚的一声响
[01:30.80]夕阳催我回家吃饭我不听
[01:34.76]还要跟小月亮捉迷藏到天明
[01:55.38]青苔石上坐着小青蛙
[01:59.12]鼓着肚皮像个小喇叭
[02:03.28]我折了一只歪歪的纸船儿
[02:07.28]载着三片花瓣顺水去天涯
[02:11.74]山那头住着神仙老爷爷吗
[02:15.82]会不会收到我寄去的悄悄话
[02:20.92]泥点子画在我鼻尖像小花猫
[02:24.48]脆生生的笑声惊飞了白鹭鸟
[02:28.10]竹笛吹开山谷里的薄雾
[02:32.10]水花溅湿我的小布鞋
[02:36.16]伸出手想把白云装进篓
[02:39.76]它却化作浪花溜出指缝间
[02:44.34]空山新雨后呀溪水哗啦啦
[02:48.54]我是山里最忙的小娃娃
[02:52.98]东边追小鱼西边数落花
[02:56.18]把彩虹借来当我的发卡
[03:01.84]古筝弹得水波一圈一圈晃
[03:04.30]松果掉进水里咚的一声响
[03:08.44]夕阳催我回家吃饭我不听
[03:12.38]还要跟小月亮捉迷藏到天明
[03:16.94]小溪水慢些流等等我脚步
[03:20.68]竹篓里装满了夏天的礼物
[03:24.94]一颗鹅卵石加上半朵云
[03:28.66]全都藏进我小小的秘密木盒里
[03:51.98]空山新雨后呀溪水哗啦啦
[03:55.62]我是山里最快乐的小娃娃
[03:59.48]东边追小鱼西边数落花
[04:03.24]把星光借来当我的发卡
[04:07.66]琵琶声伴着晚风轻轻唱
[04:12.26]炊烟升起在温暖的村庄
[04:16.52]娘亲在村口喊着我的小名
[04:19.68]我提着满篓月光蹦蹦跳跳回庭院
[04:24.42]溪水哗啦啦
[04:25.62]蛙声呱呱呱
[04:27.66]云朵睡着啦
[04:33.04]明天再来捉它</pre>
</figure>
### 3. 寒露拾桂子 — Picking Osmanthus at Cold Dew
*Guofeng electronic* · 578 characters · 290 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-3" src="https://content.hoelee.com/file/hoelee/music/寒露拾桂子-Mr-Hoelee.mp3"></audio>
<pre id="lrc-3" hidden>[00:19.03]日历翻到寒露的那一页
[00:23.33]院子里的秋风有了一点凉
[00:28.06]我披上娘亲缝的小夹袄
[00:32.36]提着小竹篮跑到桂花树旁
[00:37.82]金黄的小花朵像雨点落下
[00:40.62]沾满我的刘海和麻花辫上
[00:45.72]大黄狗围着我的裙角打转
[00:49.40]一不小心踩响了满地金黄
[00:54.36]远处的笛声吹落了晚霞
[00:57.84]大雁排成人字飞向南方
[01:01.70]我把桂花一朵一朵收好
[01:06.66]留住整个秋天最甜的时光
[01:10.48]寒露的风儿吹呀吹散了云裳
[01:14.58]小小姑娘在桂花雨里忙
[01:19.70]一层桂花铺上一层白砂糖
[01:24.98]封进玻璃罐里等冬天品尝
[01:27.86]琵琶和古筝在屋檐下对唱
[01:31.94]红枫叶寄去远方的思念长
[01:37.16]哪怕霜花染白了小窗台
[01:41.46]我的小竹篮里永远有春光
[02:05.32]石桌上摆着柿子红彤彤
[02:07.64]我偷偷尝了一口甜进心中
[02:13.48]蚂蚁搬着花瓣排成了长队
[02:17.02]我蹲在地上看了一刻钟
[02:21.64]竹篮装满了一半金桂香
[02:25.10]另一半装进落叶和夕阳
[02:29.14]摇摇晃晃走过九曲小回廊
[02:33.92]影子被拉得又细又长
[02:39.10]远处的笛声吹落了晚霞
[02:42.58]大雁排成人字飞向南方
[02:47.02]我把桂花一朵一朵收好
[02:51.28]留住整个秋天最甜的时光
[02:55.28]寒露的风儿吹呀吹散了云裳
[02:59.24]小小姑娘在桂花雨里忙
[03:04.34]一层桂花铺上一层白砂糖
[03:09.70]封进玻璃罐里等冬天品尝
[03:12.56]琵琶和古筝在屋檐下对唱
[03:16.64]红枫叶寄去远方的思念长
[03:21.88]哪怕霜花染白了小窗台
[03:26.18]我的小竹篮里永远有春光
[03:32.18]节气一轮一轮慢慢走
[03:35.70]小树苗陪着我一起长高
[03:40.12]等到来年春暖花再开
[03:43.62]再把这罐秋天的甜味打开
[03:50.02]寒露的月光洒呀洒满小院墙
[03:53.86]小小姑娘在桂花梦里晃
[03:59.00]一层星光铺上一层桂花糖
[04:03.24]送给夜空中最亮的那道光
[04:08.38]弦乐与古筝在云端上回响
[04:11.80]千山万水都闻到这抹香
[04:16.52]不管岁月走得有多匆忙
[04:20.74]童年的桂花永远不会遗忘
[04:25.70]寒露凉呀桂花香
[04:29.60]小小竹篮装月光
[04:34.28]摇啊摇到外婆桥
[04:38.26]秋天的甜梦真漫长</pre>
</figure>
### 4. 纸鸢与旧木偶 — The Kite and the Old Puppet
*Guofeng ballad* · 572 characters · 280 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-4" src="https://content.hoelee.com/file/hoelee/music/纸鸢与旧木偶-Mr-Hoelee.mp3"></audio>
<pre id="lrc-4" hidden>[00:14.64]老戏台的青苔爬上了木桩
[00:20.50]雨水顺着褪色的画梁滴落
[00:27.38]我抱膝坐在角落的竹筐旁
[00:31.38]看见你静静躺在灰尘中央
[00:34.42]半边脸颊还留着红胭脂妆
[00:41.04]身上的彩衣已经有些微凉
[00:44.72]是谁把丝线遗忘在旧时光
[00:47.64]让你独自守着空荡的剧场
[00:54.06]我用衣袖擦去你眼角的灰
[00:57.56]解下发间的红绳系上你手背
[01:00.56]风吹过空巷像一声轻叹
[01:03.68]我轻轻摇晃你便抬起眉眼
[01:09.16]小木偶呀你别怕夜黑风高
[01:12.98]我提着小灯笼陪你跳一曲歌谣
[01:16.52]指尖的红丝线一牵一绕
[01:20.26]旧戏台又亮起从前的热闹
[01:23.38]唱一段悲欢呀叹一段离合
[01:26.36]清亮的童音穿过雨幕飘摇
[01:29.78]哪怕看戏的人早就散了场
[01:33.00]还有我为你鼓掌到天亮
[01:53.12]你曾看过多少繁华的灯火
[01:58.92]听过多少台下热烈的喝彩
[02:05.72]如今只剩燕子在梁上筑窝
[02:09.72]和一只淋湿翅膀的纸鸢作伴
[02:13.06]我把纸鸢修好放在你身旁
[02:19.28]给你们讲山外小镇的故事长
[02:23.40]木偶的嘴角仿佛弯了一弯
[02:26.00]在摇曳烛光里不再孤单
[02:32.48]我用衣袖擦去你眼角的灰
[02:35.86]解下发间的红绳系上你手背
[02:38.88]风吹过空巷像一声轻叹
[02:42.04]我轻轻摇晃你便抬起眉眼
[02:47.52]小木偶呀你别怕夜黑风高
[02:51.32]我提着小灯笼陪你跳一曲歌谣
[02:55.38]指尖的红丝线一牵一绕
[02:59.02]旧戏台又亮起从前的热闹
[03:01.42]唱一段悲欢呀叹一段离合
[03:04.74]清亮的童音穿过雨幕飘摇
[03:08.08]哪怕看戏的人早就散了场
[03:11.30]还有我为你鼓掌到天亮
[03:18.40]啦——啊——
[03:25.92]丝线不断情意不散
[03:31.44]啦——啊——
[03:38.24]木偶有心岁月生香
[03:44.20]小木偶呀你看那雨过天晴
[03:47.90]漫天的星河落进古老的屋檐
[03:51.48]指尖的红丝线越飞越高
[03:54.74]带着纸鸢飞向云端的怀抱
[03:58.06]唱一段春秋呀绘一幅人间
[04:01.34]纯净的歌声回荡在天地之间
[04:04.70]就算我有一天也会长大走远
[04:08.06]这曲童年的戏永远留在心田
[04:14.80]红绳系手腕
[04:19.32]木偶入梦乡
[04:23.12]旧戏台安静
[04:26.30]月光白如霜</pre>
</figure>
### 5. 小楼檐下听雨眠 — Listening to Rain Under the Eaves
*Guofeng ballad* · 580 characters · 256 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-5" src="https://content.hoelee.com/file/hoelee/music/小楼檐下听雨眠-Mr-Hoelee.mp3"></audio>
<pre id="lrc-5" hidden>[00:16.16]滴答滴答雨水顺着瓦当落
[00:19.40]在青石天井敲出小小的水涡
[00:23.86]我搬来小木凳坐在门槛旁
[00:26.36]双手托着下巴数着雨点儿多
[00:30.74]院里的芭蕉叶被洗得碧绿
[00:33.36]小花猫蜷在竹椅上打着呼噜
[00:37.16]阿姐出门时说天黑前就回
[00:40.30]怎么巷口还没看见那把油纸伞
[00:43.88]风儿吹动檐下的铜风铃
[00:47.46]叮当声混着细雨轻又轻
[00:50.90]我折了一只白色的纸青蛙
[00:53.52]请它去巷口帮我探一探口信
[00:59.00]山外小楼夜听雨呀雨绵绵
[01:03.14]小姑娘守着灯火不肯眠
[01:06.58]数过一滴雨又数过一片云
[01:10.22]等那熟悉的脚步声响在门前
[01:14.20]古筝弹碎了江南的烟雨天
[01:17.48]笛声绕着青砖黛瓦转呀转
[01:20.82]阿姐你可别在路上贪看风景
[01:24.06]家里的桂花糕还温在灶台边
[01:42.88]窗纸上映着摇晃的竹影儿
[01:45.54]奶奶在灯下穿针又引线
[01:48.70]我趴在窗台哈出一团白气
[01:51.68]用手指画出一个圆圆的笑脸
[01:55.94]水缸里的红金鱼摆着尾巴
[01:59.18]好像也在听屋檐滴水的节拍
[02:02.32]雨声越下越密像一张小网
[02:05.76]把整个夜晚都轻轻网在里边
[02:10.98]风儿吹动檐下的铜风铃
[02:13.24]叮当声混着细雨轻又轻
[02:16.66]我折了一只白色的纸青蛙
[02:20.56]请它去巷口帮我探一探口信
[02:25.58]山外小楼夜听雨呀雨绵绵
[02:28.96]小姑娘守着灯火不肯眠
[02:32.42]数过一滴雨又数过一片云
[02:36.34]等那熟悉的脚步声响在门前
[02:40.36]古筝弹碎了江南的烟雨天
[02:43.30]笛声绕着青砖黛瓦转呀转
[02:45.80]阿姐你可别在路上贪看风景
[02:49.90]家里的桂花糕还温在灶台边
[02:53.96]啦——呀——
[02:55.64]雨打芭蕉叶儿垂
[03:01.04]啦——呀——
[03:04.48]油纸伞儿快快归
[03:21.66]山外小楼夜听雨呀雨渐停
[03:24.98]巷口忽然亮起一盏暖黄灯
[03:28.86]油纸伞转开晶莹的小水花
[03:32.58]阿姐笑着唤我熟悉的小名
[03:37.40]二胡与弦乐拉出团圆的暖意
[03:38.76]怀里揣着刚买的热糖炒栗子
[03:42.16]原来最美的风景不在山外头
[03:46.20]就在这屋檐下灯火相依的眼眸
[03:51.24]雨声慢慢停
[03:55.20]风铃轻轻响
[03:59.18]吃颗甜栗子
[04:04.86]钻进被窝里</pre>
</figure>
### 6. 外婆的绿搪瓷碗 — Grandma's Green Enamel Bowl
*Urban folk* · 679 characters · 252 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-6" src="https://content.hoelee.com/file/hoelee/music/外婆的绿搪瓷碗-Mr-Hoelee.mp3"></audio>
<pre id="lrc-6" hidden>[00:14.28]外婆家的院子有一棵老槐树
[00:16.84]夏天的时候知了叫得特别响
[00:19.78]我坐在小板凳上剥着毛豆壳
[00:23.26]阳光透过树叶掉在我的膝盖上
[00:27.60]厨房里有一只绿色的搪瓷碗
[00:31.56]碗边磕掉了一块黑色的瓷儿
[00:33.64]外婆总用它给我盛满凉白开
[00:37.44]或者装满刚切好的沙瓤大西瓜
[00:42.04]收音机里放着听不懂的老歌
[00:44.82]蒲扇一下一下摇走夏天的热
[00:47.36]那时候觉得下午总是很长很长
[00:51.14]长到一觉睡醒太阳还没落山坡
[00:56.14]外婆你慢点走等等我的小短腿
[01:00.04]菜市场的小路石板有些滑
[01:03.02]我帮你提着两颗绿油油的青菜
[01:06.52]你给我买一块五毛钱的橘子冰
[01:11.60]绿搪瓷碗里装过多少顿晚饭
[01:13.70]一碗西红柿炒蛋就能吃得很香
[01:17.12]墙头的老花猫眯着眼睛打哈欠
[01:20.30]日子就像井水一样安静又清凉
[01:41.32]外婆的针线盒是个铁皮饼干桶
[01:44.84]里面装着五颜六色的小纽扣
[01:48.02]我最喜欢把它们倒在竹席上
[01:51.16]一颗一颗排成弯弯的小火车
[01:54.84]傍晚的时候胡同里飘来饭菜香
[01:58.34]外婆站在门口扯着嗓子喊我回家
[02:01.32]我拍拍屁股上的土跑进院门
[02:04.94]先喝一口搪瓷碗里的绿豆汤
[02:08.30]收音机里放着听不懂的老歌
[02:12.48]蒲扇一下一下摇走夏天的热
[02:14.78]那时候觉得下午总是很长很长
[02:19.10]长到一觉睡醒太阳还没落山坡
[02:23.56]外婆你慢点走等等我的小短腿
[02:27.46]菜市场的小路石板有些滑
[02:29.98]我帮你提着两颗绿油油的青菜
[02:33.78]你给我买一块五毛钱的橘子冰
[02:38.80]绿搪瓷碗里装过多少顿晚饭
[02:41.14]一碗西红柿炒蛋就能吃得很香
[02:44.58]墙头的老花猫眯着眼睛打哈欠
[02:47.72]日子就像井水一样安静又清凉
[02:55.02]后来老平房画上了拆字圆圈
[02:58.28]我们搬进了高高的水泥楼房
[03:01.50]外婆把那只绿搪瓷碗包在布里
[03:04.78]说这是陪我长大的老伙计不能忘
[03:25.96]外婆你别变老等等我再长高一点
[03:29.00]换我牵着你的手去公园晒太阳
[03:32.28]我给你剥最甜的那颗红富士苹果
[03:36.02]给你讲学校里发生的稀奇事儿
[03:40.74]绿搪瓷碗还放在橱柜最里边
[03:43.08]盛满了老院子回不去的夏天
[03:45.80]只要外婆还在身边摸摸我的头
[03:49.98]哪里都是最安心的那个小院
[03:56.66]木吉他轻轻弹
[04:00.08]蒲扇慢慢摇
[04:03.90]绿搪瓷碗里的夏天
[04:05.82]我一直记着呢</pre>
</figure>
### 7. 弄堂口的竹蜻蜓 — The Bamboo Dragonfly at the Alley Mouth
*Urban folk* · 692 characters · 250 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-7" src="https://content.hoelee.com/file/hoelee/music/弄堂口的竹蜻蜓-Mr-Hoelee.mp3"></audio>
<pre id="lrc-7" hidden>[00:11.58]弄堂口停着一辆蓝色的大卡车
[00:14.78]工人们把大衣柜抬下木楼梯
[00:17.73]我坐在红色的小塑料板凳上
[00:20.69]手里捏着你昨天给我的竹蜻蜓
[00:23.89]白衬衫的晾衣杆还在头顶晃
[00:26.85]卖冰棍的自行车叮铃铃路过
[00:29.80]你抱着一只装满弹珠的玻璃罐
[00:31.00]站在太阳底下不知道说什么
[00:35.58]平时这个时候我们早就在捉迷藏
[00:41.18]躲在煤球炉子后面捂着嘴偷笑
[00:46.90]今天大人们忙着打包旧报纸
[00:50.52]连巷子里的风都变得静悄悄
[00:56.16]竹蜻蜓飞呀飞上二楼的晒台
[00:58.48]转着圈儿落进长满青苔的花盆
[01:01.12]你说新家在很远的江北新区
[01:03.88]要坐十几站公交车才能到那儿
[01:06.70]我把最漂亮的那张贴纸贴在你手背
[01:11.06]你把竹蜻蜓塞进我的小口袋
[01:12.58]大人说以后放假还能再见面
[01:15.16]可我知道弄堂口只剩我一个人发呆
[01:32.00]我们在水泥地上画过的跳房子
[01:34.96]被昨天的雨水冲淡了一大半
[01:37.62]小卖部门口的摇摇车还在唱歌
[01:41.22]投进一块钱就能去太空转一转
[01:43.42]你妈妈喊你上车系好安全带
[01:45.96]你隔着车窗对我用力挥挥手
[01:49.26]大卡车突突突地冒着白烟开走
[01:51.48]拐过街角再也看不见影踪
[01:56.58]平时这个时候我们早就在捉迷藏
[02:02.18]躲在煤球炉子后面捂着嘴偷笑
[02:07.84]今天大人们忙着打包旧报纸
[02:10.76]连巷子里的风都变得静悄悄
[02:15.48]竹蜻蜓飞呀飞上二楼的晒台
[02:19.36]转着圈儿落进长满青苔的花盆
[02:22.02]你说新家在很远的江北新区
[02:24.82]要坐十几站公交车才能到那儿
[02:27.62]我把最漂亮的那张贴纸贴在你手背
[02:31.00]你把竹蜻蜓塞进我的小口袋
[02:33.40]大人说以后放假还能再见面
[02:36.12]可我知道弄堂口只剩我一个人发呆
[02:41.56]手心一搓竹蜻蜓又飞了起来
[02:47.22]飞过老电线杆飞过麻雀的翅膀
[02:52.94]如果它能飞得像风筝一样高
[02:57.60]能不能替我去看看你的新书房
[03:15.34]竹蜻蜓飞呀飞进傍晚的红云彩
[03:18.58]弄堂里的路灯一盏接一盏亮起来
[03:22.20]妈妈在楼上喊我收衣服吃晚饭
[03:23.70]我把玻璃弹珠装进小书包里边
[03:26.30]也许明天会有新的小朋友搬来
[03:29.18]也许我也会慢慢习惯一个人玩
[03:32.10]但我会一直留着这只竹蜻蜓
[03:36.98]和弄堂口那个晒满太阳的夏天
[03:43.34]大卡车走远了
[03:46.88]竹蜻蜓落下来
[03:48.14]再见啦我的好朋友
[03:52.64]要在新学校天天开心呀</pre>
</figure>
### 8. 三楼窗台的蒲公英 — Dandelion on the Third-Floor Windowsill
*Urban folk* · 693 characters · 247 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-8" src="https://content.hoelee.com/file/hoelee/music/三楼窗台的蒲公英-Mr-Hoelee.mp3"></audio>
<pre id="lrc-8" hidden>[00:14.30]我家住在红砖楼的第三层
[00:16.90]窗台外面的铁栏杆漆成了绿色
[00:20.26]角落里不知什么时候长出一株草
[00:23.76]开出一朵毛茸茸的白色蒲公英
[00:27.12]楼下的修车摊大爷戴着老花镜
[00:30.60]爸爸推着那辆掉漆的二八自行车
[00:34.82]链条转起来哗啦哗啦地响
[00:37.00]车后座绑着我放学坐的小软垫
[00:41.88]作业本上还有两道算术题没写完
[00:45.10]铅笔头上的橡皮擦得圆圆的
[00:47.70]晚风吹过十九路公交车的站牌
[00:51.54]报站的声音听起来有点困了
[00:54.82]三楼窗台的蒲公英呀你要去哪里
[00:58.62]外面的高楼大厦会不会挡住你
[01:01.80]我轻轻吹一口气送你上天去
[01:04.82]替我去看看云朵后面的秘密
[01:08.58]爸爸的自行车铃铛叮铃铃地响
[01:11.88]妈妈在厨房切着土豆丝咔嚓嚓
[01:15.12]城市的傍晚没有森林和大海
[01:18.72]却有千家万户亮起的温暖窗花
[01:35.60]对门的老爷爷喜欢在阳台养信鸽
[01:39.18]每天傍晚哨子一吹鸽群就盘旋
[01:42.62]我用彩笔在日记本上画下翅膀
[01:46.00]涂上橘红色的晚霞和蓝色的天
[01:49.62]楼下卖烤红薯的三轮车停在路边
[01:53.44]甜甜的焦香味一直飘进我房间
[01:56.26]我数着储蓄罐里的硬币叮当响
[01:59.72]想给爸爸妈妈一人买一块尝尝鲜
[02:04.14]作业本上还有两道算术题没写完
[02:07.36]铅笔头上的橡皮擦得圆圆的
[02:09.98]晚风吹过十九路公交车的站牌
[02:13.80]报站的声音听起来有点困了
[02:17.60]三楼窗台的蒲公英呀你要去哪里
[02:21.30]外面的高楼大厦会不会挡住你
[02:24.08]我轻轻吹一口气送你上天去
[02:27.52]替我去看看云朵后面的秘密
[02:30.98]爸爸的自行车铃铛叮铃铃地响
[02:34.16]妈妈在厨房切着土豆丝咔嚓嚓
[02:37.42]城市的傍晚没有森林和大海
[02:41.00]却有千家万户亮起的温暖窗花
[02:45.26]小小的伞兵飘过晾衣架
[02:47.74]落在对面小弟弟的玩具车上
[02:51.14]原来长大就像蒲公英的种子
[02:54.32]总有一天要飞向更远的地方
[03:12.24]三楼窗台的蒲公英呀明年还会开
[03:15.54]我的身高线又在门框上画高了一截
[03:19.54]把小小的心愿折成纸飞机扔出去
[03:22.54]穿过梧桐树梢飞向满天星辉
[03:26.52]爸爸的旧单车停在楼道口休息
[03:29.50]一家人围在桌前喝着热玉米粥
[03:32.10]这平平常常的每一天我都记在心里
[03:36.03]就是我童年最珍贵的童话书
[03:42.84]蒲公英飞走了
[03:45.76]路灯亮起来了
[03:48.28]作业写完啦
[03:51.94]晚安我的小窗台</pre>
</figure>
### 9. 小哪吒借红绫 — Little Nezha Borrows the Red Sash
*Guofeng DJ remix* · 612 characters · 271 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-9" src="https://content.hoelee.com/file/hoelee/music/小哪吒借红绫-Mr-Hoelee.mp3"></audio>
<pre id="lrc-9" hidden>[00:28.98]双丸子头扎起红绸带迎风扬
[00:32.42]小木枪在手心转出万道金光
[00:35.86]莫笑我年纪小个头才到桌角
[00:39.30]一招一式都能把乌云全刺破
[00:42.74]陈塘关的海浪拍打着石城墙
[00:45.28]夜叉在雷雨里张牙舞爪叫嚣
[00:48.62]我踩上滑板当作我的风火轮
[00:53.02]乾坤圈一扔震碎满天狂飙
[00:57.64]琵琶声急促像急雨落玉盘
[00:59.78]战鼓擂响点燃心中的火焰
[01:02.32]看我小小身影冲进风暴眼
[01:05.90]天高海阔任我闯荡立誓言
[01:11.26]借我三尺混天绫呀翻江又倒海
[01:14.40]我是天地之间最飒的小小孩
[01:17.86]电音节拍震得四海龙王抖三抖
[01:22.16]唢呐一响万道霞光为我开
[01:25.82]乾坤圈转出日月星辰的节拍
[01:28.12]小小的拳头也能打破旧安排
[01:31.60]哪怕前方有十万天兵拦住路
[01:34.82]我自仰天大笑踏破凌霄台
[01:53.02]金砖在书包里闪着耀眼的光
[01:56.26]九龙神火罩护住身后的村庄
[01:58.88]谁说小姑娘只能安静绣花朵
[02:03.12]我偏要披上战甲去巡视八荒
[02:06.20]左脚踏碎惊雷右脚踢开巨浪
[02:09.20]把黑夜变成五光十色的游乐场
[02:12.62]眉心一点红砂亮得像小太阳
[02:16.06]全场的灯光都跟着我一起晃
[02:19.58]琵琶声急促像急雨落玉盘
[02:23.78]战鼓擂响点燃心中的火焰
[02:26.36]看我小小身影冲进风暴眼
[02:29.82]天高海阔任我闯荡立誓言
[02:35.26]借我三尺混天绫呀翻江又倒海
[02:38.84]我是天地之间最飒的小小孩
[02:41.86]电音节拍震得四海龙王抖三抖
[02:46.20]唢呐一响万道霞光为我开
[02:49.60]乾坤圈转出日月星辰的节拍
[02:52.18]小小的拳头也能打破旧安排
[02:55.56]哪怕前方有十万天兵拦住路
[02:58.84]我自仰天大笑踏破凌霄台
[03:02.44]风火轮转不停
[03:06.20]红绫舞破天际
[03:09.66]少年志不言弃
[03:13.06]看我一战成名
[03:33.54]借我三尺混天绫呀搅动九重天
[03:36.64]小小女童也有大圣般的威严
[03:40.04]重低音轰鸣唤醒沉睡的山川
[03:44.70]古筝与电音齐奏最燃的诗篇
[03:48.12]乾坤圈转出万里山河新画卷
[03:50.80]所有的烦恼统统震碎成云烟
[03:53.86]今夜把星辰大海都当作舞台
[03:57.30]跟着我的节奏一起嗨翻天
[04:00.68]红绫飞呀飞
[04:02.72]金鼓擂呀擂
[04:03.92]小小小哪吒
[04:05.12]威风震四海</pre>
</figure>
### 10. 画卷小锦鲤 — The Koi That Left the Painting
*Guofeng DJ remix* · 634 characters · 286 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-10" src="https://content.hoelee.com/file/hoelee/music/画卷小锦鲤-Mr-Hoelee.mp3"></audio>
<pre id="lrc-10" hidden>[00:32.42]宣纸画卷在月光下慢慢展开
[00:34.54]水墨渲染出一池碧绿的荷花台
[00:38.12]我穿上一袭红裙从画里跳出来
[00:41.72]摇一摇小尾巴吐出七彩云彩
[00:45.40]千年的古镇亮起霓虹的灯牌
[00:49.66]电子的鼓点敲得石拱桥摇摆
[00:52.44]别看我只是一尾小小红锦鲤
[00:56.10]心里装着跃过龙门的英雄气概
[01:01.64]古筝拨响急促的浪花一朵朵
[01:03.22]合成器的音浪推着我往前冲
[01:06.86]倒计时三二一准备起跳升空
[01:10.24]让整条银河都看见我的红影踪
[01:14.52]我是画卷里跑出来的小锦鲤
[01:20.16]乘着电音的巨浪直上九万里
[01:21.36]左摇右摆躲开所有的暴风雨
[01:24.68]一个鲤鱼打挺跃过龙门去
[01:28.06]把好运气统统洒向人间的街巷
[01:32.12]让每张笑脸都闪着金色的光芒
[01:36.52]鼓点咚咚咚敲响最旺的节拍
[01:38.84]跟着小锦鲤一起摇摆发大财
[01:58.10]琉璃瓦上反射着绚烂的激光
[02:00.20]我提着莲花灯在飞檐上奔跑
[02:03.84]金鳞片在夜色里闪得像钻石
[02:07.32]把沉闷的黑夜点亮成不夜城
[02:11.64]不管是高山险滩还是急流弯
[02:14.48]我都笑着当成蹦蹦床来通关
[02:18.06]一口气游过三千六百道波澜
[02:21.72]头顶的小龙角已经露出一尖尖
[02:26.66]古筝拨响急促的浪花一朵朵
[02:30.00]合成器的音浪推着我往前冲
[02:33.58]倒计时三二一准备起跳升空
[02:35.90]让整条银河都看见我的红影踪
[02:39.62]我是画卷里跑出来的小锦鲤
[02:43.84]乘着电音的巨浪直上九万里
[02:46.38]左摇右摆躲开所有的暴风雨
[02:50.22]一个鲤鱼打挺跃过龙门去
[02:53.74]把好运气统统洒向人间的街巷
[02:57.82]让每张笑脸都闪着金色的光芒
[03:03.26]鼓点咚咚咚敲响最旺的节拍
[03:04.56]跟着小锦鲤一起摇摆发大财
[03:08.06]水花四溅跃云端
[03:11.78]金光万道破重关
[03:17.56]锦鲤赐福好运来
[03:18.82]岁岁年年皆灿烂
[03:39.12]我是画卷里最神气的小锦鲤
[03:43.30]化身金龙在九天星河里嬉戏
[03:46.84]竹笛与电音吹散所有的阴霾
[03:49.88]漫天烟花都在为我热烈喝彩
[03:52.84]把福气和欢喜装满每个口袋
[03:56.38]心想事成花开富贵全都来
[03:59.94]最燃的节奏永远不会停下来
[04:03.56]小小锦鲤带你飞向最亮的未来
[04:08.22]锦鲤跳呀跳
[04:09.44]龙门高呀高
[04:14.04]好运来到家门口
[04:18.38]天天开口笑</pre>
</figure>
### 11. 山海小当家 — Little Boss of the Classic of Mountains and Seas
*Guofeng DJ remix* · 647 characters · 237 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-11" src="https://content.hoelee.com/file/hoelee/music/山海小当家-Mr-Hoelee.mp3"></audio>
<pre id="lrc-11" hidden>[00:16.41]翻开山海经那本厚厚的图鉴
[00:19.85]里面的神兽全都跳到我面前
[00:23.30]小饕餮馋得抱着奶瓶不松手
[00:26.75]九尾狐忙着梳理毛茸茸的尾尖
[00:30.48]我吹响竹哨立下今天的规矩
[00:32.26]排好队伍不许调皮也不许哭泣
[00:35.28]虽然我只是扎着羊角辫的小童
[00:38.76]却是这山海神兽乐园的大统领
[00:42.20]战鼓与电音在昆仑山顶轰鸣
[00:46.12]琵琶弦拨出闪电一般的轨迹
[00:51.12]骑上小麒麟戴上威风的虎头帽
[00:53.30]今夜的山海巡游马上要开启
[00:55.84]我是山海经里最酷的小当家
[00:59.68]带着百兽战队闯荡海角天涯
[01:03.38]重低音一震群山跟着一起晃
[01:06.74]凤凰在头顶铺开五彩的霓裳
[01:10.46]应龙在云端打着节拍呼风唤雨
[01:13.48]白泽在身边为我指点着迷津
[01:16.56]一二三四跟着鼓点大步往前迈
[01:20.36]小小当家要把天地都点燃起来
[01:37.76]毕方鸟点亮了漫山遍野的篝火
[01:41.06]精卫鸟衔来亮晶晶的彩色贝壳
[01:44.74]连打瞌睡的烛龙都睁开了眼睛
[01:47.96]被这动感的电子节拍彻底唤醒
[01:50.72]我拿出糖葫芦分给乖巧的小兽
[01:54.54]谁表现最好就奖励一朵小红花
[01:57.62]古老的神话撞上最潮的电音浪
[02:01.12]整片星空都是我们的游乐场
[02:04.60]战鼓与电音在昆仑山顶轰鸣
[02:08.38]琵琶弦拨出闪电一般的轨迹
[02:11.44]骑上小麒麟戴上威风的虎头帽
[02:15.58]今夜的山海巡游马上要开启
[02:18.12]我是山海经里最酷的小当家
[02:21.56]带着百兽战队闯荡海角天涯
[02:25.94]重低音一震群山跟着一起晃
[02:29.04]凤凰在头顶铺开五彩的霓裳
[02:32.74]应龙在云端打着节拍呼风唤雨
[02:35.76]白泽在身边为我指点着迷津
[02:38.90]一二三四跟着鼓点大步往前迈
[02:42.64]小小当家要把天地都点燃起来
[02:46.92]昆仑山高路不平
[02:50.36]神兽同行显威灵
[02:53.82]唢呐声声震九霄
[02:56.54]山海少年最逍遥
[03:12.96]我是山海经里无敌的小当家
[03:16.44]跨越山海把快乐传遍千万家
[03:20.50]最密的鼓点撞碎所有的孤单
[03:24.20]古筝与合成器奏响狂欢的夜晚
[03:29.28]麒麟踏瑞气九尾摇动满天星
[03:30.48]每一个梦想都在节奏里觉醒
[03:34.40]不管未来还有多少未知的奇遇
[03:37.54]山海小当家永远勇敢冲第一
[03:38.74]小麒麟跑呀跑
[03:39.94]九尾狐笑呀笑
[03:41.14]山海小当家
[03:54.20]明天继续闹</pre>
</figure>
<script>
// yue2-track: sync LRC lyrics with <audio> playback (no dependencies)
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
const raw = document.getElementById(audio.dataset.lrc).textContent;
const lines = raw.split('\n').map((l) => {
const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
}).filter(Boolean);
const box = document.createElement('div');
box.className = 'yue2-box';
lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
audio.parentNode.insertBefore(box, audio.nextSibling);
let cur = -1;
audio.addEventListener('timeupdate', () => {
const now = audio.currentTime;
// last line whose timestamp has passed; -1 before the first line starts
// (findIndex()-1 went negative at t=0 and wrapped to the LAST line)
let i = -1;
for (let k = 0; k < lines.length; k++) { if (lines[k].t <= now) i = k; else break; }
if (i !== cur) {
if (cur >= 0) box.children[cur].classList.remove('on');
cur = i;
if (cur >= 0) {
const p = box.children[cur];
p.classList.add('on');
// centre the active line inside the box. Use rect deltas, NOT offsetTop:
// offsetTop is measured from the nearest *positioned* ancestor, so without
// position:relative on the box it is huge and scrollTop clamps to the bottom.
const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
} else {
box.scrollTop = 0; // back to the top before the first line
}
}
});
});
</script>
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "Why My AI Songs Kept Losing Their Endings: Running YuE2 on a 12 GB GPU"
description: "Twelve Mandarin songs came out exactly 240.000 seconds long with the outros missing. The cause wasn't the lyrics — it was a hard cap, a VRAM ceiling, and a planner that ignores BPM."
pubDate: 2026-10-20
category: ai
blueprint: ai-agent-automation
blueprintLayer: L4-aigc-media-pipelines
stream: creative-lab
tags: ["yue2", "comfyui", "ai-music", "local-ai", "gpu", "vram", "mandarin"]
ogImage: "/og/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
banner: "/banners/yue2-on-a-12gb-gpu-songs-that-lost-their-endings.jpg"
draft: false
dynamicCta: true
---
YuE2 is a 3B open-weights music model that sings Mandarin from a style prompt
and a lyric sheet. I ran it locally on a 12 GB RTX 3060 through ComfyUI —
twelve original songs in one serial queue — and the first batch came back with
**every single file exactly 240.000 seconds long**. That number is not a
coincidence, and it is not good news.
## Every track was exactly 240.000 seconds
Twelve files, twelve identical durations to the millisecond:
```
01_A1_..._糖画小仙.mp3 duration=240.000000
02_A2_..._溪边捉云记.mp3 duration=240.000000
03_A3_..._寒露拾桂子.mp3 duration=240.000000
...
```
I had asked for `max_duration=240` because the style guide I was working from
said a 12-section structure would land around three and a half minutes. It
landed on 240 seconds exactly — which means the model never decided to stop.
It ran out of room.
## A round number is a confession
YuE2 plans a song with an ABC planner: it reads the lyric sheet, decides how
many bars each section gets, and emits a symbolic sketch. The music generator
then sings that sketch and **ends when the sketch ends**. A finished song
therefore comes out *shorter* than the cap you set — it stops on its own.
Here is the tell, and it costs one shell command:
```bash
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 take.mp3)
python -c "print(abs(float('$d') - 240.0) <= 0.005)" # True = hard cut
```
If the duration equals `max_duration` to within a few milliseconds, the render
was truncated. The lyric sheet wanted more time than the cap allowed, and the
last thing being sung at the cutoff point got sliced off mid-phrase.
## Confirming the outros were actually missing
Duration alone tells you *that* it was cut, not *what* was lost. So I pulled
the tail of each take and transcribed it with a local speech model (SenseVoice,
CPU — it stays running while the GPU renders):
```bash
ffmpeg -v error -y -i take.mp3 -ss 232 -t 20 -ar 16000 -ac 1 -c:a pcm_s16le t.wav
curl -s -X POST http://127.0.0.1:20129/v1/audio/transcriptions \
-F "[email protected]" -F "language=zh"
```
(For tracks that had not even reached the final chorus, I widened the slice window back to the final 30 seconds with `-ss 210` to locate the active section when truncation hit.) The results were unambiguous:
| Song | Sung up to | What was missing |
|---|---|---|
| 糖画小仙 | final chorus, last line | the entire `[Outro]` |
| 溪边捉云记 | `[Bridge]` at 215 s | solo + final chorus + outro |
| 寒露拾桂子 | `[Bridge]` at 215 s | final chorus + outro |
| 纸鸢与旧木偶 | final chorus, line 5 | rest of chorus + outro |
So: not a mixing artefact, not a fade-out. The songs were structurally
incomplete.
## Three fixes that did nothing, and one that half-worked
### 1. Raising the BPM — no effect
The obvious reasoning: faster tempo, more syllables per second, more lyrics
fit in the same window. I tested this across the batch: the 117 BPM guofeng
tracks and the 140 BPM DJ remixes had **effectively identical throughput**.
YuE2's duration comes from the ABC planner's bar count, not from the BPM token
in your style prompt. The BPM tag shapes the *feel*; it does not buy you room.
Stop tuning it for duration.
### 2. Re-running with the same seed — silently does nothing
ComfyUI was started with `--cache-classic`, so resubmitting a byte-identical
prompt — same style, same lyrics, same seed — hits the node cache. The job
"completes" in about ten seconds and hands you back the same truncated audio
with a `_00002` filename.
It looks like a fast, successful retry. It is a cache hit on a known-bad take.
### 3. Rolling new seeds — works about 3 times out of 4
The planner's bar allocation depends on the seed, so a different seed really can
produce a shorter, complete song from the same lyrics. This rescued three of my
four clipped tracks:
| Song | Original | New seed | Result |
|---|---|---|---|
| 溪边捉云记 | clipped at 281 s | 20002 | 279.7 s, complete |
| 外婆的绿搪瓷碗 | clipped at 300 s | 20007 | 251.9 s, complete |
| 北溟有小鱼 | clipped at 300 s | 20005, 30005 | clipped again |
## The budget is characters per second — and it isn't constant
I tried to turn this into arithmetic: count the Chinese characters, divide by a
throughput figure, get the duration. The measurements refused to cooperate.
| Song | Characters | Cap | Actual | Fits? |
|---|---|---|---|---|
| 糖画小仙 | 556 | 272 s | 258.7 s | ✅ |
| 纸鸢与旧木偶 | 572 | 300 s | 279.9 s | ✅ |
| 溪边捉云记 | 578 | 281 s | **281.0 s** | ❌ cut |
| 小楼檐下听雨眠 | 580 | 300 s | 256.3 s | ✅ |
| 画卷小锦鲤 | 634 | 300 s | 285.9 s | ✅ |
| 三楼窗台的蒲公英 | 693 | 329 s | 247.4 s | ✅ |
| 弄堂口的竹蜻蜓 | 692 | 300 s | 250.0 s | ✅ |
Read the last two rows against the third. **693 characters finished in 247
seconds, while 578 characters overflowed 281 seconds.** A lyrics-to-duration
formula simply does not exist here — the observed throughput ranges from about
2.0 to 2.8 Chinese characters per second, depending on how the planner chops the
sections.
So characters are useful for a *budget warning*, not for setting a timer.
## The limit that actually binds: VRAM
Once you accept "give it enough room and let it close naturally", the cap wants
to be large. On a 12 GB card it cannot be.
Measured peak VRAM while rendering, sampled every five seconds with
`nvidia-smi` during the run:
| `max_duration` | Peak VRAM |
|---|---|
| 240 s | 10.3 GB |
| 272 s | 11.65 GB |
| 278 s | 11.07 GB |
| 281 s | 12.06 GB |
| 300 s | 12.03 GB |
The card has 12,288 MiB (12 GB) of VRAM. **300 seconds is the practical ceiling** — past that
you are one desktop browser tab away from an out-of-memory abort in the middle
of a six-minute render.
## The fix
Three changes, all cheap:
1. **Set the cap to 300 s and let the planner close the song itself.** Do not
chase a target length; give it headroom and it ends where the music ends.
2. **Archive clipped takes instead of mixing them in.** A take whose duration
equals the cap goes to `未自然结尾\` ("did not end naturally"). The output
folder then contains only complete songs, and re-renders never destroy a
usable take.
3. **Cap the seed roulette at three attempts.** Different seeds help, but a song
whose lyrics genuinely need more than the VRAM ceiling allows will fail
forever. Three strikes and you trim the lyric sheet — don't burn an hour of
GPU pretending the next seed will be the one.
## The song that never fit
One of the twelve never made it. **北溟有小鱼 — 593 characters — failed five
times:**
| Attempt | Parameters | Result |
|---|---|---|
| 1 | seed 10005, cap 287 s | hit 287 s |
| 2 | seed 10005, cap 300 s | hit 300 s |
| 3 | seed 20005, cap 300 s | hit 300 s |
| 4 | seed 30005, cap 300 s | hit 300 s |
| 5 | seed 40005, both instrumental sections removed | hit 300 s |
I even deleted the `[Interlude]` and `[Instrumental Solo]` markers — 12 sections
down to 10, not one sung word changed — and it still overflowed. Its planner
wants more time than a 12 GB card can give. The honest fix is to cut about 40
characters of lyric and re-render, and I would rather tell you that than ship a
twelfth track with a bitten-off ending.
## A trap I hit before any of this
Worth its own warning: **YuE2 will sing the Chinese production notes in your
lyric sheet.**
If you paste lyrics that look like this — which is how most lyric sheets are
written —
```
[Intro]
(重鼓点起,急促琵琶轮指,唢呐破空,合成器铺底)
嘿!哈!山海震!
```
…then that parenthetical line becomes a lyric. The model does not know it is a
stage direction. Strip them before submitting:
```python
clean = "\n".join(l for l in raw.split("\n")
if not re.match(r"^\s*[((].*[))]\s*$", l))
```
Arrangement and dynamics belong in the style prompt. The lyric box gets section
tags and sung words, nothing else.
## What I'd do differently
- **Pre-flight the lyric sheet, not the render.** Count characters first. Over
roughly 600 characters on a 12 GB card, expect trouble; over 550, expect to
trim something.
- **Never set `max_duration` to a target length.** Set it generously above the
estimate and let the planner finish. The cap is a safety rail, not a dial.
- **Treat "duration == cap" as a hard failure in the pipeline**, not something
you notice three days later when a chorus sounds wrong.
- **Re-run with a new seed before touching the lyrics** — it is free and works
most of the time. Then stop at three.
## Eleven tracks
These are the ones that closed naturally, sung by a child voice, in Mandarin,
on a 12 GB card that cost less than a year of cloud music credits. Each player
below has synced lyrics — the timing was recovered by transcription and aligned
against the written lyrics, since the model emits no timestamps.
<style>
.yue2-track{margin:2.2rem 0}
.yue2-track audio{width:100%;max-width:560px}
.yue2-box{position:relative;max-height:250px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6}
.yue2-box p{margin:.18rem 0;opacity:.55}
.yue2-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
</style>
### 1. 糖画小仙 — Sugar-Painting Fairy
*Guofeng electronic* · 556 characters · 259 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-1" src="https://content.hoelee.com/file/hoelee/music/糖画小仙-Mr-Hoelee.mp3"></audio>
<pre id="lrc-1" hidden>[00:16.38]青石板路亮起红灯笼
[00:19.74]我踮起脚尖钻进人海中
[00:24.50]铜锅里的麦芽糖咕嘟咕嘟响
[00:28.20]甜丝丝的风吹红我脸庞
[00:31.22]老爷爷手腕轻轻晃一晃
[00:36.52]金黄的丝线落在石板上
[00:40.22]左一圈是尾巴右一勾是翅膀
[00:43.68]我数着星星等它亮晶晶登场
[00:48.76]竹签儿转呀转出小月亮
[00:52.46]笛声悠悠飘过青瓦墙
[00:55.20]快看那糖画眨了眨眼眶
[00:59.36]悄悄对我说今夜去飞翔
[01:04.04]我是提着灯笼的糖画小仙
[01:07.92]骑着金丝小龙飞过屋檐
[01:11.36]琵琶声哒哒敲打着云烟
[01:15.26]把甜味洒满整条长安街
[01:19.70]蝴蝶在发梢跳起圆圈舞
[01:23.30]小凤凰衔来春天的露珠
[01:27.06]一口咬下月亮的甜脆边角
[01:31.58]连梦里都带着桂花的味道
[01:52.64]小铜勺画出一只小花猫
[01:54.62]胡须翘翘对着我喵喵叫
[01:56.52]旁边的弟弟馋得直跺脚
[01:58.50]我举高竹签偏偏不给瞧
[02:00.72]鼓点像心跳蹦蹦停不了
[02:03.04]石榴裙摆跟着风儿摇
[02:05.20]糖人儿突然拉住我的手
[02:06.64]带我穿过灯火去银河游一游
[02:08.78]竹签儿转呀转出小月亮
[02:12.46]笛声悠悠飘过青瓦墙
[02:15.14]快看那糖画眨了眨眼眶
[02:19.70]悄悄对我说今夜去飞翔
[02:24.12]我是提着灯笼的糖画小仙
[02:28.50]骑着金丝小龙飞过屋檐
[02:31.28]琵琶声哒哒敲打着云烟
[02:35.20]把甜味洒满整条长安街
[02:39.70]蝴蝶在发梢跳起圆圈舞
[02:43.68]小凤凰衔来春天的露珠
[02:47.66]一口咬下月亮的甜脆边角
[02:51.56]连梦里都带着桂花的味道
[02:56.46]不怕夜风凉也不怕路遥远
[03:00.62]童话就藏在一勺甜里边
[03:04.22]只要心里还有亮晶晶的光
[03:08.08]每一朵云彩都是麦芽糖
[03:28.10]我是提着灯笼的糖画小仙
[03:32.00]骑着金丝小龙飞向天边
[03:36.48]古筝声铮铮唤醒了星辰
[03:39.22]万家灯火都为我敞开门
[03:43.42]蝴蝶在发梢跳起圆圈舞
[03:47.26]小凤凰衔来春天的露珠
[03:51.10]一口咬下月亮的甜脆边角
[03:55.52]全世界的开心都装进荷包
[04:01.56]糖画转呀转
[04:02.76]灯火闪呀闪
[04:05.74]小仙女困了
[04:09.72]枕着甜梦晚安</pre>
</figure>
### 2. 溪边捉云记 — Catching Clouds by the Stream
*Guofeng electronic* · 578 characters · 280 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-2" src="https://content.hoelee.com/file/hoelee/music/溪边捉云记-Mr-Hoelee.mp3"></audio>
<pre id="lrc-2" hidden>[00:18.38]阵雨刚刚洗干净小山坡
[00:22.40]荷叶上滚着圆圆的水珠儿
[00:25.18]我卷起裤脚拎着小竹篓
[00:30.10]光着脚丫踩碎溪里的云朵
[00:35.44]小螃蟹吐着泡泡横着走
[00:38.16]红蜻蜓停在芦苇尖上头
[00:42.22]山风吹过竹林沙沙地唱歌
[00:45.88]水底的太阳公公看着我笑呵呵
[00:50.40]竹笛吹开山谷里的薄雾
[00:54.42]水花溅湿我的小布鞋
[00:58.50]伸出手想把白云装进篓
[01:02.16]它却化作浪花溜出指缝间
[01:06.12]空山新雨后呀溪水哗啦啦
[01:10.32]我是山里最忙的小娃娃
[01:15.36]东边追小鱼西边数落花
[01:18.48]把彩虹借来当我的发卡
[01:24.20]古筝弹得水波一圈一圈晃
[01:26.62]松果掉进水里咚的一声响
[01:30.80]夕阳催我回家吃饭我不听
[01:34.76]还要跟小月亮捉迷藏到天明
[01:55.38]青苔石上坐着小青蛙
[01:59.12]鼓着肚皮像个小喇叭
[02:03.28]我折了一只歪歪的纸船儿
[02:07.28]载着三片花瓣顺水去天涯
[02:11.74]山那头住着神仙老爷爷吗
[02:15.82]会不会收到我寄去的悄悄话
[02:20.92]泥点子画在我鼻尖像小花猫
[02:24.48]脆生生的笑声惊飞了白鹭鸟
[02:28.10]竹笛吹开山谷里的薄雾
[02:32.10]水花溅湿我的小布鞋
[02:36.16]伸出手想把白云装进篓
[02:39.76]它却化作浪花溜出指缝间
[02:44.34]空山新雨后呀溪水哗啦啦
[02:48.54]我是山里最忙的小娃娃
[02:52.98]东边追小鱼西边数落花
[02:56.18]把彩虹借来当我的发卡
[03:01.84]古筝弹得水波一圈一圈晃
[03:04.30]松果掉进水里咚的一声响
[03:08.44]夕阳催我回家吃饭我不听
[03:12.38]还要跟小月亮捉迷藏到天明
[03:16.94]小溪水慢些流等等我脚步
[03:20.68]竹篓里装满了夏天的礼物
[03:24.94]一颗鹅卵石加上半朵云
[03:28.66]全都藏进我小小的秘密木盒里
[03:51.98]空山新雨后呀溪水哗啦啦
[03:55.62]我是山里最快乐的小娃娃
[03:59.48]东边追小鱼西边数落花
[04:03.24]把星光借来当我的发卡
[04:07.66]琵琶声伴着晚风轻轻唱
[04:12.26]炊烟升起在温暖的村庄
[04:16.52]娘亲在村口喊着我的小名
[04:19.68]我提着满篓月光蹦蹦跳跳回庭院
[04:24.42]溪水哗啦啦
[04:25.62]蛙声呱呱呱
[04:27.66]云朵睡着啦
[04:33.04]明天再来捉它</pre>
</figure>
### 3. 寒露拾桂子 — Picking Osmanthus at Cold Dew
*Guofeng electronic* · 578 characters · 290 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-3" src="https://content.hoelee.com/file/hoelee/music/寒露拾桂子-Mr-Hoelee.mp3"></audio>
<pre id="lrc-3" hidden>[00:19.03]日历翻到寒露的那一页
[00:23.33]院子里的秋风有了一点凉
[00:28.06]我披上娘亲缝的小夹袄
[00:32.36]提着小竹篮跑到桂花树旁
[00:37.82]金黄的小花朵像雨点落下
[00:40.62]沾满我的刘海和麻花辫上
[00:45.72]大黄狗围着我的裙角打转
[00:49.40]一不小心踩响了满地金黄
[00:54.36]远处的笛声吹落了晚霞
[00:57.84]大雁排成人字飞向南方
[01:01.70]我把桂花一朵一朵收好
[01:06.66]留住整个秋天最甜的时光
[01:10.48]寒露的风儿吹呀吹散了云裳
[01:14.58]小小姑娘在桂花雨里忙
[01:19.70]一层桂花铺上一层白砂糖
[01:24.98]封进玻璃罐里等冬天品尝
[01:27.86]琵琶和古筝在屋檐下对唱
[01:31.94]红枫叶寄去远方的思念长
[01:37.16]哪怕霜花染白了小窗台
[01:41.46]我的小竹篮里永远有春光
[02:05.32]石桌上摆着柿子红彤彤
[02:07.64]我偷偷尝了一口甜进心中
[02:13.48]蚂蚁搬着花瓣排成了长队
[02:17.02]我蹲在地上看了一刻钟
[02:21.64]竹篮装满了一半金桂香
[02:25.10]另一半装进落叶和夕阳
[02:29.14]摇摇晃晃走过九曲小回廊
[02:33.92]影子被拉得又细又长
[02:39.10]远处的笛声吹落了晚霞
[02:42.58]大雁排成人字飞向南方
[02:47.02]我把桂花一朵一朵收好
[02:51.28]留住整个秋天最甜的时光
[02:55.28]寒露的风儿吹呀吹散了云裳
[02:59.24]小小姑娘在桂花雨里忙
[03:04.34]一层桂花铺上一层白砂糖
[03:09.70]封进玻璃罐里等冬天品尝
[03:12.56]琵琶和古筝在屋檐下对唱
[03:16.64]红枫叶寄去远方的思念长
[03:21.88]哪怕霜花染白了小窗台
[03:26.18]我的小竹篮里永远有春光
[03:32.18]节气一轮一轮慢慢走
[03:35.70]小树苗陪着我一起长高
[03:40.12]等到来年春暖花再开
[03:43.62]再把这罐秋天的甜味打开
[03:50.02]寒露的月光洒呀洒满小院墙
[03:53.86]小小姑娘在桂花梦里晃
[03:59.00]一层星光铺上一层桂花糖
[04:03.24]送给夜空中最亮的那道光
[04:08.38]弦乐与古筝在云端上回响
[04:11.80]千山万水都闻到这抹香
[04:16.52]不管岁月走得有多匆忙
[04:20.74]童年的桂花永远不会遗忘
[04:25.70]寒露凉呀桂花香
[04:29.60]小小竹篮装月光
[04:34.28]摇啊摇到外婆桥
[04:38.26]秋天的甜梦真漫长</pre>
</figure>
### 4. 纸鸢与旧木偶 — The Kite and the Old Puppet
*Guofeng ballad* · 572 characters · 280 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-4" src="https://content.hoelee.com/file/hoelee/music/纸鸢与旧木偶-Mr-Hoelee.mp3"></audio>
<pre id="lrc-4" hidden>[00:14.64]老戏台的青苔爬上了木桩
[00:20.50]雨水顺着褪色的画梁滴落
[00:27.38]我抱膝坐在角落的竹筐旁
[00:31.38]看见你静静躺在灰尘中央
[00:34.42]半边脸颊还留着红胭脂妆
[00:41.04]身上的彩衣已经有些微凉
[00:44.72]是谁把丝线遗忘在旧时光
[00:47.64]让你独自守着空荡的剧场
[00:54.06]我用衣袖擦去你眼角的灰
[00:57.56]解下发间的红绳系上你手背
[01:00.56]风吹过空巷像一声轻叹
[01:03.68]我轻轻摇晃你便抬起眉眼
[01:09.16]小木偶呀你别怕夜黑风高
[01:12.98]我提着小灯笼陪你跳一曲歌谣
[01:16.52]指尖的红丝线一牵一绕
[01:20.26]旧戏台又亮起从前的热闹
[01:23.38]唱一段悲欢呀叹一段离合
[01:26.36]清亮的童音穿过雨幕飘摇
[01:29.78]哪怕看戏的人早就散了场
[01:33.00]还有我为你鼓掌到天亮
[01:53.12]你曾看过多少繁华的灯火
[01:58.92]听过多少台下热烈的喝彩
[02:05.72]如今只剩燕子在梁上筑窝
[02:09.72]和一只淋湿翅膀的纸鸢作伴
[02:13.06]我把纸鸢修好放在你身旁
[02:19.28]给你们讲山外小镇的故事长
[02:23.40]木偶的嘴角仿佛弯了一弯
[02:26.00]在摇曳烛光里不再孤单
[02:32.48]我用衣袖擦去你眼角的灰
[02:35.86]解下发间的红绳系上你手背
[02:38.88]风吹过空巷像一声轻叹
[02:42.04]我轻轻摇晃你便抬起眉眼
[02:47.52]小木偶呀你别怕夜黑风高
[02:51.32]我提着小灯笼陪你跳一曲歌谣
[02:55.38]指尖的红丝线一牵一绕
[02:59.02]旧戏台又亮起从前的热闹
[03:01.42]唱一段悲欢呀叹一段离合
[03:04.74]清亮的童音穿过雨幕飘摇
[03:08.08]哪怕看戏的人早就散了场
[03:11.30]还有我为你鼓掌到天亮
[03:18.40]啦——啊——
[03:25.92]丝线不断情意不散
[03:31.44]啦——啊——
[03:38.24]木偶有心岁月生香
[03:44.20]小木偶呀你看那雨过天晴
[03:47.90]漫天的星河落进古老的屋檐
[03:51.48]指尖的红丝线越飞越高
[03:54.74]带着纸鸢飞向云端的怀抱
[03:58.06]唱一段春秋呀绘一幅人间
[04:01.34]纯净的歌声回荡在天地之间
[04:04.70]就算我有一天也会长大走远
[04:08.06]这曲童年的戏永远留在心田
[04:14.80]红绳系手腕
[04:19.32]木偶入梦乡
[04:23.12]旧戏台安静
[04:26.30]月光白如霜</pre>
</figure>
### 5. 小楼檐下听雨眠 — Listening to Rain Under the Eaves
*Guofeng ballad* · 580 characters · 256 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-5" src="https://content.hoelee.com/file/hoelee/music/小楼檐下听雨眠-Mr-Hoelee.mp3"></audio>
<pre id="lrc-5" hidden>[00:16.16]滴答滴答雨水顺着瓦当落
[00:19.40]在青石天井敲出小小的水涡
[00:23.86]我搬来小木凳坐在门槛旁
[00:26.36]双手托着下巴数着雨点儿多
[00:30.74]院里的芭蕉叶被洗得碧绿
[00:33.36]小花猫蜷在竹椅上打着呼噜
[00:37.16]阿姐出门时说天黑前就回
[00:40.30]怎么巷口还没看见那把油纸伞
[00:43.88]风儿吹动檐下的铜风铃
[00:47.46]叮当声混着细雨轻又轻
[00:50.90]我折了一只白色的纸青蛙
[00:53.52]请它去巷口帮我探一探口信
[00:59.00]山外小楼夜听雨呀雨绵绵
[01:03.14]小姑娘守着灯火不肯眠
[01:06.58]数过一滴雨又数过一片云
[01:10.22]等那熟悉的脚步声响在门前
[01:14.20]古筝弹碎了江南的烟雨天
[01:17.48]笛声绕着青砖黛瓦转呀转
[01:20.82]阿姐你可别在路上贪看风景
[01:24.06]家里的桂花糕还温在灶台边
[01:42.88]窗纸上映着摇晃的竹影儿
[01:45.54]奶奶在灯下穿针又引线
[01:48.70]我趴在窗台哈出一团白气
[01:51.68]用手指画出一个圆圆的笑脸
[01:55.94]水缸里的红金鱼摆着尾巴
[01:59.18]好像也在听屋檐滴水的节拍
[02:02.32]雨声越下越密像一张小网
[02:05.76]把整个夜晚都轻轻网在里边
[02:10.98]风儿吹动檐下的铜风铃
[02:13.24]叮当声混着细雨轻又轻
[02:16.66]我折了一只白色的纸青蛙
[02:20.56]请它去巷口帮我探一探口信
[02:25.58]山外小楼夜听雨呀雨绵绵
[02:28.96]小姑娘守着灯火不肯眠
[02:32.42]数过一滴雨又数过一片云
[02:36.34]等那熟悉的脚步声响在门前
[02:40.36]古筝弹碎了江南的烟雨天
[02:43.30]笛声绕着青砖黛瓦转呀转
[02:45.80]阿姐你可别在路上贪看风景
[02:49.90]家里的桂花糕还温在灶台边
[02:53.96]啦——呀——
[02:55.64]雨打芭蕉叶儿垂
[03:01.04]啦——呀——
[03:04.48]油纸伞儿快快归
[03:21.66]山外小楼夜听雨呀雨渐停
[03:24.98]巷口忽然亮起一盏暖黄灯
[03:28.86]油纸伞转开晶莹的小水花
[03:32.58]阿姐笑着唤我熟悉的小名
[03:37.40]二胡与弦乐拉出团圆的暖意
[03:38.76]怀里揣着刚买的热糖炒栗子
[03:42.16]原来最美的风景不在山外头
[03:46.20]就在这屋檐下灯火相依的眼眸
[03:51.24]雨声慢慢停
[03:55.20]风铃轻轻响
[03:59.18]吃颗甜栗子
[04:04.86]钻进被窝里</pre>
</figure>
### 6. 外婆的绿搪瓷碗 — Grandma's Green Enamel Bowl
*Urban folk* · 679 characters · 252 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-6" src="https://content.hoelee.com/file/hoelee/music/外婆的绿搪瓷碗-Mr-Hoelee.mp3"></audio>
<pre id="lrc-6" hidden>[00:14.28]外婆家的院子有一棵老槐树
[00:16.84]夏天的时候知了叫得特别响
[00:19.78]我坐在小板凳上剥着毛豆壳
[00:23.26]阳光透过树叶掉在我的膝盖上
[00:27.60]厨房里有一只绿色的搪瓷碗
[00:31.56]碗边磕掉了一块黑色的瓷儿
[00:33.64]外婆总用它给我盛满凉白开
[00:37.44]或者装满刚切好的沙瓤大西瓜
[00:42.04]收音机里放着听不懂的老歌
[00:44.82]蒲扇一下一下摇走夏天的热
[00:47.36]那时候觉得下午总是很长很长
[00:51.14]长到一觉睡醒太阳还没落山坡
[00:56.14]外婆你慢点走等等我的小短腿
[01:00.04]菜市场的小路石板有些滑
[01:03.02]我帮你提着两颗绿油油的青菜
[01:06.52]你给我买一块五毛钱的橘子冰
[01:11.60]绿搪瓷碗里装过多少顿晚饭
[01:13.70]一碗西红柿炒蛋就能吃得很香
[01:17.12]墙头的老花猫眯着眼睛打哈欠
[01:20.30]日子就像井水一样安静又清凉
[01:41.32]外婆的针线盒是个铁皮饼干桶
[01:44.84]里面装着五颜六色的小纽扣
[01:48.02]我最喜欢把它们倒在竹席上
[01:51.16]一颗一颗排成弯弯的小火车
[01:54.84]傍晚的时候胡同里飘来饭菜香
[01:58.34]外婆站在门口扯着嗓子喊我回家
[02:01.32]我拍拍屁股上的土跑进院门
[02:04.94]先喝一口搪瓷碗里的绿豆汤
[02:08.30]收音机里放着听不懂的老歌
[02:12.48]蒲扇一下一下摇走夏天的热
[02:14.78]那时候觉得下午总是很长很长
[02:19.10]长到一觉睡醒太阳还没落山坡
[02:23.56]外婆你慢点走等等我的小短腿
[02:27.46]菜市场的小路石板有些滑
[02:29.98]我帮你提着两颗绿油油的青菜
[02:33.78]你给我买一块五毛钱的橘子冰
[02:38.80]绿搪瓷碗里装过多少顿晚饭
[02:41.14]一碗西红柿炒蛋就能吃得很香
[02:44.58]墙头的老花猫眯着眼睛打哈欠
[02:47.72]日子就像井水一样安静又清凉
[02:55.02]后来老平房画上了拆字圆圈
[02:58.28]我们搬进了高高的水泥楼房
[03:01.50]外婆把那只绿搪瓷碗包在布里
[03:04.78]说这是陪我长大的老伙计不能忘
[03:25.96]外婆你别变老等等我再长高一点
[03:29.00]换我牵着你的手去公园晒太阳
[03:32.28]我给你剥最甜的那颗红富士苹果
[03:36.02]给你讲学校里发生的稀奇事儿
[03:40.74]绿搪瓷碗还放在橱柜最里边
[03:43.08]盛满了老院子回不去的夏天
[03:45.80]只要外婆还在身边摸摸我的头
[03:49.98]哪里都是最安心的那个小院
[03:56.66]木吉他轻轻弹
[04:00.08]蒲扇慢慢摇
[04:03.90]绿搪瓷碗里的夏天
[04:05.82]我一直记着呢</pre>
</figure>
### 7. 弄堂口的竹蜻蜓 — The Bamboo Dragonfly at the Alley Mouth
*Urban folk* · 692 characters · 250 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-7" src="https://content.hoelee.com/file/hoelee/music/弄堂口的竹蜻蜓-Mr-Hoelee.mp3"></audio>
<pre id="lrc-7" hidden>[00:11.58]弄堂口停着一辆蓝色的大卡车
[00:14.78]工人们把大衣柜抬下木楼梯
[00:17.73]我坐在红色的小塑料板凳上
[00:20.69]手里捏着你昨天给我的竹蜻蜓
[00:23.89]白衬衫的晾衣杆还在头顶晃
[00:26.85]卖冰棍的自行车叮铃铃路过
[00:29.80]你抱着一只装满弹珠的玻璃罐
[00:31.00]站在太阳底下不知道说什么
[00:35.58]平时这个时候我们早就在捉迷藏
[00:41.18]躲在煤球炉子后面捂着嘴偷笑
[00:46.90]今天大人们忙着打包旧报纸
[00:50.52]连巷子里的风都变得静悄悄
[00:56.16]竹蜻蜓飞呀飞上二楼的晒台
[00:58.48]转着圈儿落进长满青苔的花盆
[01:01.12]你说新家在很远的江北新区
[01:03.88]要坐十几站公交车才能到那儿
[01:06.70]我把最漂亮的那张贴纸贴在你手背
[01:11.06]你把竹蜻蜓塞进我的小口袋
[01:12.58]大人说以后放假还能再见面
[01:15.16]可我知道弄堂口只剩我一个人发呆
[01:32.00]我们在水泥地上画过的跳房子
[01:34.96]被昨天的雨水冲淡了一大半
[01:37.62]小卖部门口的摇摇车还在唱歌
[01:41.22]投进一块钱就能去太空转一转
[01:43.42]你妈妈喊你上车系好安全带
[01:45.96]你隔着车窗对我用力挥挥手
[01:49.26]大卡车突突突地冒着白烟开走
[01:51.48]拐过街角再也看不见影踪
[01:56.58]平时这个时候我们早就在捉迷藏
[02:02.18]躲在煤球炉子后面捂着嘴偷笑
[02:07.84]今天大人们忙着打包旧报纸
[02:10.76]连巷子里的风都变得静悄悄
[02:15.48]竹蜻蜓飞呀飞上二楼的晒台
[02:19.36]转着圈儿落进长满青苔的花盆
[02:22.02]你说新家在很远的江北新区
[02:24.82]要坐十几站公交车才能到那儿
[02:27.62]我把最漂亮的那张贴纸贴在你手背
[02:31.00]你把竹蜻蜓塞进我的小口袋
[02:33.40]大人说以后放假还能再见面
[02:36.12]可我知道弄堂口只剩我一个人发呆
[02:41.56]手心一搓竹蜻蜓又飞了起来
[02:47.22]飞过老电线杆飞过麻雀的翅膀
[02:52.94]如果它能飞得像风筝一样高
[02:57.60]能不能替我去看看你的新书房
[03:15.34]竹蜻蜓飞呀飞进傍晚的红云彩
[03:18.58]弄堂里的路灯一盏接一盏亮起来
[03:22.20]妈妈在楼上喊我收衣服吃晚饭
[03:23.70]我把玻璃弹珠装进小书包里边
[03:26.30]也许明天会有新的小朋友搬来
[03:29.18]也许我也会慢慢习惯一个人玩
[03:32.10]但我会一直留着这只竹蜻蜓
[03:36.98]和弄堂口那个晒满太阳的夏天
[03:43.34]大卡车走远了
[03:46.88]竹蜻蜓落下来
[03:48.14]再见啦我的好朋友
[03:52.64]要在新学校天天开心呀</pre>
</figure>
### 8. 三楼窗台的蒲公英 — Dandelion on the Third-Floor Windowsill
*Urban folk* · 693 characters · 247 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-8" src="https://content.hoelee.com/file/hoelee/music/三楼窗台的蒲公英-Mr-Hoelee.mp3"></audio>
<pre id="lrc-8" hidden>[00:14.30]我家住在红砖楼的第三层
[00:16.90]窗台外面的铁栏杆漆成了绿色
[00:20.26]角落里不知什么时候长出一株草
[00:23.76]开出一朵毛茸茸的白色蒲公英
[00:27.12]楼下的修车摊大爷戴着老花镜
[00:30.60]爸爸推着那辆掉漆的二八自行车
[00:34.82]链条转起来哗啦哗啦地响
[00:37.00]车后座绑着我放学坐的小软垫
[00:41.88]作业本上还有两道算术题没写完
[00:45.10]铅笔头上的橡皮擦得圆圆的
[00:47.70]晚风吹过十九路公交车的站牌
[00:51.54]报站的声音听起来有点困了
[00:54.82]三楼窗台的蒲公英呀你要去哪里
[00:58.62]外面的高楼大厦会不会挡住你
[01:01.80]我轻轻吹一口气送你上天去
[01:04.82]替我去看看云朵后面的秘密
[01:08.58]爸爸的自行车铃铛叮铃铃地响
[01:11.88]妈妈在厨房切着土豆丝咔嚓嚓
[01:15.12]城市的傍晚没有森林和大海
[01:18.72]却有千家万户亮起的温暖窗花
[01:35.60]对门的老爷爷喜欢在阳台养信鸽
[01:39.18]每天傍晚哨子一吹鸽群就盘旋
[01:42.62]我用彩笔在日记本上画下翅膀
[01:46.00]涂上橘红色的晚霞和蓝色的天
[01:49.62]楼下卖烤红薯的三轮车停在路边
[01:53.44]甜甜的焦香味一直飘进我房间
[01:56.26]我数着储蓄罐里的硬币叮当响
[01:59.72]想给爸爸妈妈一人买一块尝尝鲜
[02:04.14]作业本上还有两道算术题没写完
[02:07.36]铅笔头上的橡皮擦得圆圆的
[02:09.98]晚风吹过十九路公交车的站牌
[02:13.80]报站的声音听起来有点困了
[02:17.60]三楼窗台的蒲公英呀你要去哪里
[02:21.30]外面的高楼大厦会不会挡住你
[02:24.08]我轻轻吹一口气送你上天去
[02:27.52]替我去看看云朵后面的秘密
[02:30.98]爸爸的自行车铃铛叮铃铃地响
[02:34.16]妈妈在厨房切着土豆丝咔嚓嚓
[02:37.42]城市的傍晚没有森林和大海
[02:41.00]却有千家万户亮起的温暖窗花
[02:45.26]小小的伞兵飘过晾衣架
[02:47.74]落在对面小弟弟的玩具车上
[02:51.14]原来长大就像蒲公英的种子
[02:54.32]总有一天要飞向更远的地方
[03:12.24]三楼窗台的蒲公英呀明年还会开
[03:15.54]我的身高线又在门框上画高了一截
[03:19.54]把小小的心愿折成纸飞机扔出去
[03:22.54]穿过梧桐树梢飞向满天星辉
[03:26.52]爸爸的旧单车停在楼道口休息
[03:29.50]一家人围在桌前喝着热玉米粥
[03:32.10]这平平常常的每一天我都记在心里
[03:36.03]就是我童年最珍贵的童话书
[03:42.84]蒲公英飞走了
[03:45.76]路灯亮起来了
[03:48.28]作业写完啦
[03:51.94]晚安我的小窗台</pre>
</figure>
### 9. 小哪吒借红绫 — Little Nezha Borrows the Red Sash
*Guofeng DJ remix* · 612 characters · 271 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-9" src="https://content.hoelee.com/file/hoelee/music/小哪吒借红绫-Mr-Hoelee.mp3"></audio>
<pre id="lrc-9" hidden>[00:28.98]双丸子头扎起红绸带迎风扬
[00:32.42]小木枪在手心转出万道金光
[00:35.86]莫笑我年纪小个头才到桌角
[00:39.30]一招一式都能把乌云全刺破
[00:42.74]陈塘关的海浪拍打着石城墙
[00:45.28]夜叉在雷雨里张牙舞爪叫嚣
[00:48.62]我踩上滑板当作我的风火轮
[00:53.02]乾坤圈一扔震碎满天狂飙
[00:57.64]琵琶声急促像急雨落玉盘
[00:59.78]战鼓擂响点燃心中的火焰
[01:02.32]看我小小身影冲进风暴眼
[01:05.90]天高海阔任我闯荡立誓言
[01:11.26]借我三尺混天绫呀翻江又倒海
[01:14.40]我是天地之间最飒的小小孩
[01:17.86]电音节拍震得四海龙王抖三抖
[01:22.16]唢呐一响万道霞光为我开
[01:25.82]乾坤圈转出日月星辰的节拍
[01:28.12]小小的拳头也能打破旧安排
[01:31.60]哪怕前方有十万天兵拦住路
[01:34.82]我自仰天大笑踏破凌霄台
[01:53.02]金砖在书包里闪着耀眼的光
[01:56.26]九龙神火罩护住身后的村庄
[01:58.88]谁说小姑娘只能安静绣花朵
[02:03.12]我偏要披上战甲去巡视八荒
[02:06.20]左脚踏碎惊雷右脚踢开巨浪
[02:09.20]把黑夜变成五光十色的游乐场
[02:12.62]眉心一点红砂亮得像小太阳
[02:16.06]全场的灯光都跟着我一起晃
[02:19.58]琵琶声急促像急雨落玉盘
[02:23.78]战鼓擂响点燃心中的火焰
[02:26.36]看我小小身影冲进风暴眼
[02:29.82]天高海阔任我闯荡立誓言
[02:35.26]借我三尺混天绫呀翻江又倒海
[02:38.84]我是天地之间最飒的小小孩
[02:41.86]电音节拍震得四海龙王抖三抖
[02:46.20]唢呐一响万道霞光为我开
[02:49.60]乾坤圈转出日月星辰的节拍
[02:52.18]小小的拳头也能打破旧安排
[02:55.56]哪怕前方有十万天兵拦住路
[02:58.84]我自仰天大笑踏破凌霄台
[03:02.44]风火轮转不停
[03:06.20]红绫舞破天际
[03:09.66]少年志不言弃
[03:13.06]看我一战成名
[03:33.54]借我三尺混天绫呀搅动九重天
[03:36.64]小小女童也有大圣般的威严
[03:40.04]重低音轰鸣唤醒沉睡的山川
[03:44.70]古筝与电音齐奏最燃的诗篇
[03:48.12]乾坤圈转出万里山河新画卷
[03:50.80]所有的烦恼统统震碎成云烟
[03:53.86]今夜把星辰大海都当作舞台
[03:57.30]跟着我的节奏一起嗨翻天
[04:00.68]红绫飞呀飞
[04:02.72]金鼓擂呀擂
[04:03.92]小小小哪吒
[04:05.12]威风震四海</pre>
</figure>
### 10. 画卷小锦鲤 — The Koi That Left the Painting
*Guofeng DJ remix* · 634 characters · 286 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-10" src="https://content.hoelee.com/file/hoelee/music/画卷小锦鲤-Mr-Hoelee.mp3"></audio>
<pre id="lrc-10" hidden>[00:32.42]宣纸画卷在月光下慢慢展开
[00:34.54]水墨渲染出一池碧绿的荷花台
[00:38.12]我穿上一袭红裙从画里跳出来
[00:41.72]摇一摇小尾巴吐出七彩云彩
[00:45.40]千年的古镇亮起霓虹的灯牌
[00:49.66]电子的鼓点敲得石拱桥摇摆
[00:52.44]别看我只是一尾小小红锦鲤
[00:56.10]心里装着跃过龙门的英雄气概
[01:01.64]古筝拨响急促的浪花一朵朵
[01:03.22]合成器的音浪推着我往前冲
[01:06.86]倒计时三二一准备起跳升空
[01:10.24]让整条银河都看见我的红影踪
[01:14.52]我是画卷里跑出来的小锦鲤
[01:20.16]乘着电音的巨浪直上九万里
[01:21.36]左摇右摆躲开所有的暴风雨
[01:24.68]一个鲤鱼打挺跃过龙门去
[01:28.06]把好运气统统洒向人间的街巷
[01:32.12]让每张笑脸都闪着金色的光芒
[01:36.52]鼓点咚咚咚敲响最旺的节拍
[01:38.84]跟着小锦鲤一起摇摆发大财
[01:58.10]琉璃瓦上反射着绚烂的激光
[02:00.20]我提着莲花灯在飞檐上奔跑
[02:03.84]金鳞片在夜色里闪得像钻石
[02:07.32]把沉闷的黑夜点亮成不夜城
[02:11.64]不管是高山险滩还是急流弯
[02:14.48]我都笑着当成蹦蹦床来通关
[02:18.06]一口气游过三千六百道波澜
[02:21.72]头顶的小龙角已经露出一尖尖
[02:26.66]古筝拨响急促的浪花一朵朵
[02:30.00]合成器的音浪推着我往前冲
[02:33.58]倒计时三二一准备起跳升空
[02:35.90]让整条银河都看见我的红影踪
[02:39.62]我是画卷里跑出来的小锦鲤
[02:43.84]乘着电音的巨浪直上九万里
[02:46.38]左摇右摆躲开所有的暴风雨
[02:50.22]一个鲤鱼打挺跃过龙门去
[02:53.74]把好运气统统洒向人间的街巷
[02:57.82]让每张笑脸都闪着金色的光芒
[03:03.26]鼓点咚咚咚敲响最旺的节拍
[03:04.56]跟着小锦鲤一起摇摆发大财
[03:08.06]水花四溅跃云端
[03:11.78]金光万道破重关
[03:17.56]锦鲤赐福好运来
[03:18.82]岁岁年年皆灿烂
[03:39.12]我是画卷里最神气的小锦鲤
[03:43.30]化身金龙在九天星河里嬉戏
[03:46.84]竹笛与电音吹散所有的阴霾
[03:49.88]漫天烟花都在为我热烈喝彩
[03:52.84]把福气和欢喜装满每个口袋
[03:56.38]心想事成花开富贵全都来
[03:59.94]最燃的节奏永远不会停下来
[04:03.56]小小锦鲤带你飞向最亮的未来
[04:08.22]锦鲤跳呀跳
[04:09.44]龙门高呀高
[04:14.04]好运来到家门口
[04:18.38]天天开口笑</pre>
</figure>
### 11. 山海小当家 — Little Boss of the Classic of Mountains and Seas
*Guofeng DJ remix* · 647 characters · 237 s
<figure class="yue2-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-11" src="https://content.hoelee.com/file/hoelee/music/山海小当家-Mr-Hoelee.mp3"></audio>
<pre id="lrc-11" hidden>[00:16.41]翻开山海经那本厚厚的图鉴
[00:19.85]里面的神兽全都跳到我面前
[00:23.30]小饕餮馋得抱着奶瓶不松手
[00:26.75]九尾狐忙着梳理毛茸茸的尾尖
[00:30.48]我吹响竹哨立下今天的规矩
[00:32.26]排好队伍不许调皮也不许哭泣
[00:35.28]虽然我只是扎着羊角辫的小童
[00:38.76]却是这山海神兽乐园的大统领
[00:42.20]战鼓与电音在昆仑山顶轰鸣
[00:46.12]琵琶弦拨出闪电一般的轨迹
[00:51.12]骑上小麒麟戴上威风的虎头帽
[00:53.30]今夜的山海巡游马上要开启
[00:55.84]我是山海经里最酷的小当家
[00:59.68]带着百兽战队闯荡海角天涯
[01:03.38]重低音一震群山跟着一起晃
[01:06.74]凤凰在头顶铺开五彩的霓裳
[01:10.46]应龙在云端打着节拍呼风唤雨
[01:13.48]白泽在身边为我指点着迷津
[01:16.56]一二三四跟着鼓点大步往前迈
[01:20.36]小小当家要把天地都点燃起来
[01:37.76]毕方鸟点亮了漫山遍野的篝火
[01:41.06]精卫鸟衔来亮晶晶的彩色贝壳
[01:44.74]连打瞌睡的烛龙都睁开了眼睛
[01:47.96]被这动感的电子节拍彻底唤醒
[01:50.72]我拿出糖葫芦分给乖巧的小兽
[01:54.54]谁表现最好就奖励一朵小红花
[01:57.62]古老的神话撞上最潮的电音浪
[02:01.12]整片星空都是我们的游乐场
[02:04.60]战鼓与电音在昆仑山顶轰鸣
[02:08.38]琵琶弦拨出闪电一般的轨迹
[02:11.44]骑上小麒麟戴上威风的虎头帽
[02:15.58]今夜的山海巡游马上要开启
[02:18.12]我是山海经里最酷的小当家
[02:21.56]带着百兽战队闯荡海角天涯
[02:25.94]重低音一震群山跟着一起晃
[02:29.04]凤凰在头顶铺开五彩的霓裳
[02:32.74]应龙在云端打着节拍呼风唤雨
[02:35.76]白泽在身边为我指点着迷津
[02:38.90]一二三四跟着鼓点大步往前迈
[02:42.64]小小当家要把天地都点燃起来
[02:46.92]昆仑山高路不平
[02:50.36]神兽同行显威灵
[02:53.82]唢呐声声震九霄
[02:56.54]山海少年最逍遥
[03:12.96]我是山海经里无敌的小当家
[03:16.44]跨越山海把快乐传遍千万家
[03:20.50]最密的鼓点撞碎所有的孤单
[03:24.20]古筝与合成器奏响狂欢的夜晚
[03:29.28]麒麟踏瑞气九尾摇动满天星
[03:30.48]每一个梦想都在节奏里觉醒
[03:34.40]不管未来还有多少未知的奇遇
[03:37.54]山海小当家永远勇敢冲第一
[03:38.74]小麒麟跑呀跑
[03:39.94]九尾狐笑呀笑
[03:41.14]山海小当家
[03:54.20]明天继续闹</pre>
</figure>
<script>
// yue2-track: sync LRC lyrics with <audio> playback (no dependencies)
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
const raw = document.getElementById(audio.dataset.lrc).textContent;
const lines = raw.split('\n').map((l) => {
const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
}).filter(Boolean);
const box = document.createElement('div');
box.className = 'yue2-box';
lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
audio.parentNode.insertBefore(box, audio.nextSibling);
let cur = -1;
audio.addEventListener('timeupdate', () => {
const now = audio.currentTime;
// last line whose timestamp has passed; -1 before the first line starts
// (findIndex()-1 went negative at t=0 and wrapped to the LAST line)
let i = -1;
for (let k = 0; k < lines.length; k++) { if (lines[k].t <= now) i = k; else break; }
if (i !== cur) {
if (cur >= 0) box.children[cur].classList.remove('on');
cur = i;
if (cur >= 0) {
const p = box.children[cur];
p.classList.add('on');
// centre the active line inside the box. Use rect deltas, NOT offsetTop:
// offsetTop is measured from the nearest *positioned* ancestor, so without
// position:relative on the box it is huge and scrollTop clamps to the bottom.
const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
} else {
box.scrollTop = 0; // back to the top before the first line
}
}
});
});
</script>
文章 03 / 08:我如何用 FLUX.1 Kontext 打造 AI 家具合成演示
- Slug:
ai-furniture-compositing-with-flux-kontext - 英文标题: How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext
- 分类:
case-studies| 发布日期: 2026-09-11 - 线上地址: https://blog.hoelee.com/posts/ai-furniture-compositing-with-flux-kontext/ (EN) | https://blog.hoelee.com/posts/zh/ai-furniture-compositing-with-flux-kontext/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "我如何用 FLUX.1 Kontext 打造 AI 家具合成演示"
description: "用 fal.ai FLUX.1 Kontext 把家具产品照合成到风格化房间场景——多图端点、tainted canvas 的修复,以及为什么必须关掉 prompt enhancement。"
pubDate: 2026-09-11
category: case-studies
blueprint: sme-digital-suite
blueprintLayer: L1-high-conversion-web
tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce]
ogImage: /og/zh-ai-furniture-compositing-with-flux-kontext.jpg
banner: /banners/ai-furniture-compositing-with-flux-kontext.jpg
---
一位家具客户带着一个每个小电商卖家最终都会撞上的问题来找我:他们的产品图都是**孤立的产品照**——一张白底桌子、一张白底椅子——但顾客不会从一片白茫茫的虚无里买家具,他们买的是*房间*。给每个产品、每种室内风格都拍一次实体棚拍,对一个人的生意来说根本不可能。
需求是:拿两张产品照(一张餐桌、一把椅子),把它们一起放进一个可信的、高级感的室内场景——生成出来的,而不是拍出来的。这篇文章讲的就是我为验证这件事可行而做的概念验证:我选的模型,以及三个差点把它搞砸的 bug。
## 为什么选 FLUX.1 Kontext——以及那个关键的端点
第一反应自然是通用文生图模型:*「一张餐桌和一把椅子放在现代客厅里」*。可当客户说*「不——是我的桌子、我的椅子,就是我产品页上那一款」*时,这个思路就崩了。生成一个相似品毫无价值;重点是要让**真实产品原封不动**,只改变它周围的房间。
这正是 **FLUX.1 Kontext**(通过 [fal.ai](https://fal.ai))专门解决的问题:以图生图,参考图*锚定*产品,提示词描述场景。但有个坑让我耗掉一个下午:这里有**两个**端点。
| 端点 | 参考图输入 | 用途 |
|---|---|---|
| `fal-ai/flux-pro/kontext` | `image_url`(单张) | 编辑 / 重新语境化一张图 |
| `fal-ai/flux-pro/kontext/multi` | `image_urls`(数组) | **合并多张参考图** |
我需要把*两个独立产品融合进一个场景*,所以普通 `kontext` 不够——它只吃一张参考图。`multi` 变体接受参考图数组,这才是「桌子 + 椅子 → 一个房间」能成立的关键。它被标记为实验版,但这件事上它是唯一的选择。
## 用一个纯 HTML 文件调用 fal.ai——不用 SDK,不用服务器
做概念验证我不想搭后端。fal.ai 暴露了一个纯 REST 队列 API,所以一个 `index.html` 配 vanilla JavaScript 就能搞定全部。但这是三步,不是一步:
<details>
<summary>index.html — fal.ai REST queue submission & polling (32 lines)</summary>
```js
// 1. 提交——返回 request_id + 轮询 URL
const submit = await fetch(
"https://queue.fal.run/fal-ai/flux-pro/kontext/multi",
{
method: "POST",
headers: {
Authorization: "Key " + FAL_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: "...",
image_urls: [tableDataUrl, chairDataUrl],
enhance_prompt: false,
}),
}
);
const { request_id, status_url, response_url } = await submit.json();
// 2. 轮询状态直到 COMPLETED(约 5–30 秒)
let status;
do {
await new Promise((r) => setTimeout(r, 2000));
status = (await (await fetch(status_url, {
headers: { Authorization: "Key " + FAL_KEY },
})).json()).status;
} while (status === "IN_QUEUE" || status === "IN_PROGRESS");
// 3. 取结果
const result = await (await fetch(response_url, {
headers: { Authorization: "Key " + FAL_KEY },
})).json();
// result.images[0].url 就是生成的图片
```
</details>
有两件事我很早就验证了,因为它们决定了「单文件」方案成不成立:
1. **CORS 是开放的。** `queue.fal.run` 返回宽松的 `access-control-allow-origin`,并允许 `authorization` 头,所以浏览器可以直接调用,无需代理。我在写任何 UI 之前就用 preflight 确认了这一点。
2. **本地图以 base64 data URI 传入。** fal.ai 的 `image_urls` 接受 `data:` URI,所以我根本不需要先把客户的照片传到存储桶——demo 直接读文件、压缩,然后塞进请求体。
## Canvas 压缩层
手机照片有 5–10 MB。两张这样、再 base64 编码,请求体会膨胀到无法使用,生成也会变慢。所以提交前,demo 会把每张图过一遍 `<canvas>` 来缩放并重新编码:
```js
async function imgToDataURI(file, maxSize = 1024, quality = 0.85) {
const img = await createImageBitmap(file); // 或 new Image()
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const c = document.createElement("canvas");
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
return c.toDataURL("image/jpeg", quality); // 每张约 200–400 KB
}
```
一张 7 MB 的手机照变成约 300 KB 的 JPEG。上传快,模型处理也快。
## Bug #1 ——「Tainted canvases may not be exported」
demo 在我脑子里能跑,在浏览器里却不行。我一点「生成」就收到:
> `Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.`
这是浏览器的安全边界,不是 fal.ai 的问题。当你用 `file://` 打开页面、并用 `<img src="chair.jpg">` 加载一张*本地*图片时,那张图属于**不透明源**。它一被画到 canvas 上,canvas 就变成 *tainted*,`toDataURL()` 拒绝导出——浏览器不会让网页读回一张它无法证明有权读取的文件的像素。
有两个修复,我在不同阶段都用过:
1. **把默认图以 base64 data URI 内嵌进 HTML 源码。** data URI 不会污染 canvas,所以即使从磁盘双击打开也能跑。
2. **走 HTTP、用同源相对路径**(`src="chair.jpg"`)。一旦页面和图片同源,canvas 就保持干净。这是真实部署的正确答案——demo 现在跑在一个 URL 上,而不是双击打开的文件。
通用规则:**只要任何不透明源的图片碰到 canvas,`toDataURL` 立刻报错。** 如果你要加载本地文件,要么内嵌,要么走 HTTP。不改浏览器安全设置,没有第三条路。
## Bug #2 —— API key 就在 HTML 里
对 demo 来说这可以容忍;对任何公开的东西来说这是个洞。客户端 `fetch` 里的 `Key` 头意味着 key 就在页面源码里,任何人按 F12 就能读到。在交付客户内部预览期间,该静态页面仅搭配了极低余额的限额测试 Key 并在受控私密链接下访问;而在下一阶段的正式 WordPress 插件中,Key 将彻底移至服务端 PHP。
真正 WordPress 插件(下一阶段)的计划是把 key 挪到**服务端 PHP**:插件的端点去做带鉴权的 fal.ai 调用并返回图片,浏览器只跟插件自己的路由对话。demo 证明了管线能跑通;插件会把秘密放回秘密该待的地方。
## Bug #3 —— 输出看起来「像第二张图」
这是让我怀疑 `multi` 到底能不能用的那个。我换了一张新桌子图、生成,结果看起来跟椅子参考图几乎一模一样——好像模型直接忽略了桌子、复制了椅子。
在怪模型之前,我先查了一件可证伪的事:**`multi` 到底是在合成,还是只是在回显某一张输入?** 我用感知哈希和平均像素差对比了生成图和两张源图:
| 对比 | 平均像素差(0 = 相同) | dHash 距离 |
|---|---|---|
| 结果 vs 桌子 | 55.7 | 31 |
| 结果 vs 椅子 | 55.3 | 29 |
| 桌子 vs 椅子 | 69.3 | 30 |
结果离两张输入都*很远*——端点是真在合成新场景,不是复制。所以「像椅子」的问题不在 API,而在**输入和提示词**。
三个叠加的原因:
1. **参考图不一致。** 最初的 `chair.jpg` 是一张完整的「桌+椅」*场景*照,不是干净的单椅——于是模型看到一张图就已经满足「桌+椅同框」,就倾向照着它来。
2. **按位置指代不可靠。** Kontext 按图片*内容*匹配,而不是数组顺序。说「第一张图」/「第二张图」并不能可靠地映射到「桌子」/「椅子」。修复是直接*命名物体*——*「the dining table」*和*「the chair」*——让模型自己去匹配正确的参考图。
3. **测试初期开启了 prompt enhancement。** fal.ai 的 `enhance_prompt` 参数一旦开启,会在后台把提示词改写成更丰富的审美描述——当目标是*「绝不要重新解读产品」*时,这正好帮了倒忙。我将其显式锁定为 `enhance_prompt: false`,强制模型严格执行字面约束,而不是添油加醋。
## 最终起作用的提示词
客户的核心要求很严格:**原样保留家具**,只去发明房间。这需要一段把大部分篇幅花在*阻止*重新解读、而不是描述风格上的提示词:
> Create a photorealistic interior photograph using the exact dining table and
> exact chair from the reference images. Preserve both furniture pieces exactly
> as shown — do not redesign, recolor, repaint, restyle, replace, or reinterpret
> either product. Keep their original geometry, proportions, construction,
> material, finish, texture, and color exactly unchanged.
>
> Place the chair naturally beside the dining table in a realistic dining
> position, slightly pulled under the table and properly aligned with it.
> Maintain realistic scale, perspective, and physical contact with the floor.
> The chair and table must look photographed together in the same real room,
> with natural contact shadows — not composited or pasted together.
>
> Preserve the true original color. Do not allow the room lighting, wall or
> floor colors, or grading to alter the furniture.
>
> Set the scene in a spacious modern living room with floor-to-ceiling windows
> and subtle warm afternoon daylight. Use restrained, neutral interior colors.
>
> Photorealistic commercial furniture photography, realistic camera
> perspective, natural proportions, high detail.
demo 里内置了六段这样的提示词,每种场景风格一段(现代客厅、温馨餐厅、北欧风、侘寂风、以及干净的影棚),每一段只改「set the scene」那一节。
## 结果
一个 `index.html`,没有后端、没有 SDK——两张产品照进,一个布置好的房间出,**每张图 $0.04**,生成约 15–20 秒。客户现在有了一个能拿给*他们*顾客看的可用预览,带场景选择器和可编辑提示词,然后再决定是否投入完整的 WordPress 插件。
这个概念验证回答了一个它生来就要回答的问题:**是的,你可以保留真实产品、只生成它周围的房间**——只要你尊重那三条真正要紧的约束:每个产品一张干净的参考图、按物体命名(而非按位置)的提示词、以及 `enhance_prompt: false`。
## 我会做不同的地方
- **第一天就要求干净的单品参考图。** 下游的每一种失败模式——「像某一张图」的问题、比例漂移——都能追溯到不一致的源照片。我会在碰任何模型之前,先发一份给客户的简短说明(「每张照片一个产品、纯色背景、没有其他家具」)。
- **先显式关掉 `enhance_prompt`。** 我浪费了一次生成,就因为 enhancement 把我精心写的「别重新解读」改写成恰恰相反的东西。
- **在写 UI 之前先用真实 preflight 验证 CORS。** 两行 `curl` 的事,却能给整个架构决策祛除风险。
---
## 想为你的产品目录做这个吗?
我为电商企业构建 AI 图像管线、WordPress 插件,以及自托管基础设施。如果你想把产品摆进风格化的房间场景——或者想雇我做类似的 AI 集成——我非常乐意交流:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **邮箱:** [[email protected]](mailto:[email protected]?subject=AI%20product%20image%20compositing)
- 🌐 **网站:** [hoelee.com](https://hoelee.com)
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext"
description: "Compositing furniture photos into styled room scenes with fal.ai FLUX.1 Kontext — the multi-image endpoint, the tainted-canvas fix, and why prompt enhancement had to go."
pubDate: 2026-09-11
category: case-studies
blueprint: sme-digital-suite
blueprintLayer: L1-high-conversion-web
tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce]
ogImage: /og/ai-furniture-compositing-with-flux-kontext.jpg
banner: /banners/ai-furniture-compositing-with-flux-kontext.jpg
---
A furniture client came to me with a problem that every small e-commerce seller
eventually hits: their catalogue photos are **isolated product shots** — a
table on white, a chair on white — but customers don't buy furniture from a
white void. They buy the *room*. Staging a real photoshoot for every product,
in every interior style, is out of the question for a one-person business.
The ask: take two product photos (a dining table, a chair) and place them
together into a believable, high-end interior scene — generated, not
photographed. This post is the story of the proof-of-concept I built to prove
that's possible, the model I chose, and the three bugs that tried to eat it.
## Why FLUX.1 Kontext — and the endpoint that matters
The obvious first instinct is a general text-to-image model: *"a dining table
and a chair in a modern living room."* That fails the moment the client says
*"no — MY table, MY chair, the exact one on my product page."* Generating a
lookalike product is worthless; the whole point is to keep the **real product
unchanged** and only change the room around it.
That's the specific problem **FLUX.1 Kontext** (via [fal.ai](https://fal.ai))
is built for: image-conditioned generation, where the reference image *anchors*
the product and the prompt describes the scene. But there's a subtlety that cost
me an afternoon: there are **two** endpoints.
| Endpoint | Reference input | Use case |
|---|---|---|
| `fal-ai/flux-pro/kontext` | `image_url` (single) | edit / re-context one image |
| `fal-ai/flux-pro/kontext/multi` | `image_urls` (array) | **combine multiple reference images** |
I needed to *fuse two separate products into one scene*, so plain `kontext`
wasn't enough — it only takes one reference image. The `multi` variant accepts
an array of reference images and is what makes "table + chair → one room"
possible. It's flagged experimental, but it's the only game in town for this.
## Calling fal.ai from a plain HTML file — no SDK, no server
For a proof-of-concept I didn't want to stand up a backend. fal.ai exposes a
plain REST queue API, so a single `index.html` with vanilla JavaScript can do
the whole job. There are three steps, not one:
<details>
<summary>index.html — fal.ai REST queue submission & polling (32 lines)</summary>
```js
// 1. Submit — returns request_id + polling URLs
const submit = await fetch(
"https://queue.fal.run/fal-ai/flux-pro/kontext/multi",
{
method: "POST",
headers: {
Authorization: "Key " + FAL_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: "...",
image_urls: [tableDataUrl, chairDataUrl],
enhance_prompt: false,
}),
}
);
const { request_id, status_url, response_url } = await submit.json();
// 2. Poll status until COMPLETED (takes ~5–30s)
let status;
do {
await new Promise((r) => setTimeout(r, 2000));
status = (await (await fetch(status_url, {
headers: { Authorization: "Key " + FAL_KEY },
})).json()).status;
} while (status === "IN_QUEUE" || status === "IN_PROGRESS");
// 3. Fetch the result
const result = await (await fetch(response_url, {
headers: { Authorization: "Key " + FAL_KEY },
})).json();
// result.images[0].url is the generated image
```
</details>
Two things I verified early, because they make or break the "single file"
approach:
1. **CORS is open.** `queue.fal.run` returns a permissive
`access-control-allow-origin` and allows the `authorization` header, so a
browser can call it directly with no proxy. I confirmed this with a
preflight before writing any UI.
2. **Local images go in as base64 data URIs.** fal.ai accepts `data:` URIs in
`image_urls`, so I never had to upload the client's photos to a storage
bucket first — the demo reads a file, compresses it, and ships it straight
in the request body.
## The canvas compression layer
Phone photos are 5–10 MB. Two of those, base64-encoded, bloats the request to
unusable size and slows generation. So before submitting, the demo runs each
image through a `<canvas>` to resize and re-encode it:
```js
async function imgToDataURI(file, maxSize = 1024, quality = 0.85) {
const img = await createImageBitmap(file); // or new Image()
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const c = document.createElement("canvas");
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
return c.toDataURL("image/jpeg", quality); // ~200–400 KB each
}
```
A 7 MB phone shot becomes a ~300 KB JPEG. Fast to upload, fast for the model to
process.
## Bug #1 — "Tainted canvases may not be exported"
The demo worked in my head. Then it didn't work in a browser. The moment I hit
"generate" I got:
> `Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.`
This is a browser security boundary, not a fal.ai problem. When you open the
page over `file://` and load a *local* image with `<img src="chair.jpg">`,
that image is an **opaque origin**. The moment it's drawn onto a canvas, the
canvas becomes *tainted*, and `toDataURL()` refuses to export it — the browser
won't let a webpage read back the pixels of a file it can't prove it's allowed
to read.
Two fixes, and I used both at different points:
1. **Inline the default images as base64 data URIs** directly in the HTML
source. Data URIs don't taint the canvas, so the demo works even when
double-clicked from disk.
2. **Serve over HTTP with same-origin relative paths** (`src="chair.jpg"`).
Once the page and images share an origin, the canvas stays clean. This is
the right answer for the real deployment — the demo now lives at a URL, not
a double-clicked file.
The general rule: **`toDataURL` fails the instant any opaque-origin image touches
the canvas.** If you're loading local files, either inline them or serve over
HTTP. There is no third way without changing browser security settings.
## Bug #2 — the API key is in the HTML
For a demo this is tolerable; for anything public it's a hole. A `Key` header in
a client-side `fetch` means the key is in the page source, readable by anyone
who presses F12. During the client internal review, this demo ran strictly with
a low-balance restricted test key over a gated link; for production, client-side
keys are a non-starter.
The plan for the real WordPress plugin (the next phase) is to move the key to
**server-side PHP**: the plugin's endpoint does the authenticated fal.ai call
and returns the image, while the browser only talks to the plugin's own route.
The demo proved the pipeline works; the plugin will put the secret where
secrets belong.
## Bug #3 — the output looked like "the second image"
This was the one that made me question whether `multi` even worked. I swapped in
a new table photo, generated, and the result looked almost identical to the
chair reference — as if the model had just ignored the table and copied the
chair.
Before blaming the model, I checked something falsifiable: **is `multi` even
compositing, or just echoing one input?** I compared the generated image
against both source images with a perceptual hash and mean pixel difference:
| Comparison | Mean pixel diff (0 = identical) | dHash distance |
|---|---|---|
| result vs table | 55.7 | 31 |
| result vs chair | 55.3 | 29 |
| table vs chair | 69.3 | 30 |
The result was *far* from both inputs — the endpoint genuinely composites a new
scene, not a copy. So the "looks like the chair" problem wasn't the API; it was
the **inputs and the prompt**.
Three compounding causes:
1. **The reference photos were inconsistent.** The original `chair.jpg` was a
photo of a full table-and-chair *scene*, not a clean single chair — so the
model already saw "table + chair" satisfied by one image and leaned on it.
2. **Positional prompting is unreliable.** Kontext matches images by *content*,
not array order. Saying "the first image" / "the second image" doesn't
reliably map to "the table" / "the chair". The fix is to name the objects —
*"the dining table"* and *"the chair"* — and let the model match them to the
right reference.
3. **Prompt enhancement was enabled in early tests.** When turned on, fal.ai's
`enhance_prompt` rewrites your prompt into a richer aesthetic description —
exactly wrong when the goal is *"do not reinterpret the product."* I explicitly
pinned `enhance_prompt: false` so the model obeys the literal constraints instead
of embellishing them.
## The prompt that finally worked
The client's core requirement was strict: **preserve the furniture exactly** and
only invent the room. That needs a prompt that spends most of its length
*preventing* reinterpretation, not describing style:
> Create a photorealistic interior photograph using the exact dining table and
> exact chair from the reference images. Preserve both furniture pieces exactly
> as shown — do not redesign, recolor, repaint, restyle, replace, or reinterpret
> either product. Keep their original geometry, proportions, construction,
> material, finish, texture, and color exactly unchanged.
>
> Place the chair naturally beside the dining table in a realistic dining
> position, slightly pulled under the table and properly aligned with it.
> Maintain realistic scale, perspective, and physical contact with the floor.
> The chair and table must look photographed together in the same real room,
> with natural contact shadows — not composited or pasted together.
>
> Preserve the true original color. Do not allow the room lighting, wall or
> floor colors, or grading to alter the furniture.
>
> Set the scene in a spacious modern living room with floor-to-ceiling windows
> and subtle warm afternoon daylight. Use restrained, neutral interior colors.
>
> Photorealistic commercial furniture photography, realistic camera
> perspective, natural proportions, high detail.
The demo ships six of these, one per scene style (modern living room, cozy
dining room, Scandinavian, wabi-sabi, and a clean studio), each differing only
in the "set the scene" paragraph.
## The result
One `index.html`, no backend, no SDK — two product photos in, a staged room
out, at **$0.04 per image** and roughly 15–20 seconds of generation. The client
now has a working preview they can show *their* customers, with a scene selector
and an editable prompt, before committing to the full WordPress plugin.
The proof-of-concept answered the question it was built to answer: **yes, you
can keep the real product and only generate the room around it** — as long as
you respect the three constraints that actually matter: a clean reference image
per product, object-named (not positional) prompts, and `enhance_prompt: false`.
## What I'd do differently
- **Require clean, single-product reference images on day one.** Every failure
mode downstream — the "looks like one image" problem, the proportion drift —
traces back to inconsistent source photos. I'd ship a tiny client-facing
guide ("one product per photo, plain background, no other furniture") before
touching any model.
- **Turn `enhance_prompt` off explicitly, first.** I lost a generation to the
enhancement rewriting my careful "do not reinterpret" instructions into
exactly the opposite.
- **Validate CORS with a real preflight before building UI.** It's a two-line
`curl` and it de-risks the entire architecture decision.
---
## Want this for your product catalogue?
I build AI image pipelines, WordPress plugins, and self-hosted infrastructure
for e-commerce businesses. If you want to stage your products in styled room
scenes — or hire me for a similar AI integration — I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=AI%20product%20image%20compositing)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
文章 04 / 08:从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操
- Slug:
from-audio-to-a-4-minute-ai-music-video - 英文标题: From Audio to a 4-Minute Music Video: Orchestrating Gemini, AI Diffusion, and Filmora Editing
- 分类:
case-studies| 发布日期: 2026-10-25 - 线上地址: https://blog.hoelee.com/posts/from-audio-to-a-4-minute-ai-music-video/ (EN) | https://blog.hoelee.com/posts/zh/from-audio-to-a-4-minute-ai-music-video/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操"
description: "基于《月牙湾》1080P 高清动漫 OP 制作实录:详解 AI 音乐重构、Gemini 提示词分镜规划、文字生图与图生视频多轮采样筛选调优,以及 Filmora 剪辑与特效合成的真实生产实践。"
pubDate: 2026-10-25
category: case-studies
blueprint: ai-agent-automation
blueprintLayer: L4-aigc-media-pipelines
stream: creative-lab
tags: ["ai-video", "music-video", "gemini", "filmora", "face-swapping", "ai-music", "creative-workflows"]
ogImage: "/og/zh-from-audio-to-a-4-minute-ai-music-video.jpg"
banner: "/banners/from-audio-to-a-4-minute-ai-music-video.jpg"
draft: false
---
绝大多数 AI 视频演示都停留在 5 秒的单镜头片段:一个女生转头、一段雨水打在水坑里的特写。发在社交媒体上很惊艳,但要真正用 AI 从零做出一整首长达 4 分钟(240 秒)、包含音乐生成与 20+ 个连续镜头的完整音乐视频(MV),完全是另一套现实的工程与创作实操。
真实做过的人都知道,**AI 根本做不到“一键完美直出”**:
* 让 Gemini 生成分镜提示词,需要细致引导才不偷工减料;
* 文字生图经常出现五官崩坏、服装变色;
* 图生视频跑出来的微动作,往往需要反复生成多次(重抽)才能挑出一段自然的;
* 最后要把这些零散的素材剪成一首节奏对齐、转场自然的完整 MV,必须依赖 **Filmora** 这样的专业剪辑软件做精细卡点、特效叠加与平滑过渡。
为了验证整套流水线能否真正跑通,我重构改编了经典华语曲目**《月牙湾》(Crescent Bay)**,将其打造为日系热血奇幻动漫 OP 风(Oriental Fantasy J-Rock),并完成了整整 4 分钟 1080P 高清成片。
以下是完整的最终成品(可直接全屏播放):
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; max-width: 100%; border-radius: 8px; margin: 2rem 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);">
<iframe
src="https://www.youtube.com/embed/OJC-Zdn232k"
title="月牙湾 (Crescent Bay) - 1080P AI 音乐视频"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen>
</iframe>
</div>
---
## 1. 音乐生成与风格重构:日系热血动漫 OP 的诞生
视觉的灵魂是节奏。制作 MV 的第一步,首先是产生符合设想的音乐母带。
原版的《月牙湾》带有浓厚的西域民谣与流行底蕴。为了赋予它更强烈的视觉冲击力和画面推动感,我通过 AI 音乐生成工具将整首歌重构为 **Oriental Fantasy J-Rock / AniSong(东方奇幻日系动漫 OP 风格)**:
* **鼓点与贝斯**:换上高速双踩双底鼓(Double Bass Drum)与持续滚动的失真电贝斯,让副歌的推进感拉满;
* **吉他与乐器交织**:前奏保留了西域传统风情的空灵音色,但在主歌切入后迅速叠加大失真主音电吉他;
* **人声质感**:要求高燃、清亮且穿透力极强的热血女声,在副歌瞬间爆发。
有了这首 4 分钟的完整音频后,我将全曲按情绪起伏拆解成 24 个左右的时钟分段(前奏、主歌 A/B、副歌爆发、间奏吉他 Solo、尾奏),为后续的分镜画面打好节拍地基。
---
## 2. Gemini 分镜设计:两阶段提示词规划
很多人用大模型生成分镜时,习惯把整首歌歌词丢给它,说“帮我生成 24 个生图提示词”。这样做的结果往往惨不忍睹:前 3 个镜头写得很详细,到了第 10 镜以后提示词就只剩两三句话,甚至连主角长相都丢了。
在 Gemini 中,我采用**分阶段结构化输出**:
1. **第一阶段:锁定视觉核心 DNA(Visual Constants)**
* **角色固定锚点**:固定主角发型(黑长直双环髻、红丝带发簪)、服装(青白双层汉元素织物)、外貌气质与年龄;
* **色彩与摄影基调**:统一色温(4500K 冷青色调)、电影镜头感(ARRI 50mm 镜头虚化、胶片微颗粒感);
* **留白区域**:明确每张图采用三分法构图,人物位于画面一侧,另一侧预留干净的景深虚化负空间(Copy Space)。
2. **第二阶段:生成 24 镜生图(T2I)与生视频(I2V)提示词**
* 让 Gemini 逐个格子输出:该镜头的**生图提示词(Text-to-Image Prompt)**以及对应的**图生视频运动提示词(Image-to-Video Motion Prompt)**;
* 人物镜头与纯意象空镜头(大漠风沙、屋檐雨滴、古琴特写)穿插交替,避免连续正脸特写造成视觉疲劳。
---
## 3. 生图与图生视频的现实:多轮采样与筛选调优
AI 视频制作中耗费最多时间的,就是**“生图 -> 挑图 -> 垫图生视频 -> 重新采样挑选”**的反复迭代过程。
### ① 文字生图(T2I):解决画风与角色漂移
即使有详细的提示词,生图模型也常常会出现五官比例怪异、衣服颜色擅自更改、或在空镜头里莫名其妙画一张人脸的情况。
* 单个镜头通常需要生成 3~5 次,挑选出光影、人物站位和留白最符合分镜的一张;
* 挑选出的首帧图片,必须保证主角的标志性服饰和发型在视觉上具备高度辨识度。
### ② 图生视频(I2V):控制微动作,避免身体畸变
拿挑选好的静态图送入图生视频模型生成 5~10 秒的动态视频,是崩坏率最高的环节:
* **严禁否定词**:在正向提示词中写“不要变形、不要乱动”,模型反而容易产生肢体扭曲;必须使用正面稳定词(如微风吹拂发丝、胸口自然呼吸起伏、镜头缓慢推进);
* **动作幅度控制**:AI 生成长镜头时,动作幅度越小越逼真。大步奔跑或大幅度转身很容易引发身体部位撕裂;
* **多次生成挑选最佳片段**:一个 10 秒的视频镜头,常常需要生成 3 次以上。有时候前 4 秒很自然、第 5 秒开始崩坏,这没关系——只要有几秒是完美的,就可以保留下来放到剪辑软件中裁剪使用。
### ③ 面部一致性锁颜:本地 FaceFusion 深度模型解决“多镜头变脸”
即便在提示词中固定了发型与服饰,文生图与图生视频在 20 多个连续镜头中,人物五官骨相依然会出现微小的“角色漂移”,更无法直接保证封面海报与成片女主拥有完全一致的面孔。
* **本地算力部署**:利用本机显卡(RTX 3060 12GB)离线运行 **FaceFusion** 神经网络换脸流水线;
* **拒绝 2D 贴纸感**:放弃容易产生贴纸感和边缘闪烁的通用 128 像素简易算法,选用训练精度更高的 **320 规格深度神经网络模型(DFM)**,让光影与 3D 轮廓深度融合;
* **高清面部修复**:配合 **SCRFD** 灵敏人脸检测与 **GFPGAN** 高清修复,将精挑细选的高辨识度东方少女面容(与封面海报保持 100% 同款面庞)无缝置换入主角核心特写镜头中,彻底消除“一转镜头换了个人”的违和感。
---
## 4. Filmora 剪辑与后期合成:真正让素材变成 MV 的关键一步
AI 生成出来的只是一个个孤立的视频切片,**把这些碎片变成一首真正有呼吸感、有节奏感的音乐视频,核心功力全在剪辑软件里**。
我使用 **Filmora** 完成了最后的重度后期与合成:
```
[原始音频轨道] ────────────────────────────────────────────── (对齐卡点基准)
[视频轨 1 / 2] [ 镜头1 (淡出) ] ── (平滑交叉叠化) ── [ 镜头2 (淡入) ]
[特效与转场层] [ 光晕 / 风沙粒子 / 局部调色 LUT ]
[歌词与排版层] [ 双语艺术字幕 / 动态节拍对齐 ]
```
1. **精准对位音乐节拍(Beats & Cuts)**:
* 将音频波形导入 Filmora,标记出吉他切入点、重低音鼓点与高潮副歌爆发点;
* 将 AI 生成的 24 段视频按节奏修剪,只截取每段生成最平稳、动作最自然的 6~10 秒精华部分,让镜头的切换严丝合缝地踩在鼓点上。
2. **消灭黑屏跳闪,打造平滑过渡**:
* 绝不能用粗暴的黑场淡入淡出(Dip to Black),否则整首 MV 每 10 秒就会像眨眼一样暗下去一次;
* 在 Filmora 中使用两轨交叉叠化(Cross Dissolve)与柔光过渡,让前一个画面的残影与后一个画面的光影自然交融,呈现出电影般的无缝穿梭感。
3. **叠加氛围特效与统一色调**:
* 针对沙漠敦煌与动漫热血主题,叠加了微弱的风沙粒子动效与镜头光晕(Lens Flare);
* 通过全局 LUT 统一 20 多个不同镜头的明暗与冷暖色阶,消除不同生成批次之间的色差。
4. **歌词排版与呈现**:
* 结合大号中文艺术字体与下方的英文字幕,做好边框与阴影,防止歌词在动态背景中被背景颜色吞噬。
---
## 5. 经验总结:个人创作者的 AI 生产力闭环
完成这一首 4 分钟的《月牙湾》MV,给我最大的感受是:
1. **AI 放大的是制作上限,但不能取代审美判断**:AI 可以在短时间内产出海量的画面与动作构想,但“哪一张图最好、哪一段动作能用、在哪个鼓点切镜头”,依然需要创作者的主观审美与导演直觉。
2. **多工具协同才是最优解**:
* **Gemini** 负责文字创意、世界观定调与分镜拆解;
* **AI 绘图与视频扩散** 负责提供高质感的视听资产;
* **FaceFusion (本地 RTX 3060)** 负责锁定女主角核心特写镜头与封面海报的面部一致性;
* **Filmora** 负责精准的时间轴控制、镜头拼接、声画对齐与视觉特效。
这种“AI 生成资产 + 人工剪辑雕琢”的工作流,让个人或小型团队在过去需要整个制作公司才能完成的 4 分钟完整 MV 制作,变成了一个人在工作站前几天内即可交付的现实。
---
*如果您对 AI 音视频流水线搭建、数字内容自动化生成或商业多媒体交付感兴趣,欢迎交流探讨:*
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **邮件:** [[email protected]](mailto:[email protected]?subject=%E5%92%A8%E8%AF%A2%20AI%20MV%20%E5%88%9B%E4%BD%9C%E4%B8%8E%E6%B5%81%E6%B0%B4%E7%BA%BF)
- 🌐 **网站:** [hoelee.com](https://hoelee.com)
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "From Audio to a 4-Minute Music Video: Orchestrating Gemini, AI Diffusion, and Filmora Editing"
description: "A complete production breakdown of building a 4-minute 1080p anime OP music video (Crescent Bay): AI music generation, Gemini storyboard prompting, iterative image-to-video rerolls, and Filmora VFX editing."
pubDate: 2026-10-25
category: case-studies
blueprint: ai-agent-automation
blueprintLayer: L4-aigc-media-pipelines
stream: creative-lab
tags: ["ai-video", "music-video", "gemini", "filmora", "face-swapping", "ai-music", "creative-workflows"]
ogImage: "/og/from-audio-to-a-4-minute-ai-music-video.jpg"
banner: "/banners/from-audio-to-a-4-minute-ai-music-video.jpg"
draft: false
---
Most AI video demonstrations end after five seconds: a stunning single-shot clip of a character turning their head or raindrops hitting a puddle. It looks cinematic in a tweet, but assembling a complete four-minute music video (240 seconds) with music generation and 20+ continuous scenes is a vastly different engineering and creative challenge.
Anyone who has actually produced long-form AI content knows the blunt truth: **AI never outputs a perfect 4-minute video in one click**.
* Gemini requires careful multi-stage prompt engineering, otherwise it starts cutting corners halfway through;
* Text-to-Image models frequently distort hands or swap character clothes;
* Image-to-Video models suffer from high deformation rates on 10-second clips, demanding multiple rerolls before landing a usable segment;
* Finally, turning these isolated raw clips into a coherent, rhythmic music video requires heavy post-production in professional NLE software like **Wondershare Filmora** for beat-cutting, visual effects, and seamless cross-dissolves.
To test how viable this end-to-end production workflow is, I remade the classic track **Crescent Bay (月牙湾)** into a high-energy J-Rock anime opening (Oriental Fantasy J-Rock) and produced a full 4-minute 1080p music video.
Here is the finished music video:
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; max-width: 100%; border-radius: 8px; margin: 2rem 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);">
<iframe
src="https://www.youtube.com/embed/OJC-Zdn232k"
title="月牙湾 (Crescent Bay) - 1080P AI Music Video"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen>
</iframe>
</div>
---
## 1. Music Generation & Re-Arrangement: Birth of an Anime Opening
The soul of an impactful video is its musical tempo and momentum. Before drafting a single visual, the first step was creating the master audio.
The original *Crescent Bay* is a classic Chinese pop track with traditional Silk Road instrumentation. To give it the visual driving force required for an anime opening, I reimagined the arrangement through AI music generation tools into an **Oriental Fantasy J-Rock / AniSong**:
* **Drums & Bass:** Introduced rapid double-bass drum rhythms and a sustained, driving distorted electric bass to build intense forward momentum into the chorus;
* **Guitars & Exotic Textures:** Kept the evocative traditional desert flute and acoustic strums during the intro, then slammed into overdriven lead electric guitars as the verses kick in;
* **Vocal Character:** Tuned for a soaring, resonant, high-energy female vocal that cuts through the heavy rock mix during the chorus.
With the completed 4-minute audio ready, I divided the track into 24 distinct 10-second timeline cells (Intro, Verse A/B, Pre-Chorus, Chorus explosions, Guitar Solo, Outro) to serve as the structural anchor for all visuals.
---
## 2. Gemini Storyboard Architecture: Multi-Stage Prompting
A common failure mode when using LLMs for storyboarding is dumping the entire lyric sheet into a single prompt and asking for "24 scene prompts." The model writes detailed paragraphs for the first three scenes, but by Scene 12 it burns through its context budget, dropping character descriptions and producing generic single-sentence prompts.
Using Gemini, I decoupled the workflow into two structured stages:
1. **Stage 1: Locking Visual DNA & Constants**
* **Character Anchors:** Fixed hairstyle (straight jet-black hair in twin-looped buns with a trailing crimson ribbon), dual-layer clothing (pale celadon-cyan organza outer robe over matte ivory silk), and age/silhouette;
* **Cinematographic Constants:** Enforced a consistent 4500K cool-jade color palette and optical traits (`ARRI Alexa 65, Panavision Primo 50mm anamorphic lens, shallow depth of field`);
* **Rule-of-Thirds Composition:** Positioned subjects strictly on one third of the frame, demanding clean, creamy out-of-focus negative space on the opposing side for typography.
2. **Stage 2: 24 Atomic Scene Prompts**
* Tasked Gemini to generate both the **Text-to-Image (T2I) visual prompt** and the corresponding **Image-to-Video (I2V) motion prompt** for each 10-second grid cell;
* Alternated between character portraits, environmental wide shots, and macro B-roll (wind chimes, rain ripples on stone, close-up of zither strings) to prevent visual fatigue from continuous close-up portraits.
---
## 3. The Reality of Video Diffusion: Iterative Rerolls & Quality Control
In real-world generative video production, the bulk of production time is spent on **iterative rerolls and quality filtering**.
### ① Text-to-Image (T2I): Fighting Drift
Even with explicit prompt anchors, image models occasionally render strange facial proportions, hallucinate western clothing, or insert unwanted faces into macro landscape shots.
* Each shot required generating 3 to 5 candidates to find the one with optimal lighting, correct silhouette, and clean negative space;
* Selected base frames served as strict visual anchors for the video diffusion stage.
### ② Image-to-Video (I2V): Controlling Micro-Motions
Passing still images into video diffusion models (Veo, Kling, Runway) for 5 to 10 seconds is where most deformations occur:
* **Never Use Negative Words in Positive Prompts:** Prompting `no morphing` or `don't distort` frequently backfires in text encoders, triggering limb anomalies. Instead, use positive stabilizing anchors: `consistent anatomy, temporal stability, subtle breathing, gentle wind blowing through hair`;
* **Micro-Motions Over Large Gestures:** In diffusion models, smaller motions look far more realistic. Complex full-body turns or rapid running almost always cause torso tearing;
* **Multiple Rerolls to Harvest Usable Takes:** Producing a 10-second clip often takes 3 or more attempts. Even if a take begins to morph at second 6, the first 5 flawless seconds can be harvested and trimmed during editing.
### ③ Locking Facial Identity via Local FaceFusion
Even when hairstyle and clothing descriptors are nailed down in prompts, text-to-image and video diffusion models exhibit subtle bone-structure and facial drift across 20+ distinct scenes. Crucially, raw diffusion cannot guarantee that the protagonist in the final video matches the hero cover art.
* **Offline Local GPU Pipeline:** Deployed **FaceFusion** locally on an NVIDIA RTX 3060 12GB;
* **Beyond 2D Affine Warping:** Bypassed basic 128px insightface warping (which causes plastic edge shimmering) in favor of high-resolution **320 DFM deep neural models**, achieving seamless 3D contour integration and natural skin lighting;
* **Restoration & Detection:** Paired with **SCRFD** face detection and **GFPGAN** neural enhancement to lock in a striking, consistent East Asian heroine face across both key close-up video cuts and the official 2.7K cover artwork.
---
## 4. Filmora Post-Production: Turning Raw Clips into a Music Video
AI models only produce disconnected video fragments. **Transforming these raw assets into a cohesive, breathing music video happens entirely in the editing suite**.
I assembled and finished the entire video in **Wondershare Filmora**:
```
[Master Audio Track] ────────────────────────────────────────────── (Beat & Rhythm Guide)
[Video Tracks 1 & 2] [ Clip 1 (Fade Out) ] ── (Cross Dissolve) ── [ Clip 2 (Fade In) ]
[VFX & Adjustment] [ Lens Flare / Dust Particles / Master Cinematic LUT ]
[Typography Layer] [ Bilingual Synchronized Artistic Subtitles ]
```
1. **Precision Beat-Cutting**:
* Imported the master audio and marked downbeats, guitar riffs, and dynamic chorus drops;
* Trimmed each AI video clip to its cleanest, most stable 5–9 second segment, aligning cuts precisely with musical transients.
2. **Eliminating Black-Screen Dips with Cross-Dissolves**:
* Hard fade-to-black transitions (`Dip to Black`) make a 4-minute video feel like an annoying strobe light;
* Used multi-track overlapping cross-dissolves with gentle easing, allowing light and motion from the preceding clip to blend smoothly into the next.
3. **Atmospheric VFX & Unified Color Grading**:
* Added subtle desert dust particle overlays and delicate anamorphic lens flares to enhance the fantasy anime atmosphere;
* Applied a master color-grading LUT across all 24 clips to harmonize luminance and color temperature variations between different generation batches.
4. **Bilingual Subtitle Styling**:
* Styled large, expressive Chinese calligraphy font lyrics paired with clear English translations beneath, backed by delicate shadows to maintain legibility against bright desert and sky backdrops.
---
## 5. Key Takeaways for Solo Creators
Producing this 4-minute *Crescent Bay* music video clarified what modern AI content creation actually looks like:
1. **AI Expands Production Bandwidth, But Human Taste Directs the Cut:** Generative models can spit out endless visual variations, but selecting the right take, controlling motion amplitude, and knowing exactly when to cut on a beat remains the domain of human creative direction.
2. **Tool Synergy Wins:**
* **Gemini** handles conceptualization, world-building, and structured storyboard prompting;
* **AI Image & Video Diffusion** generate high-fidelity raw audio-visual assets;
* **FaceFusion (Local RTX 3060)** locks protagonist facial identity and consistency across key close-ups and hero cover art;
* **Filmora** provides precise timeline control, clip assembly, rhythm synchronization, and post-processing VFX.
This collaborative workflow makes it possible for a solo creator to deliver an entire 4-minute anime music video—a project that previously required a dedicated production team—in a matter of days from a single workstation.
---
*Interested in custom AI video pipelines, automated digital media workflows, or high-throughput creative production for your business? Let's connect:*
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=AI%20Music%20Video%20Production)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
文章 05 / 08:我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的
- Slug:
how-i-built-an-automated-music-video-pipeline - 英文标题: How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP
- 分类:
case-studies| 发布日期: 2025-04-18 - 线上地址: https://blog.hoelee.com/posts/how-i-built-an-automated-music-video-pipeline/ (EN) | https://blog.hoelee.com/posts/zh/how-i-built-an-automated-music-video-pipeline/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的"
description: '基于 Remotion、Whisper、Docker 与 Chrome DevTools Protocol 的全自动音乐视频流水线:从歌词声学对齐、1080P 渲染到 YouTube 自动发布与多语种外挂字幕。'
pubDate: 2025-04-18
updatedDate: 2026-10-22
category: case-studies
blueprint: ai-agent-automation
blueprintLayer: L4-aigc-media-pipelines
stream: creative-lab
tags: ["remotion", "docker", "cdp", "youtube", "automation", "video-generation", "whisper"]
ogImage: "/og/zh-how-i-built-an-automated-music-video-pipeline.jpg"
banner: "/banners/how-i-built-an-automated-music-video-pipeline.jpg"
draft: false
---
绝大多数 AI 音乐制作流程在生成完音频文件后都会陷入停滞。你手里有一份 MP3 文件和一段纯文本歌词。如果想把它做成能在 YouTube、Bilibili 或各大社交平台上发布的成品视频,通常需要经历漫长而繁琐的手工流程:打开剪辑软件、裁剪背景画面、逐行拉字幕调关键帧、导出几个 G 的庞大文件、守着浏览器上传、手动填写封面与标签、再手动复制粘贴字幕文件。
做一首歌可能要耗费大半天,如果有几十首歌曲库,这种手工做法完全无法规模化。
我需要一条纯自动化的工业级管线:只需丢入音频文件与歌词,离开工位。几分钟后,一段 1080P 高清 MV 视频即渲染完成,自带跟随音乐动态律动的音频动效环、高精度三行滚动歌词、智能分镜人物避让切换,并全自动通过远程 CDP 发布到 YouTube,挂载自定义封面与 8 种语言的外挂同步字幕。
以下是整套系统的架构设计、核心踩坑点与线上实战方案。
> **线上成品展示**:由本管线全自动渲染并发布上线的真实曲目为:[《糖画小仙》1080p 官方 MV(YouTube)](https://youtu.be/vZbyKeCCSRA),内置精准声学对齐与 8 语同步外挂字幕(英、日、韩、西、法、德、马、繁中)。
> **TL;DR** —— 四段互不耦合的工序:Whisper 词级对齐 → Remotion 1080P 渲染 → Docker 归档落盘 → CDP 发布到 YouTube Studio。每一段各自失败、也各自可单独排查;最难缠的两段单独写了长文:[把大文件送进浏览器容器](/posts/zh/uploading-large-videos-to-youtube-via-cdp/) 与 [消除渲染抖动](/posts/zh/debugging-remotion-video-jitter/)。
---
## 1. 端到端执行流程与耗时分解
为了诚实呈现系统的实际吞吐能力,而不是笼统承诺“三分钟搞定一切”,以下是针对 4 分 21 秒(261 秒)1080P 30fps 曲目的实测端到端耗时:
| 阶段 | 处理流程与工具 | 实测耗时 | 是否真正无人值守? |
|---|---|---|---|
| **1. 歌词声学对齐** | `faster-whisper`(词级时间戳)+ 能量起始峰值锁定 | 14.2 秒 | ✅ 100% 自动 |
| **2. 构图与留白核验** | 4x2 分镜拼图生成 + 左右留白侧校验(`sides.json`) | 2.1 秒 | ⚠️ 建议 1 键人工核对 |
| **3. Remotion 1080P 渲染** | Remotion CLI(30 fps,H.264,7830 帧,i9-13900K + RTX 3060) | 218.3 秒 | ✅ 100% 自动 |
| **4. Docker 容器落盘** | Portainer Docker Archive API tar 流写入(128.85 MB 视频 + 封面) | 1.89 秒 | ✅ 100% 自动 |
| **5. YouTube 自动化发布** | Chrome CDP 自动化(注入文件、填充简介、勾选公开) | 32.4 秒 | ✅ 100% 自动 |
| **6. 多语种字幕处理** | 大模型批量翻译(8 语 × 56 句)+ CDP 自动上架发布 | 48.6 秒 | ✅ 100% 自动 |
| **全流程总计** | **从音频文件到油管 8 语公开发布** | **约 5.3 分钟** | **98% 自动化** |
4 分钟视频的渲染阶段在我们的工作站上用时 **218.3 秒**(约 3.6 分钟),随后的入库、发布与 8 语字幕处理约 1 分多钟。在分镜留白侧确认无误后,后续全链路无需任何人工交互。
---
## 2. 整体系统架构
整个自动化系统分为四个完全解耦的层级:
```
[原始 MP3 音频 + 文本歌词]
│
▼
1. 声学起唱对齐(Whisper 词级时间戳 + 声学起唱校准 -> LRC + SRT)
│
▼
2. 动态 React 视频合成(Remotion 1080P 30fps)
├─ 动态环形音频动效(320px 基础半径 -> 380px 低频弹性外扩 + 72 根跳动频谱柱)
├─ 三行平滑滚动歌词(留白侧智能滑动与人物避让)
└─ 纯净透明度叠化背景(1.8s 贝塞尔曲线过渡,彻底杜绝抖动)
│
▼
3. 容器免鉴权落盘(Portainer Docker Archive API -> 容器 /config/Downloads)
│
▼
4. YouTube 自动化发布(远程 Chrome DevTools Protocol / Playwright)
├─ 自动挂载视频与封面 cover.jpg
├─ 自动填充标题、简介、SEO 标签与官网/业务联络方式
├─ 穿透发布向导,选择公开(Public)发布
└─ 调度大模型翻译并自动挂载发布 8 国外挂字幕(英、日、韩、西、法、德、马、繁中)
```
---
## 3. 解决歌唱声学字幕对齐难题
AI 歌唱音频中普遍存在拖音、转音、颤音和节奏弹性拉伸。如果直接用常规语音转写工具识别,伴奏强烈的鼓点和乐器声往往会导致整句唱词被漏掉。
为了让咬字对齐落在**渲染帧**这一级,管线采用了**基于已知歌词的声学起唱对齐(Forced Acoustic Onset Alignment)**:
1. 以人工确认的标准歌词作为基准锚点;
2. 调用本地 `faster-whisper` 并开启词级时间戳(`word_timestamps=True`);
3. 针对每行歌词,锁定首字真实发音起振的能量起跑点,校准字幕起止点。
这样产出的 `.lrc` 与 `.srt` 即可精准对齐到**物理渲染帧**:在 30 fps 帧率下,单帧时长为 33.3 ms,这才是视频渲染真正起决定性作用的时间分辨率。当歌手唱出乐句第一个辅音的瞬间,高亮状态即在对应渲染帧切换。词级时间戳并非“微秒级精确”,这套管线也绝不作此夸大宣称——它将每行歌词对齐到声学起振点,并交由 33.3 ms 的物理帧网格做最终裁决。
---
## 4. Remotion 留白侧人物避让算法
在采用插画背景的 MV 中,一个常见痛点是歌词遮挡画面主角。如果某一镜小仙女画在画面右侧,歌词依然居中或靠右就会直接盖在人物脸上。
为了从根本上规避遮挡,管线引入了 `sides.json` 留白侧定义文件:
```json
["left", "right", "left", "right", "left", "right", "left", "right"]
```
在 Remotion 合成组件(`MusicVideo.tsx`)中,音频环与歌词容器由统一的动态坐标驱动:
```tsx
const getSideX = (idx: number) => {
const side = sides[idx] || (idx % 2 === 0 ? 'left' : 'right');
return side === 'left' ? 540 : 1380;
};
```
在每张分镜结束前的 1.8 秒内,歌词与音频环使用贝塞尔缓动平滑横向滑移。在下一张图片淡入之前,文字已平稳就位在留白侧,右侧人物与景物 100% 毫无遮挡。
---
## 5. 远程 YouTube CDP 上传通道
视频在本地 Windows 工作站上渲染,而已登录 YouTube 账号的浏览器运行在 DSM NAS 上的独立 Docker 容器(`chrome2`)中。
为了无需人工扫码或重新授权即可完成上架:
1. **Docker 归档落盘**:130MB 的 MP4 视频与封面图片通过 Portainer REST API 打包成 tar 流直接 PUT 写入容器文件系统(`/config/Downloads`),2 秒内完成落盘,彻底规避 CDP 协议的内存上限;
2. **CDP 节点注入**:通过 Playwright 接入 CDP(`ws://192.168.1.1:9242`),直接向 DOM 的文件输入节点注入容器路径:
```python
cdp.send("DOM.setFileInputFiles", {
"files": ["/config/Downloads/国风电子 糖画小仙.mp4"],
"nodeId": video_node["nodeId"],
})
```
3. **多语种字幕调度**:字幕翻译自动化脚本将基准 SRT 提取为纯文本行数组,通过本地 OmniRoute 大模型网关翻译为 8 种语言,并调用 CDP 遍历 YouTube Studio 后台,自动创建语言并发布所有外挂字幕轨。
---
## 6. 经验与教训
- **渲染与发布必须解耦**:早期版本尝试过一边渲染一边推流或直接向浏览器传 buffer,编码器稍有波动就会导致浏览器连接重置。先在磁盘写出完整 MP4 再通过归档传输,两道环节互不牵连,容错率极高。
- **合成前必须进行九宫格审查**:正式渲染全片前,先生成一张全分镜缩略拼图快速校验,肉眼确认每一张图的留白侧标记完全吻合,避免全片渲染数分钟后才发现有一处文字挡了人物。
---
## 想为你的业务打造类似的系统吗?
我专注于自动化音视频制作管线、AI 音乐与多媒体创作流,以及基于 Docker / NAS 的自托管自动化基础设施搭建。
无论你是需要自动化媒体制作系统,还是需要定制全栈软件工具:
- **WhatsApp**: [直接在 WhatsApp 上联系我](https://wa.me/60127972969)
- **邮箱**: [[email protected]](mailto:[email protected]?subject=自动化视频管线合作咨询)
- **官网**: [hoelee.com](https://www.hoelee.com/)
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP"
description: 'Explore an automated music video workflow using Remotion, Whisper, Docker and Chrome DevTools Protocol, from lyric alignment and rendering to YouTube publishing and multilingual subtitles.'
pubDate: 2025-04-18
updatedDate: 2026-10-22
category: case-studies
blueprint: ai-agent-automation
blueprintLayer: L4-aigc-media-pipelines
stream: creative-lab
tags: ["remotion", "docker", "cdp", "youtube", "automation", "video-generation", "whisper"]
ogImage: "/og/how-i-built-an-automated-music-video-pipeline.jpg"
banner: "/banners/how-i-built-an-automated-music-video-pipeline.jpg"
draft: false
---
Most AI music workflows hit a brick wall right after the track is generated. You have an MP3 file and maybe a text block of lyrics. If you want to turn that into a finished YouTube video, you typically enter a tedious manual loop: opening a video editor, cutting background footage, adjusting rolling captions line by line, setting keyframes, exporting a multi-gigabyte render, waiting for YouTube Studio to upload, typing descriptions, and pasting subtitle tracks.
Doing that once takes an afternoon. Doing that across dozens of tracks is unsustainable.
I wanted a single automated pipeline: give it an audio file and lyrics, and walk away. A few minutes later, a 1080p music video is rendered with dynamic audio-reactive visuals, synchronized three-line rolling lyrics, smart scene cut alternation, and automatically published to YouTube with 8-language external subtitles.
Here is the exact architecture, the failure points I had to solve, and the code running this in production.
> **Live Production Proof**: The full benchmark artifact rendered and published by this exact pipeline is live on YouTube: [《糖画小仙》 (Sugar Art Fairy) 1080p MV](https://youtu.be/vZbyKeCCSRA), featuring 8-language CC subtitle tracks (EN, JA, KO, ES, FR, DE, MS, ZH-Hant).
> **TL;DR** — Four decoupled stages: Whisper word-level alignment → Remotion 1080p render → Docker Archive staging → CDP upload to YouTube Studio. Each fails on its own, so each can be debugged on its own; the two that fought back hardest have their own write-ups: [staging large files into the browser container](/posts/uploading-large-videos-to-youtube-via-cdp/) and [killing render jitter](/posts/debugging-remotion-video-jitter/).
---
## 1. End-to-End Execution & Timing Breakdown
To be completely honest about performance rather than promising a vague "three minutes": here is the measured benchmark run for our 4-minute 21-second (261s) 1080p 30fps song:
| Stage | Process & Tools | Measured Time | Unattended? |
|---|---|---|---|
| **1. Audio-Lyric Alignment** | `faster-whisper` (word timestamps) + acoustic onset peak extraction | 14.2 s | ✅ 100% Autonomous |
| **2. Layout Verification** | 4x2 montage thumbnail generation + negative space side audit | 2.1 s | ⚠️ 1-click human check |
| **3. Remotion 1080p Render** | Remotion CLI (30 fps, H.264, 7,830 frames, i9-13900K + RTX 3060) | 218.3 s | ✅ 100% Autonomous |
| **4. Docker Staging** | Portainer Docker Archive API tar stream (128.85 MB MP4 + cover) | 1.89 s | ✅ 100% Autonomous |
| **5. YouTube Publication** | Headless Chrome CDP (Studio upload dialog, public visibility) | 32.4 s | ✅ 100% Autonomous |
| **6. Multilingual Subtitles** | LLM translation (8 languages × 56 cues) + CDP Studio upload | 48.6 s | ✅ 100% Autonomous |
| **Total Pipeline Cycle** | **From raw audio to public YouTube video with 8 languages** | **~5.3 minutes** | **98% Autonomous** |
The actual rendering of a 4-minute video takes **218.3 seconds** (under 3.7 minutes) on our workstation, and the upload/subtitle publication pipeline finishes in another minute. Once the storyboard layout is confirmed, the entire sequence runs with zero human intervention.
---
## 2. System Architecture
The pipeline consists of four decoupled layers:
```
[Audio MP3 + Lyrics]
│
▼
1. Acoustic Onset Alignment (Whisper word-level timestamping -> LRC + SRT)
│
▼
2. Dynamic React Video Synthesis (Remotion 1080p 30fps)
├─ Dynamic Circular Visualizer (320px base -> 380px bass expansion + 72 radial bars)
├─ 3-Line Smooth Scrolling Lyrics (Negative space side-tracking)
└─ Pure-Opacity Image Slideshow (no per-frame scaling · 1.8s Bézier crossfade)
│
▼
3. Headless Docker Staging (Portainer Docker Archive API -> Container /config/Downloads)
│
▼
4. Autonomous YouTube Publication (Remote Chrome DevTools Protocol / Playwright)
├─ Attach video & custom thumbnail
├─ Fill SEO metadata, biography, & service contact links
├─ Step through upload wizard & set visibility to Public
└─ Upload & publish 8 multilingual subtitle tracks (EN, JA, KO, ES, FR, DE, MS, ZH-TW)
```
---
## 3. Solving Acoustic Lyric Alignment
AI music generators stretch vowels, add melodic vibrato, and change tempo dynamically. Standard text-to-speech alignment or plain transcription drops entire verses when instruments overpower vocals.
Instead of blind speech recognition, I use **forced acoustic onset alignment**:
1. Take the ground-truth lyrics written beforehand.
2. Run `faster-whisper` on the vocal track with word-level timestamps (`word_timestamps=True`).
3. For each lyric line, find the closest acoustic voice activity onset for the starting character and lock the timestamp.
That yields a `.lrc` and `.srt` pair aligned to the **rendered frame**: at 30 fps one frame is 33.3 ms, so a frame is the resolution that actually matters here. When the singer lands the first consonant of a phrase, the highlight switches on that frame. Word-level timestamps are not microsecond-accurate, and the pipeline does not claim to be — it lines each line up with the acoustic onset and lets the frame grid decide.
---
## 4. Negative-Space Side Alternation in Remotion
In music videos with illustrated backgrounds, a central problem is character obstruction. If a slide shows a character standing on the right, centering the lyrics or locking them to the right covers the artwork.
To solve this deterministically, the pipeline extracts a `sides.json` layout manifest:
```json
["left", "right", "left", "right", "left", "right", "left", "right"]
```
In Remotion (`MusicVideo.tsx`), the visualizer ring and lyric stack share a single animated X coordinate:
```tsx
const getSideX = (idx: number) => {
const side = sides[idx] || (idx % 2 === 0 ? 'left' : 'right');
return side === 'left' ? 540 : 1380;
};
```
During the last 1.8 seconds of a scene, the HUD coordinates glide across the canvas using a smooth cubic Bézier curve (`Easing.bezier(0.42, 0, 0.58, 1)`). The lyrics drift gracefully into the empty negative space before the new illustration fades in, guaranteeing zero overlap with focal characters.
---
## 5. The Remote YouTube Upload Bridge
Rendering happens on a Windows workstation, while my authenticated YouTube Studio session runs inside an isolated Docker container (`chrome2`) on a Synology NAS.
To upload without browser authentication prompts:
1. **Docker Archive Staging**: The ~129 MB MP4 video and cover image package is tar-streamed directly into the container filesystem via Portainer's REST API (`PUT /api/endpoints/2/docker/containers/{id}/archive?path=/config/Downloads`). It bypasses CDP byte limits and writes in under 2 seconds. The full pattern — code, tar caveats, and what it does not solve — is in [Uploading 100MB+ Videos to YouTube via CDP](/posts/uploading-large-videos-to-youtube-via-cdp/).
2. **CDP File Injection**: Using Playwright over CDP (`ws://192.168.1.1:9242`), the upload file input receives the local container path:
```python
cdp.send("DOM.setFileInputFiles", {
"files": ["/config/Downloads/国风电子 糖画小仙.mp4"],
"nodeId": video_node["nodeId"],
})
```
3. **Multilingual Subtitle Dispatch**: An automated worker splits the calibrated SRT into an array of lines, translates them into 8 languages (English, Japanese, Korean, Spanish, French, German, Malay, Traditional Chinese) via our local OmniRoute LLM gateway, and uploads each `.srt` file through the Studio Translations interface.
---
## 6. What I Would Do Differently
- **Decouple rendering from upload**: Early revisions tried to pipe ffmpeg buffers directly into a browser tab. Encoding hiccups would freeze the browser socket. Saving the MP4 locally first and staging via Docker Archive completely isolated the two failure domains.
- **Strict layout verification**: Always generate a 4x2 montage thumbnail of all storyboard scenes before starting full rendering. Visually asserting the negative space of each slide prevents re-rendering four-minute videos due to a single misplaced side flag.
---
## Want this for your business?
I build automated video generation pipelines, custom music production workflows, and self-hosted infrastructure for creators, educators, and brands.
If you want to automate your media workflow or need custom full-stack tools:
- **WhatsApp**: [Chat directly on WhatsApp](https://wa.me/60127972969)
- **Email**: [[email protected]](mailto:[email protected]?subject=Automated%20Video%20Pipeline%20Inquiry)
- **Website**: [hoelee.com](https://www.hoelee.com/)
文章 06 / 08:我是如何构建 DigiKedai Telegram AI 客服机器人的
- Slug:
how-i-built-the-digikedai-telegram-bot - 英文标题: How I Built the DigiKedai Telegram AI Bot
- 分类:
case-studies| 发布日期: 2026-09-06 - 线上地址: https://blog.hoelee.com/posts/how-i-built-the-digikedai-telegram-bot/ (EN) | https://blog.hoelee.com/posts/zh/how-i-built-the-digikedai-telegram-bot/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "我是如何构建 DigiKedai Telegram AI 客服机器人的"
description: "一个跨 Telegram 与 WhatsApp 解答疑问并全自动开通免费试用账号的 AI 客服机器人——TypeScript、grammY、Cloudflare 隧道 webhook,以及自建 LiteLLM 网关。"
pubDate: 2026-09-06
category: case-studies
blueprint: ai-agent-automation
blueprintLayer: L1-deterministic-orchestration
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/zh-how-i-built-the-digikedai-telegram-bot.jpg
banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg
summary:
audience: "企业主与产品/运营负责人"
painPoint: "一人数字商品电商面临大量重复售前咨询(试用申请、账号开通、选品),夜间与周末漏单严重,人工客服成本高。"
impact:
- metric: "24/7 秒级响应"
detail: "全天候接管 Telegram 与 WhatsApp 双端咨询,零人工介入自动发放免费试用账号"
- metric: "0 价格幻觉"
detail: "三层确定性 SKU 检索 + 状态机拦截购买意图,杜绝大模型胡报价或泄漏内部路径"
- metric: "~175 项单测护航"
detail: "单容器稳定运行于 NAS,通过自建 LiteLLM 网关实现模型高可用切换"
stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"]
jumpLinks:
business: "#why-a-business-needs-a-bot-like-this"
architecture: "#the-architecture"
result: "#the-result"
dynamicCta: true
---
Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:*「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」*。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。
这篇讲的是它是怎么搭起来的——架构、LLM 接线,以及那些各自吃掉我一整个下午的 bug。
## 为什么企业需要一个这样的机器人 <a id="why-a-business-needs-a-bot-like-this"></a>
讲架构之前,先说*为什么*。客服机器人不是噱头——它改变了小生意的经济学:
- **省钱省人力。** 机器人每回答一个常规问题,你的团队就少答一个。Digi Kedai 用雇一个客服几分之一的成本,就得到了一个全天候的客服专员。
- **全天候即时响应。** 顾客会在半夜和周末提问。机器人几秒钟内用他们的语言回复——没有排队,没有「我们稍后回复」。
- **把浏览者变成买家。** 机器人不只是回答——它还会*向上销售*。一句「有免费试用吗?」几下点击就变成一个已领取的试用账号,全程无需人工。
- **永远记得顾客。** 长期记忆让回头客被当作熟客问候,而不是陌生人。
- **随商品目录扩展。** 加一个商品,机器人就已经认识它了——无需重新训练,无需新的 FAQ 页面。
对 Digi Kedai 这样一个人的生意来说,这就是在凌晨两点丢单和成交之间的差别。
## 技术栈
- **TypeScript + Node 20**,作为单个容器跑在我的 Synology NAS 上。
- **grammY**——一个轻量的 Telegram Bot 框架,用 webhook 模式接线。
- **Hono**——一个极小的 HTTP 服务器,负责接收 webhook 并响应 `/health`。
- **Cloudflare 隧道**——`bot.digikedai.com` 直连容器;无需公网 IP,无需开放端口。
- **LiteLLM**——一个位于真实模型之前的自托管网关,这样我可以不碰机器人代码就切换供应商或加故障转移。
- **PostgreSQL**——对话与用户记忆存储,与我的 mem0 栈共用。
## 架构 <a id="the-architecture"></a>
```
顾客 → Telegram/WhatsApp → Cloudflare 隧道 → bot.digikedai.com
→ Hono POST /<secret>/webhook → grammY 处理器
→ 存入 PostgreSQL → Agent.respond(系统提示词 + 检索到的商品 facts + 最近 20 条消息)
→ LiteLLM(模型别名 "mem0-openai")→ 持久化 + 回复
```
几个值得解释的决策:
**Cloudflare 隧道直连容器,不经过 Traefik。** 我管理的自托管生产节点在 CGNAT 后面,没有隧道就没有任何东西能被公网访问。webhook URL 藏在一个**秘密路径段**(`/<secret>/webhook`)后面,这样 Telegram 的更新只有知道这个 secret 的人才能送达——这是在 Telegram 自带 token 认证之外的第一道廉价防线。
**模型用 LiteLLM 的*别名*寻址,从不用原始模型名。** 机器人调用 `mem0-openai`;LiteLLM 把它映射到真实模型(背后还有 OpenRouter 故障转移)。机器人永远不需要知道实际是哪个供应商在服务请求。我设了 `temperature: 0.4` 和 `45s` 超时。
**机器人知道自己的商品目录,但不含价格和内部路径。** 一个构建时的生成器把单一的 `catalog_sku.csv`(唯一数据源)转成机器人导入的 TypeScript 模块——只含 SKU、名称、分类、大小和商品 URL,*别的什么都不带*。价格从不定死(「以页面当前价为准」),内部资源路径从不会进机器人镜像。这让打包体积精简,也防止模型泄露内部结构。
## 检索:机器人如何真正*知道*商品目录
把整个目录塞进提示词里的通用 LLM 会瞎编。所以机器人先检索、再回答。它有一个三档检索器:
1. **SKU token 匹配**——`/\b[A-Z]{2,}\d{2,}\b/gi` 能精确命中 `CZH01` 这样的 SKU。
2. **整句子串匹配**——针对短而精确的查询。
3. **分段匹配**——切分 CJK 连续串(剥离「有/吗/哪些」这类疑问助词),并过滤英文停用词。
前 5 个匹配结果成为注入系统提示词的「facts」,模型被要求*只*基于实际检索到的内容回答——查不到时也要明说。
这比看起来更重要。顾客问*「CZH01 有免费版吗?」*之所以能得到关于 `FREECZH01` 的正确回答,是因为检索器在命中付费 SKU 时,会**自动附带它的免费 twin** 并排在第二位。这个细节把一句「抱歉,没有」变成了正确的向上销售。
## 免费试用开通——全程无需人工
我最引以为豪的部分:机器人不只是*谈论*免费试用,而是真正*发放*它们。顾客有三种方式触发——`/trial` 命令、像*「我要这个试用:digikedai.com/products/czh01」*这样的自然语言消息,或者 `?start=CZH01` 这样的深链。
机器人不经过 n8n,而是**直接写入 NocoDB**——插入一条顾客记录和一条顾客-商品记录。我已有的 webhook 接手并自动开通真实账号,所以机器人从不碰文件服务器。整个过程是幂等的:如果顾客已有账号,就复用它而不是新建重复的;如果商品插入失败,就回滚顾客记录,这样重试不会卡死。
甚至还有多账号选择器。如果同一个 Telegram 用户有好几个账号,机器人会把它们列成内联按钮,问要把试用挂到哪个账号上。
## 那些各自吃掉我一整个下午的 bug
上线并不顺利。有三个值得讲的调试故事:
**1. 无限回复循环。** 如果服务器没在超时窗口内确认,Telegram 会重新投递同一条 webhook 更新。我的处理器跑得久(LLM 延迟),于是 Telegram 重发了同一条消息——机器人就一遍又一遍地回答它。修复是 `onTimeout: "return"` 加 50 秒窗口,这能让重投递的螺旋戛然而止。
**2. 模型别名 400。** 直接调用原始模型名(`gpt-5-mini`)返回 400。只有 LiteLLM 别名能通。这现在是仓库里的一条硬性规则:*LLM 必须用别名,从不用原始上游名。*
**3. WhatsApp 的「m_text」bug。** 当我加 WhatsApp 通道(通过一个把 WhatsApp Web 事件 POST 到 webhook 的浏览器扩展)时,一个「自定义 webhook payload」设置里空字符串的模板值让扩展把字面字段*名* `m_text` 当成了消息文本——于是机器人回复*「我不明白 m_text」*。修复是关掉那个设置、改用默认 payload,适配器就能正确读取了。
三个故事共同的诚实结论是:故障不在难的部分——LLM 或检索。而在 **webhook 生命周期和 payload 契约的细节**,那些集成真正出问题的无聊边角。
## 在 LLM 之上叠加确定性流程
LLM 擅长开放式问题,却很不擅长*状态*。所以那些需要可靠的对话环节——购买、试用领取、深链——都是**确定性状态机**,而非提示词工程:
- **购买意图**(`order`、`want to buy`、`buy`…)在进入 LLM 之前就被拦截,驱动一个内联键盘(网店 → 管理员 → 返回),从不给自由文本回复。
- **深链**把 SKU 放在 `?start=` payload 里,但 Telegram 那里只允许 `A-Z a-z 0-9 _ -`——一个 `:` 会静默地把它弄坏。我吃过大亏,于是把分隔符从 `buy:SKU` 换成了 `buy-SKU`。
- **WhatsApp 里的跑题消息**会让模型输出一个哨兵值(`NO_REPLY`),代理把它变成*「什么都不发」*——这样机器人在没话可说时保持沉默,而不是胡言乱语。
## 我会做不同的地方
- **启动时就加 `setMyCommands()`。** 命令列表定义在代码里,但我从没在 Telegram 注册它,所以聊天框里的命令菜单一直是空的,直到我用 Bot API 修好。小事,但因此损失了一个真实的顾客触点。
- **在写通道适配器之前先定好 payload 契约。** WhatsApp 的 `m_text` bug 就来自我信任了一个没读过契约的自定义功能。
- **把 webhook 超时当作一等架构**,而不是事后补丁——如果我在第一天就想到确认窗口,重投递循环本可以避免。
## 结果 <a id="the-result"></a>
现在 NAS 上的一个容器就能跨两个通道(Telegram + WhatsApp)解答顾客问题,按 SKU 或自然语言检索到正确商品,并端到端发放免费试用账号——还有一套约 175 个单元测试覆盖确定性流程、检索和 payload 契约。源码在
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot)。
如果你也在打造一个 LLM 驱动的 Telegram 机器人,教训其实很朴素:模型是最容易的部分。webhook 生命周期、payload 契约、以及开通流程的幂等性才是真正会出问题的地方——先设计好这些。
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "How I Built the DigiKedai Telegram AI Bot"
description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway."
pubDate: 2026-09-06
category: case-studies
blueprint: ai-agent-automation
blueprintLayer: L1-deterministic-orchestration
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/how-i-built-the-digikedai-telegram-bot.jpg
banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg
summary:
audience: "Business Owners & Product Leads"
painPoint: "A solo digital goods business faces a non-stop stream of repetitive pre-sales questions (trial requests, account setup, package selection) — leading to missed midnight sales and high manual support overhead."
impact:
- metric: "24/7 Instant Reply"
detail: "Handles customer inquiries across Telegram and WhatsApp with zero human intervention for free-trial provisioning."
- metric: "Zero Price Hallucination"
detail: "3-tier deterministic SKU retrieval and state-machine purchase interception prevent hallucinated prices or leaked internal paths."
- metric: "~175 Unit Tests"
detail: "Runs reliably in a single container on a Synology NAS with LiteLLM gateway failover."
stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"]
jumpLinks:
business: "#why-a-business-needs-a-bot-like-this"
architecture: "#the-architecture"
result: "#the-result"
dynamicCta: true
---
Digi Kedai sells digital products — online courses, ebooks, and templates — and
every sale involves a stream of the same customer questions: *"does this course
have a free preview?", "how do I get my account?", "which package should I
buy?"*. Answering them manually doesn't scale for a one-person business. So I
built an AI customer-support bot that answers those questions around the clock
and can even hand out free-trial accounts without a human in the loop.
This is the story of how it went together — the architecture, the LLM wiring,
and the bugs that ate an afternoon each.
## Why a business needs a bot like this
Before the architecture, the *why*. A support bot isn't a gimmick — it changes
the economics of a small business:
- **Saves money on staff.** Every routine question a bot answers is one your
team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock
support agent for a fraction of the cost of hiring one.
- **Answers instantly, 24/7.** Customers ask at midnight and on weekends. A bot
replies in seconds, in their language — no queues, no "we'll get back to you".
- **Converts browsers into buyers.** The bot doesn't just answer — it *upsells*.
A "does this have a free trial?" question turns into a claimed trial account
in a couple of taps, with no human in the loop.
- **Never forgets a customer.** Long-term memory means repeat customers are
greeted like regulars, not strangers.
- **Scales with your catalogue.** Add a product and the bot already knows it —
no retraining, no new FAQ pages.
For a one-person business like Digi Kedai, that's the difference between losing
sales at 2 a.m. and closing them.
## The stack
- **TypeScript + Node 20** running as a single container on my Synology NAS.
- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode.
- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`.
- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container;
no public IP, no open ports.
- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap
providers or add fallbacks without touching bot code.
- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack.
## The architecture
```
Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
→ Hono POST /<secret>/webhook → grammY handler
→ save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs)
→ LiteLLM (model alias "mem0-openai") → persist + reply
```
A few decisions worth explaining:
**A Cloudflare tunnel into the container, with no Traefik hop.** The self-hosted infrastructure is
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
updates only land if the caller knows the secret — a cheap first line of defense
on top of Telegram's own token auth.
**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot
calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter
fallback behind it). The bot never needs to know which provider is actually
serving the request. I set `temperature: 0.4` and a `45s` timeout.
**The bot knows its catalogue without prices or internal paths.** A build-time
generator turns a single `catalog_sku.csv` (the source of truth) into a
TypeScript module the bot imports — SKU, name, category, size, and a product URL,
and *nothing else*. Prices are never baked in ("check the site for the current
price"), and internal resource paths never ship to the bot image. That keeps the
bundle lean and prevents the model from leaking internal structure.
## Retrieval: how the bot actually *knows* the catalogue
A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the
bot retrieves first, then answers. It has a three-tier retriever:
1. **SKU token match** — `/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`.
2. **Whole-query substring** — for short, precise queries.
3. **Per-segment matching** — splits CJK runs (stripping question particles like
有/吗/哪些) and filters English stopwords.
The top-5 matches become the "facts" injected into the system prompt, and the
model is instructed to answer *only* from what it actually retrieved — and to say
so when it finds nothing.
This matters more than it looks. A customer asking *"does CZH01 have a free
version?"* gets a correct answer about `FREECZH01` only because the retriever,
on hitting the paid SKU, **automatically attaches its free twin** and ranks it
second. That one detail turns a "no, sorry" into the correct upsell.
## Free-trial provisioning — no human in the loop
The part I'm proudest of: the bot doesn't just *talk about* free trials, it
*issues* them. A customer can trigger it three ways — the `/trial` command, a
natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a
deep link like `?start=CZH01`.
Rather than route through n8n, the bot writes **directly to NocoDB** — inserting
a customer row and a customer-product row. My existing webhooks pick that up and
provision the actual account automatically, so the bot never touches the file
server. The whole thing is idempotent: if a customer already has an account, it
reuses it instead of creating a duplicate; if the product insert fails, it rolls
back the customer row so a retry doesn't dead-end.
There's even a multi-account selector. If the same Telegram user has several
accounts, the bot lists them as inline buttons and asks which one to attach the
trial to.
## The bugs that ate an afternoon each
Shipping this was not smooth. Three debugging stories stand out:
**1. The infinite reply loop.** Telegram re-delivers a webhook update if the
server doesn't acknowledge it within a timeout window. My handler was running
long (LLM latency), so Telegram re-sent the same message — and the bot answered
it again, and again. The fix was `onTimeout: "return"` with a 50-second window,
which stops the redelivery spiral dead.
**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a
400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM
must be the alias, never the raw upstream name.*
**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook
payload" setting with empty-string template values made the extension send the
literal field *name* `m_text` as the message text — so the bot replied *"I don't
understand m_text"*. The fix was to turn that setting off and use the default
payload, which the adapter reads correctly.
The honest takeaway from all three: the failures weren't in the hard parts — the
LLM or the retrieval. They were in **webhook lifecycle and payload-contract
details**, the boring edges where integrations actually break.
## Deterministic flows on top of the LLM
An LLM is great for open-ended questions and terrible for *state*. So the
conversational pieces that need reliability — buying, trial redemption, deep
links — are **deterministic state machines**, not prompt engineering:
- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM
and drives an inline keyboard (online store → admin → back), never a free-form
reply.
- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows
`A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way
and switched the separator from `buy:SKU` to `buy-SKU`.
- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`)
that the agent turns into *"send nothing"* — so the bot stays silent instead of
babbling when it has nothing useful to say.
## What I'd do differently
- **Add `setMyCommands()` at startup.** The command list is defined in code, but
I never registered it with Telegram, so the in-chat command menu was empty
until I fixed it via the Bot API. Small, but it cost a real customer touchpoint.
- **Design the payload contract before the channel adapter.** The WhatsApp
`m_text` bug came from trusting a customization feature I hadn't read the
contract for.
- **Treat webhook timeouts as first-class architecture**, not an afterthought —
the redelivery loop was avoidable if I'd thought about acknowledgement windows
on day one.
## The result
One container on the NAS now answers customer questions across two channels
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural
language, and issues free-trial accounts end-to-end — with a prompt suite of
~175 unit tests covering the deterministic flows, retrieval, and the payload
contracts. The source lives at
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot).
If you're building your own Telegram bot backed by an LLM, the lesson is the
boring one: the model is the easy part. The webhook lifecycle, the payload
contract, and the idempotency of your provisioning are where it actually breaks —
design those first.
文章 07 / 08:我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD
- Slug:
how-i-host-this-blog - 英文标题: How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD
- 分类:
case-studies| 发布日期: 2026-09-04 - 线上地址: https://blog.hoelee.com/posts/how-i-host-this-blog/ (EN) | https://blog.hoelee.com/posts/zh/how-i-host-this-blog/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD"
description: "这篇文章完整拆解了这个网站背后的构建与部署管线——以 Git 作为 CMS、跑在 unRaid 服务器上的自托管 runner,以及最前端的 Cloudflare。"
pubDate: 2026-09-04
category: case-studies
blueprint: sme-digital-suite
blueprintLayer: L1-high-conversion-web
dynamicCta: true
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
ogImage: /og/zh-how-i-host-this-blog.jpg
banner: /banners/how-i-host-this-blog.jpg
---
这个博客本身,就是我用来说明「我到底会做什么」的一个项目。下面是完整的
管线,让整个架构一目了然。源代码公开在
[git.hoelee.com/hoelee/hoelee-blog](https://git.hoelee.com/hoelee/hoelee-blog),
可以对照着这篇文章一起看。
## 技术栈
- **Astro 5** —— 从 Markdown 生成静态站点。
- **Git 即 CMS** —— 每一篇文章都是一个带 YAML frontmatter 的 `.md` 文件,版本管理在 Gitea 里。
- **Gitea Actions** —— 一个跑在我 unRaid 服务器上的自托管 CI runner(`act_runner`),每次 push 到 `main` 都会自动构建站点。
- **nginx** —— 一个独立的容器负责对外提供静态的 `dist/` 输出。
- **Cloudflare** —— 隧道把它暴露到公网,CDN 负责缓存一切。
## 为什么选「静态 + Git」?
技术博客没有任何动态内容。用 Markdown 写作、用 pull request 评审、合并后重新构建,带给我的是:
1. 我发布过的每一个字的完整版本历史。
2. 没有需要加固、打补丁的数据库或后台管理面板。
3. 接近完美的性能——预构建的 HTML,没有任何服务端渲染。
## 部署流程
```
git push → Gitea webhook → act_runner 领取任务
→ npm ci → astro build → 拷贝 dist/* 到站点根目录 → nginx 提供服务
```
整个过程都跑在我自己管理的基础设施硬件上——这正是重点:它就是我别处所描述的
DevOps 工作的一个活生生的演示。
## docker-compose 配置
下面是这套栈的大致形态。这是本地/源站这一侧(Gitea + runner + nginx);
公网暴露由 Cloudflare 通过隧道单独处理,所以这里的任何东西都不需要公网 IP
或开放端口。
<details>
<summary>docker-compose.yml — Gitea + act_runner + nginx (40 lines)</summary>
```yaml
# docker-compose.yml — Gitea + act_runner + nginx(博客站点根目录)
services:
gitea:
image: gitea/gitea:1.27
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- ./gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:22" # SSH(可选——我用 HTTPS)
restart: unless-stopped
act_runner:
image: gitea/act_runner:0.2.13
container_name: act_runner
environment:
- GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # runner 需要它来拉起构建任务
- ./runner-data:/data
depends_on:
- gitea
restart: unless-stopped
nginx-blog:
image: nginx:alpine
container_name: nginx-blog
volumes:
- ./html:/usr/share/nginx/html:ro # astro 构建产物就落到这里
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
```
</details>
CI 任务(`deploy.yml`)先在 runner 上构建出 `dist/`,然后 `cp -r dist/*` 到
与 nginx 容器共享的 `./html` 卷里。Cloudflare 的隧道守护进程(`cloudflared`)
指向 `nginx-blog`——那是唯一暴露到公网的东西。
有两件事值得特别说明,它们都花了我不少时间:
1. **`act_runner` 需要挂载 Docker socket**(`/var/run/docker.sock`)才能拉起
构建任务——没有它,任务会一直卡在排队状态。
2. **敏感凭据彻底外置**:宿主机注册 Runner 所用的 `GITEA_RUNNER_REGISTRATION_TOKEN` 通过宿主机未纳入版本管理的本地 `.env` 注入(`${RUNNER_TOKEN}`),而 CI 构建拉取代码所用的令牌则存储在 Gitea 的 Actions Secret 中(workflow 内引用为 `${{ secrets.PAT }}`),仓库源码中绝不硬编码任何凭据——因此整个仓库可以安全地完全公开。
## 安装时的坑(真实经历,来自我的 commit 历史)
把这条管线跑通并不顺利——这个仓库的 git 历史就是一本「坑记录」。真正花时间的
有四个:
1. **Gitea 上没有 `actions/checkout`。** Gitea 的 `act_runner` 不自带 GitHub
marketplace 的那些 action。我的第一个 workflow 立刻就失败了——因为压根没有
东西去 checkout 仓库。解决办法:去掉 `actions/checkout` 这一步,手动 clone:
```yaml
- name: Checkout
run: |
git config --global --add safe.directory '*'
git clone --depth 1 "https://hoelee:${{ secrets.PAT }}@git.hoelee.com/hoelee/hoelee-blog.git" .
```
2. **`GITHUB_TOKEN` 注入无法通过 clone 的身份验证。** 默认的 runner token
不足以 `git clone` 一个私有仓库,所以任务在 checkout 阶段就失败了。解决办法:
创建一个专门的 **Personal Access Token**,并在 workflow 里引用为
`${{ secrets.PAT }}`——绝不要硬编码进去(这也是为什么这个仓库可以放心公开)。
3. **Gitea 与 `act_runner` 的版本不匹配。** 我花了三个 commit
("test gitea 1.24.7 + act_runner 0.2.13" → "gitea 1.25.5" → "gitea 1.27.3 +
runner 3.3.2")才让 runner 成功注册并领取任务。教训是:**关注 Gitea Actions 生态的架构迁移**——Gitea 1.27+ 将原有的 `act_runner` 重构演进为新版 `gitea/runner`(版本号跳转至 3.x 分支,配置项如 `container.valid_volumes` 等结构均有调整)。如果 runner 镜像或协议落后于 Gitea 实例,任务就会静默失败或无法拉起容器。
4. **runner 需要 `config_file` 环境变量。** 只有显式传入配置文件路径,runner
才会连到正确的实例。
每一个都是「挠头一小时、修复一行代码」——而这恰恰是一篇博客文章最该帮后来人
省下的东西。
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD"
description: "A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front."
pubDate: 2026-09-04
category: case-studies
blueprint: sme-digital-suite
blueprintLayer: L1-high-conversion-web
dynamicCta: true
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
ogImage: /og/how-i-host-this-blog.jpg
banner: /banners/how-i-host-this-blog.jpg
---
This blog is itself a project I built to demonstrate the kind of work I do.
Here's the full pipeline, so the architecture is transparent. The source is
public at [git.hoelee.com/hoelee/hoelee-blog](https://git.hoelee.com/hoelee/hoelee-blog)
— read it alongside this post.
## The stack
- **Astro 5** — static site generated from Markdown.
- **Git as the CMS** — every post is a `.md` file with YAML frontmatter, versioned in Gitea.
- **Gitea Actions** — a self-hosted CI runner (`act_runner`) on my unRaid server builds the site on every push to `main`.
- **nginx** — a dedicated container serves the static `dist/` output.
- **Cloudflare** — the tunnel exposes it publicly, and the CDN caches everything.
## Why static + git?
A technical blog has no dynamic content. Writing in Markdown, reviewing via
pull request, and rebuilding on merge gives me:
1. Full version history of every word I publish.
2. No database or admin panel to secure or patch.
3. Near-perfect performance — pre-built HTML, no server-side rendering.
## The deploy flow
```
git push → Gitea webhook → act_runner picks up the job
→ npm ci → astro build → copy dist/* to docroot → nginx serves it
```
The whole thing runs on the self-hosted production infrastructure I manage, which is exactly the point —
this is a live demo of the DevOps work I describe elsewhere.
## The docker-compose setup
Here's the shape of the stack. This is the local/origin side (Gitea + the
runner + nginx); Cloudflare handles public exposure separately via a tunnel,
so nothing here needs a public IP or open ports.
<details>
<summary>docker-compose.yml — Gitea + act_runner + nginx (blog docroot) (40 lines)</summary>
```yaml
# docker-compose.yml — Gitea + act_runner + nginx (blog docroot)
services:
gitea:
image: gitea/gitea:1.27
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- ./gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:22" # SSH (optional — I use HTTPS)
restart: unless-stopped
act_runner:
image: gitea/act_runner:0.2.13
container_name: act_runner
environment:
- GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # runner spawns build jobs
- ./runner-data:/data
depends_on:
- gitea
restart: unless-stopped
nginx-blog:
image: nginx:alpine
container_name: nginx-blog
volumes:
- ./html:/usr/share/nginx/html:ro # astro build output lands here
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
```
</details>
The CI job (`deploy.yml`) builds `dist/` on the runner, then `cp -r dist/*`
into the `./html` volume shared with the nginx container. Cloudflare's tunnel
daemon (`cloudflared`) points at `nginx-blog` — that's the only thing exposed
to the internet.
Two things worth calling out that cost me time:
1. **`act_runner` needs the Docker socket** (`/var/run/docker.sock`) mounted to
spawn build jobs — without it, jobs sit queued forever.
2. **Strict credential externalization**: The runner's `GITEA_RUNNER_REGISTRATION_TOKEN` is injected via an untracked local host `.env` file (`${RUNNER_TOKEN}`), while the build checkout token lives in Gitea Actions Secrets (`${{ secrets.PAT }}` in the workflow). Nothing sensitive is hardcoded in the repo, keeping it safe to make public.
## The installation pain (real, from my commit history)
Getting this pipeline working was *not* smooth — the git history of this repo
is a log of the gotchas. The four that cost real time:
1. **No `actions/checkout` on Gitea.** Gitea's `act_runner` does not ship
GitHub's marketplace actions. My first workflow failed immediately — there
was nothing to check out the repo. Fix: drop the `actions/checkout` step
and clone manually:
```yaml
- name: Checkout
run: |
git config --global --add safe.directory '*'
git clone --depth 1 "https://hoelee:${{ secrets.PAT }}@git.hoelee.com/hoelee/hoelee-blog.git" .
```
2. **`GITHUB_TOKEN` injection doesn't authenticate the clone.** The default
runner token wasn't enough to `git clone` a private repo, so jobs failed on
checkout. Fix: create a dedicated **Personal Access Token** and reference it
as `${{ secrets.PAT }}` — never hardcode it in the workflow (that's also why
this repo is safe to make public).
3. **Gitea ↔ `act_runner` version mismatch.** I burned three commits
("test gitea 1.24.7 + act_runner 0.2.13" → "gitea 1.25.5" → "gitea 1.27.3 +
runner 3.3.2") before the runner would register and pick up jobs. The
lesson: **track the Gitea Actions architecture migration** — Gitea 1.27+
transitioned from legacy `act_runner` to `gitea/runner` (the 3.x branch with
revised container volume directives). When the runner image or protocol falls
behind the server instance, jobs fail silently or refuse to spawn containers.
4. **`config_file` env for the runner.** The runner needed its config path
passed explicitly before it would connect to the right instance.
Each of these was a "one-line fix after an hour of head-scratching" — which is
exactly the kind of thing a blog post should save the next person from.
文章 08 / 08:我用 Suno AI 做出自己的歌
- Slug:
how-i-made-my-own-songs-with-suno-ai - 英文标题: How I Made My Own Songs with Suno AI
- 分类:
case-studies| 发布日期: 2026-09-11 - 线上地址: https://blog.hoelee.com/posts/how-i-made-my-own-songs-with-suno-ai/ (EN) | https://blog.hoelee.com/posts/zh/how-i-made-my-own-songs-with-suno-ai/ (ZH)
- 本篇审核状态: ⏳ 待审核 (Pending)
✍️ 顾问审阅意见区 (Review Feedback)
【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构
【事实与数字核对】:
【人设与用词卫生】:
【CRO与转化建议】:
🇨🇳 中文版原文 (Chinese Markdown Twin)
---
title: "我用 Suno AI 做出自己的歌"
description: "自己写词、调风格提示词,用 Suno AI 做出七首原创歌曲——民谣、儿歌、动漫风心经。完整工作流 + 可直接播放的成品。"
pubDate: 2026-09-11
category: case-studies
stream: creative-lab
tags: [suno, ai-music, music, lyrics, prompting, ai]
ogImage: /og/zh-how-i-made-my-own-songs-with-suno-ai.jpg
banner: /banners/how-i-made-my-own-songs-with-suno-ai.jpg
---
我做自己的歌。不是靠弹奏乐器——而是写歌词、打磨风格提示词,让 Suno AI
负责唱腔、编曲和混音。这篇是完整工作流:当前模型家族怎么用、我实际往
提示框里打什么,以及七首可以直接播放的成品。
## 为什么用 AI 做自己的音乐?
两个理由,一个私人,一个实际。
**私人方面:** 我喜欢拿生成式 AI 做实验。一首歌是紧凑、自包含的项目——
歌词、风格、结构、演唱——练的是和搭 bot、接管道一模一样的提示词功力。
而且成品比配置文件容易分享得多。
**实际方面:** 这已经是一项能接单的技能。中小企业需要广告歌、视频背景
音乐、活动配乐、品牌音频——但不一定有录音室预算。如果我凭一页简报就能
交付一首可用的曲子,那是服务,不是魔术。Suno 现在的 **v6** 系列模型
(2026-09-09 发布)比我 2024 年 4 月刚开始用的 V3 时代可控太多。
## 当前标准:2026 年 Suno 模型长什么样
网上旧教程引用的模型早就没了,先把版本理清楚:
| 模型 | 时间 | 谁可用 |
|---|---|---|
| **v6 / v6-wild** | 2026-09-09 | 付费(旗舰 + 实验版) |
| **v6-mini** | 2026-09-09 | 人人可用——快、省,适合打草稿 |
| **v5.5** | 2026-03-26 | 付费——新增 Voices、Custom Models、My Taste |
| **v4.5-all** | 2025-10-21 | 免费版(无商用权,须署名) |
| V4 / V4.5 / V5 | 2024–2026 | 付费,已被取代 |
v6 家族新增的控制功能,直接改变了我做事的方式:
- **自然语言段落编辑**——「把副歌改成福音合唱团唱」只改一段,不重刷整首。
- **单句歌词替换**——「把 love 改成 light」只动一行。
- **Mashup 混搭**——一首歌的人声 + 另一首的鼓 + 新歌词,一次请求搞定。
- **采样→分离→重建**——取 0:45 的 riff,分离吉他,围绕它做鼓点。
- **多模态输入**——可以从文字、音频、图片甚至视频起步。
下面我自己的曲子是更早的 V4 时代工作流(2025 年 4 月)做的。底层逻辑——
风格字段、歌词元标签、迭代——没变过,v6 能省哪几步我也会注明。
## 我的工作流:先词后风格
顺序比多数人以为的重要得多。新手通常打开 Suno,在 Simple Mode 里贴个
类型,得到一首千篇一律的歌。我反过来:
1. **先把歌当文档写出来**——标题、结构、歌词。情感和故事来自我,
Suno 负责表演。
2. **打磨风格提示词**——类型 + 情绪 + 年代 + 乐器 + 人声人设 + 制作,
往往 200+ 字符,描述的是歌曲的*旅程*,不只是类型。
3. **给歌词加元标签**——`[Verse]`、`[Chorus]`、`[Bridge]`、`[Whispered]`、
`[Belted]`。没有这些,Suno 默认输出平淡的主歌-副歌-主歌,没有情绪弧线。
4. **每首生成 3–5 个 take** 留最好的。用 Extend/Reuse Prompt 修补,
每次扩展都要重述类型——风格会漂移。
5. **纠正发音**——AI 歌手是按读音读词的,所以要改写拼写
("through" → "thru"、生僻字加连字符),生僻词先用短片段测。
下面这个完整例子展示这一切长什么样。
## 成品歌曲
七首歌,托管在我自己的媒体子域名(`content.hoelee.com`,走 Publitio),
用原生 HTML5 audio 内嵌,点播放即可:
<style>
.suno-track{margin:2.2rem 0}
.suno-track audio{width:100%;max-width:560px}
.suno-box{position:relative;max-height:250px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6}
.suno-box p{margin:.18rem 0;opacity:.55}
.suno-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
</style>
### 1. 轮回的渡船
古风民谣。忘川的摆渡人、冷掉又添的孟婆汤、一个拒绝再入轮回的灵魂。
写成跨越轮回选择一个人的神话式爱情故事:稀疏弦乐里低声起,副歌推满、
撕心裂肺。
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-samsara" src="https://content.hoelee.com/file/hoelee/music/%E8%BD%AE%E5%9B%9E%E7%9A%84%E6%B8%A1%E8%88%B9.mp3"></audio>
<pre id="lrc-samsara" hidden>[00:12]河岸的雾漫过第三千个秋
[00:18]我的桨声碎在无人渡口
[00:26]忘川水打湿褪色的袖
[00:30]你背影是前世未燃尽的篝火
[00:33]佛说众生如萍聚散无由
[00:39]我却数遍每一颗星斗
[00:46]等残月照亮你回眸——
[00:53]偏偏人间雪,落满我舟头
[01:04]我摇着轮回的船,载不动红尘重如山
[01:10]孟婆的汤冷了又添,你宁化涟漪不成全
[01:20]若执念是穿心的箭,刺透因果的链
[01:27]我愿在彼岸花凋谢前 再为你搁浅
[01:34]那夜你魂在风里轻轻叹
[01:40]掌心符咒烫穿我掌纹的茧
[01:46]奈何桥断成半截诗篇
[01:53]我偷改命簿只换你半句谎言
[02:00]你吻过的铜铃锈在桅杆
[02:07]风一吹响了三生冬夏
[02:14]余生的河灯,照不亮对岸
[02:20]我摇着轮回的船,渡不完哀愁的深浅
[02:27]你眼泪凝成琥珀的盐,埋进我骨骼作谶言
[02:33]若爱是焚不尽的经卷,灰烬里写永远
[02:41]我跪在忘川最痛的流域,求一次擦肩
[03:00]梵音绕啊绕啊绕不过执念
[03:06]佛珠断啊断啊断在你眉间
[03:14]船沉时,天地裂开一道缝
[03:20]来世你为青山,我为雪
[03:27]轮回的渡船,碎成烟
[03:34]你是我 永世不靠岸的劫</pre>
</figure>
### 2. Happy Way to School
英文儿歌——原始提示词要的是「橘子汽水般冒泡的 indie folk」:木吉他、
俏皮口哨、明亮童声。把上学路上的仪式写成小颂歌:跳动的马尾辫、咖啡店
的猫、步调一致的伙伴。
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-school" src="https://content.hoelee.com/file/hoelee/music/Happy-Way-to-School.mp3"></audio>
<pre id="lrc-school" hidden>[00:07.00]A little girl with a heart so bright,
[00:12.00]Skips down the street in morning light,
[00:16.00]Her smile's like sunshine in the air,
[00:20.00]A joyful moment everywhere.
[00:24.00]The café's window's full of cheer,
[00:28.00]Curious kittens drawing near,
[00:32.00]Their eyes meet hers, a happy sight,
[00:35.00]They wave her off in pure delight.
[00:39.00]Hand in hand with friends so sweet,
[00:43.00]They skip along the lively street,
[00:47.00]Ponytails bounce with every move,
[00:51.00]The world feels warm, a groove to prove.
[00:55.00]School is waiting, full of dreams,
[00:59.00]A place where friendship always beams,
[01:02.00]With every step, they laugh and play,
[01:08.00]The start of a beautiful day.
[01:17.00]Together they walk, hearts in sync,
[01:21.00]The world so bright, as if they think,
[01:25.00]No better way to face the day,
[01:28.00]In friendship's glow, they'll always stay.
[01:32.00]A happy way to school they go,
[01:35.00]Through the morning's gentle glow,
[01:38.00]With every step, the bond is clear,
[01:43.00]They'll cherish this moment year by year.</pre>
</figure>
### 3. 新靓 —— 心经·冥想律动
般若波罗蜜多心经,配上节拍感十足的冥想编曲——简报就一句「冥想深度 +
律动活力」。观自在菩萨开场,完整「色不異空」段落,收在揭諦揭諦。试着
让千年经文真的能跟着动起来。
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-xinliang" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E9%9D%93.mp3"></audio>
<pre id="lrc-xinliang" hidden>[00:00.00]用凝练韵文引导心灵净化,
[00:06.43]在跃动节拍与禅意氛围间形成张力,
[00:12.87]达成冥想深度与律动活力的独特平衡随至…
[00:19.31]如梦如幻… 心境无常…
[00:25.74]众生皆空,见空即见真!
[00:32.18]觀自在菩薩,行深般若波羅密多時,
[00:38.62]照見五蘊皆空度一切苦厄,
[00:45.06]色不異空,空不異色,
[00:51.49]即是空,空即是色,
[00:57.93]受想行識亦復如是。
[01:04.37]色不異空,空不異色,
[01:10.80]受想行識亦復如是,
[01:17.24]般若波羅密多,無所畏懼,
[01:23.68]心無罣礙,涅槃即是此。
[01:30.12]舍利子,諸法空相,
[01:36.55]不生不滅,不垢不淨,
[01:42.99]無增無減,無老死,
[01:49.43]無苦集滅道,無智亦無得。
[01:55.87]色不異空,空不異色,
[02:02.30]受想行識亦復如是,
[02:08.74]般若波羅密多,無所畏懼,
[02:15.18]心無罣礙,涅槃即是此。
[02:21.61]三世諸佛,依般若波羅密多,
[02:28.05]得阿耨多羅三藐三菩提,
[02:34.49]般若波羅密多!
[02:40.93]是大神咒,是真實不虛!
[02:47.36]揭諦揭諦,波羅揭諦,
[02:53.80]波羅僧揭諦,菩提薩婆訶,
[03:00.24]心無罣礙,遠離顛倒夢想!</pre>
</figure>
### 4. 新劲 —— 新鲜能量
新靓的硬朗姊妹篇——同样的冥想血统,更重的打击驱动。这首没留歌词稿,
当氛围作品存在。
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E5%8A%B2.mp3"></audio>
</figure>
### 5. 泡泡星球漫游记
童趣 indie folk,也是文档最全的一首——原始简报、风格提示词、歌词稿全在
下面的完整例子里。泡泡星球、糖果喷泉、银河游乐场。
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-bubble" src="https://content.hoelee.com/file/hoelee/music/%E6%B3%A1%E6%B3%A1%E6%98%9F%E7%90%83%E6%BC%AB%E6%B8%B8%E8%AE%B0.mp3"></audio>
<pre id="lrc-bubble" hidden>[00:09.00]彩云兜着阳光转圈圈
[00:15.00]贝壳装满彩虹的碎片
[00:19.00]跳进橘子汽水的夏天
[00:24.00]海鸥掠过浪花的琴键
[00:48.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
[00:53.00]尾巴挂着星星的秋千 (晃呀晃呀)
[00:57.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
[01:02.00]魔法地图画满冒险线 (转个圈圈)
[01:08.00]橡皮艇划开银河水面
[01:12.00]萤火虫点亮薄荷月圆
[01:17.00]棉花糖云朵蓬松柔软
[01:20.00]流星滑梯通向我窗前
[01:27.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
[01:31.00]尾巴挂着星星的秋千 (晃呀晃呀)
[01:35.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
[01:40.00]魔法地图画满冒险线 (转个圈圈)
[02:05.00]水晶风筝追着蝴蝶结
[02:11.00]跳跳糖在舌尖开派对
[02:14.00]彩虹滑板穿过梧桐叶
[02:20.00]月亮船摇着光的湖水
[02:24.00]每个气泡都是新世界
[02:29.00]装得下所有奇妙遇见
[02:34.00]在泡泡星球蹦跳向前
[02:38.00]明天又是崭新的乐园</pre>
</figure>
### 6. 日漫心经 —— 心经·动漫风
同一部经文,换成动漫感十足的音色:大副歌能量、戏剧性转调,当代
J-pop 片头曲的情感语法。
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%97%A5%E6%BC%AB%E5%BF%83%E7%BB%8F.mp3"></audio>
</figure>
### 7. 未接来电 Unread Heartbeats (v2)
一首关于那通未接来电的中文流行情歌:把忙音当心跳,已读不回。v2 是
最终母带——编曲更紧凑、人声混音更干净。(旧版留在我的档案里。)
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%9C%AA%E6%8E%A5%E6%9D%A5%E7%94%B5-Unread-Heartbeats-v2.mp3"></audio>
</figure>
<script>
// suno-track: sync LRC lyrics with <audio> playback (no dependencies)
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
const raw = document.getElementById(audio.dataset.lrc).textContent;
const lines = raw.split('\n').map((l) => {
const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
}).filter(Boolean);
const box = document.createElement('div');
box.className = 'suno-box';
lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
audio.parentNode.insertBefore(box, audio.nextSibling);
let cur = -1;
audio.addEventListener('timeupdate', () => {
const now = audio.currentTime;
// last line whose timestamp has passed; -1 before the first line starts
// (findIndex()-1 went negative at t=0 and wrapped to the LAST line)
let i = -1;
for (let k = 0; k < lines.length; k++) { if (lines[k].t <= now) i = k; else break; }
if (i !== cur) {
if (cur >= 0) box.children[cur].classList.remove('on');
cur = i;
if (cur >= 0) {
const p = box.children[cur];
p.classList.add('on');
// centre the active line inside the box. Use rect deltas, NOT offsetTop:
// offsetTop is measured from the nearest *positioned* ancestor, so without
// position:relative on the box it is huge and scrollTop clamps to the bottom.
const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
} else {
box.scrollTop = 0; // back to the top before the first line
}
}
});
});
</script>
## 完整例子:泡泡星球漫游记,从简报到最后成品
下面是泡泡星球漫游记的完整链条——唯一一份我保留了全部纸面记录的。
它把上面的工作流具体化了。
**第一步——概念简报(我的笔记,中文):**
> 独立民谣(Indie Folk):木吉他+口哨声组合,适合突出童趣感
> 人声选择「精灵少女」音色库,自带俏皮气声
> 打击乐:玻璃瓶敲击音效呼应「橘子汽水」意象
> 副歌加入反向混响(reverse reverb)增强魔法感
**第二步——英文风格提示词(实际打进 Suno 的内容):**
```text
Indie folk vibe with acoustic guitar and playful whistles, fizzy like
orange soda, featuring sprite-girl vocals and clinking glass bottle
beats, reverse reverb brings dreamlike magic
```
一行里压缩四个决定:*乐器*(木吉他 + 口哨)、*感觉*(橘子汽水般冒泡)、
*人声*(精灵少女 + 气声)、*制作技巧*(副歌反向混响造魔法感)。
**第三步——带元标签的歌词稿:**
<details>
<summary>泡泡星球漫游记 — Metatagged lyric sheet (38 lines)</summary>
```text
[Intro][Whistling][Indie][Girl]
彩云兜着阳光转圈圈
贝壳装满彩虹的碎片
跳进橘子汽水的夏天
海鸥掠过浪花的琴键
[Chorus][Indie Folk][Reverse Reverb][Whistling]
泡泡载着梦飞向屋檐(飞呀飞呀)
尾巴挂着星星的秋千(晃呀晃呀)
鲸鱼喷出糖果的喷泉(甜到脚尖)
魔法地图画满冒险线(转个圈圈)
[Verse][Indie][Girl]
橡皮艇划开银河水面
萤火虫点亮薄荷月圆
棉花糖云朵蓬松柔软
流星滑梯通向我窗前
[Chorus]… (repeat)
[Interlude]
[Bridge][Fantasy Pop][Giggles]
水晶风筝追着蝴蝶结
跳跳糖在舌尖开派对
彩虹滑板穿过梧桐叶
月亮船摇着光的湖水
[Outro][Indie Folk][Girl]
每个气泡都是新世界
装得下所有奇妙遇见
在泡泡星球蹦跳向前
明天又是崭新的乐园
[Fade to End]
```
</details>
注意元标签比裸歌词稿多约束了什么:段落*标签*(`[Bridge]`、`[Outro]`)、
*演唱方式*(`[Whistling]`、`[Giggles]`)、歌曲中间换*制作风格*
(`[Reverse Reverb]` 只在副歌)、桥段转风格(`[Fantasy Pop]`)。
每句后面的填充语气词——飞呀飞呀、晃呀晃呀——告诉歌手怎么装饰每一行。
**第四步——生成、听、重复。** 每段 3–5 个 take,留最好的,扩展最顺的
副歌。最终成品在上面可播放。
## 换成 v6 我会怎么改
如果今天做这些歌,有四件事会不一样:
1. **段落编辑替代整首重来。** 轮回的渡船的桥段磨了六次生成才满意。
换成 v6,直接说「桥段放慢、变稀疏,只留人声和拨弦古筝」,其余保留。
2. **单句歌词替换。** 忘川那两句改了四版措辞;v6 只改一行,不重建整首。
3. **Voices / 自定义模型。** v5.5 的 Voices 能让七首歌用同一个歌手,
而不是七个不同的 AI 声线。
4. **分轨和 Studio。** Premier 的 Studio(带多轨 + MIDI 导出的浏览器
DAW)可以直接修一个人声瑕疵,不用围着它重新生成。
工艺层——像样的歌词、情绪弧线、诚实的元标签——跨模型版本一直成立。
模型越来越可控;写歌这件事还是人的活。
## 结果
七首原创歌曲,用 Suno AI 写词编曲完成,托管在自己的基础设施上
(`content.hoelee.com`),用原生 HTML5 audio 内嵌、歌词同步——没有录音室
预算、不用约歌手、不用乐手。整个曲库就躺在我 NAS 的一个文件夹里,
跟任何项目一样做版本管理。
想试试 AI 音乐的话,从自己的歌词和一句诚实的风格描述开始——类型、感觉、
人声、一个制作技巧——然后迭代,直到某个 take 让你意外。这就是全部手艺。
---
## 想要原创音乐——或任何 AI 加持的业务方案?
我做网站、Telegram/WhatsApp bot 和自托管基础设施,也为品牌制作原创
AI 辅助音乐:广告歌、背景音乐、活动音频,不需要录音室预算。没有乐手
要预约、没有授权迷宫——你拿到的是真的能用的原创曲目。如果这有用,
欢迎聊聊:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=Music%20for%20my%20business)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
🇬🇧 英文版原文 (English Markdown Primary)
---
title: "How I Made My Own Songs with Suno AI"
description: "I wrote the lyrics, tuned the prompts, and shipped seven songs with Suno AI — folk ballads, a kids' tune, an anime-style Heart Sutra. Full workflow + the tracks."
pubDate: 2026-09-11
category: case-studies
stream: creative-lab
tags: [suno, ai-music, music, lyrics, prompting, ai]
ogImage: /og/how-i-made-my-own-songs-with-suno-ai.jpg
banner: /banners/how-i-made-my-own-songs-with-suno-ai.jpg
---
I make my own songs. Not by playing an instrument — by writing lyrics,
crafting style prompts, and letting Suno AI produce the voice, the
arrangement, and the mix. This post is the full workflow: how the current
model family works, what I actually type into the prompt boxes, and seven
finished tracks you can play right here.
## Why make your own music with AI?
Two reasons, one personal and one practical.
**Personally:** I like experimenting with creative AI. A song is a tight,
self-contained project — lyrics, style, structure, performance — that
exercises the same prompt-crafting muscles as building a bot or wiring a
pipeline. And the output is shareable in a way a config file never is.
**Practically:** this is now a client skill. Small businesses need jingles,
background music for videos, event music, and branded audio — without a
studio budget. If I can deliver a usable track from a one-page brief,
that's a service, not a party trick. Suno's current **v6** models (released
Sep 9, 2026) made this dramatically more controllable than the V3-era
version I started with in April 2024.
## The current standard: what Suno's models look like in 2026
Version confusion is the first thing to clear up, because old guides
online reference models that don't exist anymore:
| Model | When | Who gets it |
|---|---|---|
| **v6 / v6-wild** | Sep 9, 2026 | Paid plans (flagship + experimental) |
| **v6-mini** | Sep 9, 2026 | Everyone — fast, efficient, great for drafts |
| **v5.5** | Mar 26, 2026 | Paid — added Voices, Custom Models, My Taste |
| **v4.5-all** | Oct 21, 2025 | Free plan (no commercial rights, attribution required) |
| V4 / V4.5 / V5 | 2024–2026 | Paid plans, superseded |
The v6 family added the control features that changed how I work:
- **Plain-language section edits** — "change the chorus so it's sung by a
gospel choir" edits one section without regenerating the song.
- **Single lyric swaps** — "change 'love' to 'light'" updates one line.
- **Mashups** — combine vocals from one song, drums from another, new
lyrics, in one request.
- **Sample → isolate → rebuild** — pull a riff at 0:45, isolate the
guitar, build a beat around it.
- **Multimodal input** — start from text, audio, an image, or even a video.
My own tracks below were made with the earlier V4-era workflow (April
2025). The fundamentals — style field, lyric metatags, iteration — are
unchanged, and I note where v6 would have saved me steps.
## My workflow: lyrics first, style second
The order matters more than people think. Most beginners open Suno, paste
a genre into Simple Mode, and get a generic song. I do the opposite:
1. **Write the song as a document first** — title, structure, lyrics. The
emotion and the story come from me; Suno supplies the performance.
2. **Craft the style prompt** — genre + mood + era + instruments + vocal
persona + production, often 200+ characters, describing the *journey*
of the song, not just its genre.
3. **Add metatags to the lyrics** — `[Verse]`, `[Chorus]`, `[Bridge]`,
`[Whispered]`, `[Belted]`. Without these, Suno defaults to a flat
verse-chorus-verse with no emotional arc.
4. **Generate 3–5 takes** per song and keep the best. Repair via
Extend/Reuse Prompt, and restate the genre in every extension because
style drifts.
5. **Iterate on pronunciation** — AI singers read phonetically, so I
respell ("through" → "thru", hyphenate unusual syllables) and always
test proper nouns in a short clip first.
The worked example below shows exactly what this looks like.
## The songs
Seven tracks, hosted on my own media subdomain (`content.hoelee.com` via
Publitio) and embedded with plain HTML5 audio. Press play:
<style>
.suno-track{margin:2.2rem 0}
.suno-track audio{width:100%;max-width:560px}
.suno-box{position:relative;max-height:250px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6}
.suno-box p{margin:.18rem 0;opacity:.55}
.suno-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
</style>
### 1. 轮回的渡船 — The Ferry of Reincarnation
Chinese folk ballad (古风民谣). The ferryman of the River of Forgetfulness,
Meng Po's soup gone cold, a soul that refuses to reboard the wheel of
rebirth. Written as a mythic love story about choosing someone across
lifetimes. Starts hushed over sparse strings and builds to a full,
anguished chorus.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-samsara" src="https://content.hoelee.com/file/hoelee/music/%E8%BD%AE%E5%9B%9E%E7%9A%84%E6%B8%A1%E8%88%B9.mp3"></audio>
<pre id="lrc-samsara" hidden>[00:12]河岸的雾漫过第三千个秋
[00:18]我的桨声碎在无人渡口
[00:26]忘川水打湿褪色的袖
[00:30]你背影是前世未燃尽的篝火
[00:33]佛说众生如萍聚散无由
[00:39]我却数遍每一颗星斗
[00:46]等残月照亮你回眸——
[00:53]偏偏人间雪,落满我舟头
[01:04]我摇着轮回的船,载不动红尘重如山
[01:10]孟婆的汤冷了又添,你宁化涟漪不成全
[01:20]若执念是穿心的箭,刺透因果的链
[01:27]我愿在彼岸花凋谢前 再为你搁浅
[01:34]那夜你魂在风里轻轻叹
[01:40]掌心符咒烫穿我掌纹的茧
[01:46]奈何桥断成半截诗篇
[01:53]我偷改命簿只换你半句谎言
[02:00]你吻过的铜铃锈在桅杆
[02:07]风一吹响了三生冬夏
[02:14]余生的河灯,照不亮对岸
[02:20]我摇着轮回的船,渡不完哀愁的深浅
[02:27]你眼泪凝成琥珀的盐,埋进我骨骼作谶言
[02:33]若爱是焚不尽的经卷,灰烬里写永远
[02:41]我跪在忘川最痛的流域,求一次擦肩
[03:00]梵音绕啊绕啊绕不过执念
[03:06]佛珠断啊断啊断在你眉间
[03:14]船沉时,天地裂开一道缝
[03:20]来世你为青山,我为雪
[03:27]轮回的渡船,碎成烟
[03:34]你是我 永世不靠岸的劫</pre>
</figure>
### 2. Happy Way to School
English children's song — the original prompt called for a "fizzy,
orange-soda indie folk" sound: acoustic guitar, playful whistling, and
bright kid-friendly vocals. It's the walk-to-school ritual turned into a
tiny anthem: ponytails bouncing, kittens at the café, friends in step.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-school" src="https://content.hoelee.com/file/hoelee/music/Happy-Way-to-School.mp3"></audio>
<pre id="lrc-school" hidden>[00:07.00]A little girl with a heart so bright,
[00:12.00]Skips down the street in morning light,
[00:16.00]Her smile's like sunshine in the air,
[00:20.00]A joyful moment everywhere.
[00:24.00]The café's window's full of cheer,
[00:28.00]Curious kittens drawing near,
[00:32.00]Their eyes meet hers, a happy sight,
[00:35.00]They wave her off in pure delight.
[00:39.00]Hand in hand with friends so sweet,
[00:43.00]They skip along the lively street,
[00:47.00]Ponytails bounce with every move,
[00:51.00]The world feels warm, a groove to prove.
[00:55.00]School is waiting, full of dreams,
[00:59.00]A place where friendship always beams,
[01:02.00]With every step, they laugh and play,
[01:08.00]The start of a beautiful day.
[01:17.00]Together they walk, hearts in sync,
[01:21.00]The world so bright, as if they think,
[01:25.00]No better way to face the day,
[01:28.00]In friendship's glow, they'll always stay.
[01:32.00]A happy way to school they go,
[01:35.00]Through the morning's gentle glow,
[01:38.00]With every step, the bond is clear,
[01:43.00]They'll cherish this moment year by year.</pre>
</figure>
### 3. 新靓 — Heart Sutra, Meditative Beats
The 般若波罗蜜多心经 (Heart Sutra) chanted over rhythmic, meditative
production — the brief was "meditation depth + groove energy". 观自在菩萨
opening, the full 色不異空 passage, ending on 揭諦揭諦. An attempt to make
ancient scripture something you can actually move to.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-xinliang" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E9%9D%93.mp3"></audio>
<pre id="lrc-xinliang" hidden>[00:00.00]用凝练韵文引导心灵净化,
[00:06.43]在跃动节拍与禅意氛围间形成张力,
[00:12.87]达成冥想深度与律动活力的独特平衡随至…
[00:19.31]如梦如幻… 心境无常…
[00:25.74]众生皆空,见空即见真!
[00:32.18]觀自在菩薩,行深般若波羅密多時,
[00:38.62]照見五蘊皆空度一切苦厄,
[00:45.06]色不異空,空不異色,
[00:51.49]即是空,空即是色,
[00:57.93]受想行識亦復如是。
[01:04.37]色不異空,空不異色,
[01:10.80]受想行識亦復如是,
[01:17.24]般若波羅密多,無所畏懼,
[01:23.68]心無罣礙,涅槃即是此。
[01:30.12]舍利子,諸法空相,
[01:36.55]不生不滅,不垢不淨,
[01:42.99]無增無減,無老死,
[01:49.43]無苦集滅道,無智亦無得。
[01:55.87]色不異空,空不異色,
[02:02.30]受想行識亦復如是,
[02:08.74]般若波羅密多,無所畏懼,
[02:15.18]心無罣礙,涅槃即是此。
[02:21.61]三世諸佛,依般若波羅密多,
[02:28.05]得阿耨多羅三藐三菩提,
[02:34.49]般若波羅密多!
[02:40.93]是大神咒,是真實不虛!
[02:47.36]揭諦揭諦,波羅揭諦,
[02:53.80]波羅僧揭諦,菩提薩婆訶,
[03:00.24]心無罣礙,遠離顛倒夢想!</pre>
</figure>
### 4. 新劲 — Fresh Energy
A harder-edged companion to 新靓 — same meditative lineage, more percussive
drive. No lyric sheet on this one; it lives as a vibe piece.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E5%8A%B2.mp3"></audio>
</figure>
### 5. 泡泡星球漫游记 — Bubble Planet Adventure
Kids' indie folk, and the one with the most documented backstory — the
full original brief, style prompt, and lyric sheet are in the worked
example below. Bubble planets, candy fountains, a galactic playground.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-bubble" src="https://content.hoelee.com/file/hoelee/music/%E6%B3%A1%E6%B3%A1%E6%98%9F%E7%90%83%E6%BC%AB%E6%B8%B8%E8%AE%B0.mp3"></audio>
<pre id="lrc-bubble" hidden>[00:09.00]彩云兜着阳光转圈圈
[00:15.00]贝壳装满彩虹的碎片
[00:19.00]跳进橘子汽水的夏天
[00:24.00]海鸥掠过浪花的琴键
[00:48.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
[00:53.00]尾巴挂着星星的秋千 (晃呀晃呀)
[00:57.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
[01:02.00]魔法地图画满冒险线 (转个圈圈)
[01:08.00]橡皮艇划开银河水面
[01:12.00]萤火虫点亮薄荷月圆
[01:17.00]棉花糖云朵蓬松柔软
[01:20.00]流星滑梯通向我窗前
[01:27.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
[01:31.00]尾巴挂着星星的秋千 (晃呀晃呀)
[01:35.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
[01:40.00]魔法地图画满冒险线 (转个圈圈)
[02:05.00]水晶风筝追着蝴蝶结
[02:11.00]跳跳糖在舌尖开派对
[02:14.00]彩虹滑板穿过梧桐叶
[02:20.00]月亮船摇着光的湖水
[02:24.00]每个气泡都是新世界
[02:29.00]装得下所有奇妙遇见
[02:34.00]在泡泡星球蹦跳向前
[02:38.00]明天又是崭新的乐园</pre>
</figure>
### 6. 日漫心经 — Heart Sutra, Anime Style
The same classical text, reimagined with an anime-inspired sound palette —
big chorus energy, dramatic key lifts, the emotional grammar of
contemporary J-pop openings.
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%97%A5%E6%BC%AB%E5%BF%83%E7%BB%8F.mp3"></audio>
</figure>
### 7. 未接来电 Unread Heartbeats (v2)
A mandopop ballad about that one missed call: the dial tone as a heartbeat,
the read receipts that stay unread. v2 is the final master — tighter
arrangement, cleaner vocal mix. (The original take stays in my archive.)
<figure class="suno-track">
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%9C%AA%E6%8E%A5%E6%9D%A5%E7%94%B5-Unread-Heartbeats-v2.mp3"></audio>
</figure>
<script>
// suno-track: sync LRC lyrics with <audio> playback (no dependencies)
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
const raw = document.getElementById(audio.dataset.lrc).textContent;
const lines = raw.split('\n').map((l) => {
const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
}).filter(Boolean);
const box = document.createElement('div');
box.className = 'suno-box';
lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
audio.parentNode.insertBefore(box, audio.nextSibling);
let cur = -1;
audio.addEventListener('timeupdate', () => {
const now = audio.currentTime;
// last line whose timestamp has passed; -1 before the first line starts
// (findIndex()-1 went negative at t=0 and wrapped to the LAST line)
let i = -1;
for (let k = 0; k < lines.length; k++) { if (lines[k].t <= now) i = k; else break; }
if (i !== cur) {
if (cur >= 0) box.children[cur].classList.remove('on');
cur = i;
if (cur >= 0) {
const p = box.children[cur];
p.classList.add('on');
// centre the active line inside the box. Use rect deltas, NOT offsetTop:
// offsetTop is measured from the nearest *positioned* ancestor, so without
// position:relative on the box it is huge and scrollTop clamps to the bottom.
const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;
} else {
box.scrollTop = 0; // back to the top before the first line
}
}
});
});
</script>
## Worked example: Bubble Planet Adventure, from brief to track
This is the full chain for 泡泡星球漫游记 — the only one I kept every
scrap of paperwork for. It shows the workflow above in concrete form.
**Step 1 — the concept brief (my notes, in Chinese):**
> 独立民谣(Indie Folk):木吉他+口哨声组合,适合突出童趣感
> 人声选择「精灵少女」音色库,自带俏皮气声
> 打击乐:玻璃瓶敲击音效呼应「橘子汽水」意象
> 副歌加入反向混响(reverse reverb)增强魔法感
**Step 2 — the English style prompt (what actually went into Suno):**
```text
Indie folk vibe with acoustic guitar and playful whistles, fizzy like
orange soda, featuring sprite-girl vocals and clinking glass bottle
beats, reverse reverb brings dreamlike magic
```
Four decisions compressed into one line: the *instrument* (acoustic
guitar + whistles), the *feel* (fizzy like orange soda), the *voice*
(sprite-girl with airy delivery), and the *production trick* (reverse
reverb on the chorus for the magic feel).
**Step 3 — the lyric sheet with metatags:**
<details>
<summary>Bubble Planet Odyssey — Metatagged lyric sheet (38 lines)</summary>
```text
[Intro][Whistling][Indie][Girl]
彩云兜着阳光转圈圈
贝壳装满彩虹的碎片
跳进橘子汽水的夏天
海鸥掠过浪花的琴键
[Chorus][Indie Folk][Reverse Reverb][Whistling]
泡泡载着梦飞向屋檐(飞呀飞呀)
尾巴挂着星星的秋千(晃呀晃呀)
鲸鱼喷出糖果的喷泉(甜到脚尖)
魔法地图画满冒险线(转个圈圈)
[Verse][Indie][Girl]
橡皮艇划开银河水面
萤火虫点亮薄荷月圆
棉花糖云朵蓬松柔软
流星滑梯通向我窗前
[Chorus]… (repeat)
[Interlude]
[Bridge][Fantasy Pop][Giggles]
水晶风筝追着蝴蝶结
跳跳糖在舌尖开派对
彩虹滑板穿过梧桐叶
月亮船摇着光的湖水
[Outro][Indie Folk][Girl]
每个气泡都是新世界
装得下所有奇妙遇见
在泡泡星球蹦跳向前
明天又是崭新的乐园
[Fade to End]
```
</details>
Note what the metatags enforce that a bare lyric sheet wouldn't:
section *labels* (`[Bridge]`, `[Outro]`), *delivery* (`[Whistling]`,
`[Giggles]`), a *production change* mid-song (`[Reverse Reverb]` only on
choruses), and a stylistic shift in the bridge (`[Fantasy Pop]`). The
fill-in parentheticals — 飞呀飞呀, 晃呀晃呀 — tell the singer exactly
how to ornament each line.
**Step 4 — generate, listen, repeat.** 3–5 takes per section, keep the
best, extend the chorus that landed. The final track plays above.
## What I'd do differently with v6
If I made these today, four things change:
1. **Section edits instead of full rerolls.** 轮回的渡船's bridge took six
generations to nail. With v6 I'd say *"make the bridge slower and
sparser, just voice and a plucked guzheng"* and keep the rest intact.
2. **Single-line lyric swaps.** The 忘川 couplet went through four wordings;
v6 edits one line without rebuilding the song.
3. **Voices / custom models.** v5.5's Voices would let me keep one
consistent singer across all seven tracks instead of seven different
AI vocalists.
4. **Stems and Studio.** On the Premier plan, Studio (a browser DAW with
multitrack + MIDI export) means fixing a vocal artifact instead of
regenerating around it.
The craft layer — decent lyrics, an emotional arc, honest metatags —
carries straight across model versions. The models get more controllable;
the songwriting stays the job.
## The result
Seven original songs, written and produced with Suno AI, hosted on my own
infrastructure (`content.hoelee.com`), embedded here with plain HTML5 audio
and synced lyrics — no music-studio budget, no booking a vocalist, no
session musician. The whole catalog lives in one folder on my NAS,
versioned like any other project.
If you're curious about AI music, start with your own lyrics and an honest
style sentence — genre, feel, voice, one production trick — then iterate
until one take surprises you. That's the whole craft.
---
## Want original music — or AI-powered anything — for your business?
I build websites, Telegram/WhatsApp bots, and self-hosted infrastructure,
and I produce original AI-assisted music for brands that want a jingle,
background tracks, or event audio without a studio budget. There's no
session musician to book and no licence maze — you get an original track
you can actually use. If that sounds useful, I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=Music%20for%20my%20business)
- 🌐 **Website:** [hoelee.com](https://hoelee.com)