沙画讲故事
一句话:沙台上有人撒沙,图像由沙的厚薄长出来,手一直在场。
沙画的辨识度不在画风,在物理:光从沙台底下透上来,沙越厚越挡光。
所以不能把图片直接贴上去,必须先把图转成"沙的厚度",再让手把它一点点铺开。
何时触发
- 做沙画视频 / 沙动画 / sand art
- 故事类短片(品牌故事、人物故事、情感叙事)想要比口播更有画面
- 已有口播视频,想做一版沙画版的差异化内容
五步工作流
> 想先看效果:从 GitHub 仓库取 examples/回家的路/(三张沙画 + 绿幕手 + story.json,
> 素材齐全,不用先生图),然后 python scripts/make_sand.py --project <示例目录>。
> SkillHub 发布包只收代码与文档,不含图片素材。
1. 拆故事(3~6 幕)
每幕一句话、一个画面。一屏只留一个主体,人物剪影优于群像。
文案用短句,画面才有呼吸;一幕 4~9 秒。
[分幕]
幕1 小时候,父亲牵着我的手,走这条巷子回家。 → 老人牵小孩走在老巷
幕2 后来我提着行李,去了很远的城里。 → 青年提箱走向车站
幕3 每次回来,他都在巷口等着。 → 路灯下老人独站
2. 生成沙画素材(关键)
沙画和"沙漠/沙雕"极易混淆。必须锁死这几条,prompt 照抄这段前缀:
平面沙画(sand art on light box),从正上方俯拍发光玻璃沙台。画面完全扁平,
像剪影画,没有立体浮雕、没有景深、没有投影、不是沙雕。亮黄色背光透过薄沙
形成大片明亮区域,厚沙呈深褐至近黑色。只有深褐与金黄几个层次,边缘是散开的
沙粒,颗粒感明显,没有轮廓线。无文字,无水印。
再在后面接具体画面。判据:看到立体感、看到阴影落在物体上、看到灰调过渡,就是废图。
画面可以画满,四周留一点散沙就行(渲染器会自动裁掉大片空沙面,见"实现要点"第 6 条)。
3. 生成"手"素材
一张就够:俯拍、掌心向下、指缝漏沙、手臂向右/右下出画。
俯拍视角,一只成年人右手从画面右上方斜伸入,手背朝上,掌心向下稍倾,
手指半开,指缝间有琥珀色细沙流下。背景是纯正的亮绿色 #00FF00,完全平涂
无渐变、无阴影,画面里只有手和沙,没有其他东西,没有文字。
⚠️ 不要用 background: transparent:模型会把"透明=棋盘格"画进像素,白笔杆/手指边缘无法与背景分离。
必须绿幕 + 自己抠(load_hand() 里已按绿幕调好阈值)。
4. 写 story.json 并出片
{
"voice": "zh-CN-YunyangNeural",
"rate": "+6%",
"title": "回家的路",
"tail": 0.75,
"scenes": [
{"src": "scene1.png", "line": "小时候,父亲牵着我的手,走这条巷子回家。"},
{"src": "scene2.png", "line": "后来我提着行李,去了很远的城里。", "gamma": 1.0, "box": [0.05,0.05,0.95,0.95]}
]
}
python scripts/make_sand.py --project ./my_story
产物:<项目>/<title>_沙画_竖屏.mp4。
5. 质检(逐条对照)
- [ ] 沙面没有矩形边界(极薄沙必须平滑压零,硬切会在画幅处留一条直边)
- [ ] 手没有直角断口(手腕断口要么在画面外,要么被沙面遮住)
- [ ] 沙面始终是颗粒,没有一块平滑的照片质感
- [ ] 手的移动不快于每秒一次换向(快了就是摇扇子)
- [ ] 换幕不空场:撒沙时长 ≈ 幕长 × 0.62,画完到下一幕之间的空白 ≤ 0.4 秒
- [ ] 字幕压在画面外/暗部,不与主体重叠
参数(scripts/sand_render.py)
| 参数 | 默认 | 作用 |
|---|---|---|
FIG_TOP / FIG_H / FIG_W |
58 / 950 / 700 | 图区位置与大小(720×1280 下) |
FEATHER |
0.085 | 沙边软度。太小=刀切线,太大=整幅发灰 |
HAND_SCALE |
0.34 | 手相对画面宽度,俯拍手本来就大,但别盖住画面 |
SAND_DARK / SAND_EDGE |
54,33,15 / 212,162,92 | 厚沙与散沙的颜色 |
GHOST(无) |
— | 沙画不用淡底稿:沙是一次铺满的,不是描线 |
实现要点(改代码前先读)
- 沙层 = 亮度反推厚度。
density = (paper - lum) / (paper - 26),纸色取中位数(不是 95 分位,否则整幅糊灰)。 - 上色只用 density,不沿用原图颜色。否则就是贴照片。
tint = SAND_EDGE*(1-d) + SAND_DARK*d,alpha ≈ d^0.78。 - 落沙前沿 = 手到像素的距离。
build_arrival()对路径上每个采样点算lp + dist/spread*0.14取最小,
得到"每个像素什么时候被沙覆盖",前沿天然是弧线,不会出现换趟的横向刀口。
再叠一层低频噪声(0.09),前沿才有"一波一波漫上来"的起伏,否则是一条笔直的擦除线。 - 手的纵向走位按内容分布重映射(
row_curve()+warp_v())。空沙面手一带而过、有画的地方慢下来,
这样每一秒都在出内容。真人撒沙就是这样;匀速走会在稀疏的画面上留下大段空白灯箱。 - 画幅边缘渐隐 14%,且极薄沙要平滑压零(不是硬阈值),矩形边缘才不会露出来。
- 手沿画面右侧纵向走位(
hand_path())。为什么不是横扫:素材的手臂是向右出画的,
横扫到左边时腕口会露馅;竖着走,断口永远在画面右缘之外。 - 自动裁到有沙的区域(
_crop_to_content())。素材四周常有一大片空沙面,不裁的话手从上往下扫,
前一秒多扫的全是空地,观众看到的就是一块空白灯箱。裁完内容填满画幅(floor防裁成窄条)。 - 飘沙只画在有画面的地方(
content_mask())。空沙台上飘一层沙,看着就是假的。 - 小臂是补出来的:把腕部 24px 的列做垂直拉伸 + 柱面明暗,再对齐亮度;接缝落在画面外就看不见。
已知坑(都踩过)
| 现象 | 原因 | 处理 |
|---|---|---|
| 手边缘一圈浅色方块 | paste(im, box, im) 传 RGBA 当遮罩时用的是亮度不是 alpha |
显式传 im.getchannel("A") |
| 手下面是硬边浅色块 | 素材自带一段漏沙,裁切线是直角 | 裁掉 + 34px alpha 渐隐 + 椭圆软遮罩清残留 |
| 小臂像一条色块 | 2px 列拉伸出竖条纹 | 用腕部 24px 列取垂直渐变 + 柱面光 + 高斯 |
| 画面像一块照片 | 沙层直接沿用原图 RGB | 只用 density 上色 |
| 边界是一条直线 | 没做画幅渐隐,或 FEATHER 太小 | 边距渐隐 11.5%,FEATHER 0.085 |
| 整幅发灰 | 纸色用 95 分位,把纸判成淡墨 | 改中位数 |
| 画面上出现一块矩形浅色区 | 极薄沙硬阈值切断,边界落在画幅矩形上 | 平滑压零(见要点 7) |
| 每幕开头一两秒纯空沙台 | 素材四周空沙面太多,手先扫空地 | 自动裁内容 bbox |
| 字幕像卡拉OK | 纯白字 + 黑描边 | 换暖米白 E8DCC2 + 深褐描边 331E0E |
| 字幕发蓝 | ASS 的颜色是 &HAABBGGRR,把 #E8DCC2 直接填进去就成了淡蓝 |
写 &H00C2DCE8,不是 &H00E8DCC2 |
| 每段开头前两个字听不到 | 合成音自带 0.15~0.25s 空拍,再叠 AAC 编码延迟;字幕按词时间戳走,比声音早半拍 | trim_head() 裁到 0.06s 起音并同步前移词时间戳;音轨全程 PCM,只在出片时编码一次 AAC |
音频链路(这段最容易翻车)
- 每条合成音开头都有空拍。edge-tts 垫 0.15~0.25 秒,词时间戳却是从 0.097 起算的。
不裁的话,画面已经动了、字幕也出了字,观众才听到第一个字 —— 体感就是"前两个字被吞了"。
trim_head()用silencedetect量出首个有声点,留 0.06s 起音,其余裁掉,词级时间戳整体前移相同的量(否则字幕会比声音早)。 - 不要逐段转 AAC 再
-c copy拼接。每段 AAC 自带 1024 个采样(24kHz ≈ 43ms)的编码延迟,
拼一次就多一段静音,段数越多偏得越明显,接缝处还会削掉起音。
正确做法:每段先转 24k 单声道 WAV,用apad=whole_dur=幕长+concat滤镜拼成一条 PCM,
出片时只编码一次 AAC。 apad=whole_dur同时会截断,所以画面多长这句就占多长,不会挤压下一幕。- 裁头后
adur要用前移后的时间戳重算,否则视频幕长比音频长,换幕时又多出一段空白。
与其他技能的关系
- 配音/字幕:复用
qianjin-oral-video/scripts/srt_aligner.py的词级对齐(找不到会自动退化成整幕一条) - 若要做"白板手绘"而不是沙画:用
qianjin-oral-video/scripts/handwhite_render.py - 沙画适合故事、品牌、情感;白板适合知识、教程、干货
环境
- Python:Pillow、numpy、edge-tts
- FFmpeg 需在 PATH(推流渲染 + 拼接 + 烧字幕)
- 字体:楷体优先,
FONT_CANDIDATES里有 Windows/macOS/Linux 兜底