Spaces:
Sleeping
Sleeping
暮归江南 · 步骤4-9:生命感、声音、播放器与MP4导出、封面文案、实况图、Blender脚本
Browse files- .gitattributes +2 -0
- README.md +6 -0
- blender/jiangnan_village.py +842 -0
- src/jiangnan/JiangnanApp.tsx +657 -21
- src/jiangnan/assets/fonts/title.woff2 +3 -0
- src/jiangnan/assets/fonts/wenkai.woff2 +3 -0
- src/jiangnan/assets/voice/n01.mp3 +3 -0
- src/jiangnan/assets/voice/n02.mp3 +3 -0
- src/jiangnan/assets/voice/n03.mp3 +3 -0
- src/jiangnan/assets/voice/n04.mp3 +3 -0
- src/jiangnan/assets/voice/n05.mp3 +3 -0
- src/jiangnan/assets/voice/n06.mp3 +3 -0
- src/jiangnan/assets/voice/n07.mp3 +3 -0
- src/jiangnan/assets/voice/n08.mp3 +3 -0
- src/jiangnan/assets/voice/n09.mp3 +3 -0
- src/jiangnan/assets/voice/n10.mp3 +3 -0
- src/jiangnan/audio/mixer.ts +140 -0
- src/jiangnan/audio/synth.ts +467 -0
- src/jiangnan/blender/jiangnan_village.py +842 -0
- src/jiangnan/copy/platforms.ts +83 -0
- src/jiangnan/engine/Film.ts +1 -0
- src/jiangnan/engine/camera.ts +3 -3
- src/jiangnan/engine/layout.ts +2 -2
- src/jiangnan/engine/props.ts +6 -6
- src/jiangnan/engine/sky.ts +1 -1
- src/jiangnan/engine/timeline.ts +10 -10
- src/jiangnan/export/covers.ts +122 -0
- src/jiangnan/export/livegen.ts +149 -0
- src/jiangnan/export/livephoto.ts +267 -0
- src/jiangnan/export/mp4.ts +138 -0
- src/jiangnan/jiangnan.css +110 -0
- src/jiangnan/player/overlay.ts +142 -0
- tools/jiangnan/analyze.py +104 -0
- tools/jiangnan/subset_fonts.py +12 -0
- tools/jiangnan/tts.py +17 -0
- tools/jiangnan/verify_live_photo.py +15 -0
.gitattributes
CHANGED
|
@@ -93,3 +93,5 @@ public/mvfonts/neon.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
| 93 |
素材/longshot20261021193931.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
素材/longshot20261021193942.png filter=lfs diff=lfs merge=lfs -text
|
| 95 |
public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 93 |
素材/longshot20261021193931.png filter=lfs diff=lfs merge=lfs -text
|
| 94 |
素材/longshot20261021193942.png filter=lfs diff=lfs merge=lfs -text
|
| 95 |
public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 96 |
+
src/jiangnan/assets/fonts/title.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 97 |
+
src/jiangnan/assets/fonts/wenkai.woff2 filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -119,6 +119,12 @@ tools/jiangnan/ # 旁白生成、字体子集化、实况图
|
|
| 119 |
- **步骤 1** ✅ 写好计划、大纲、步骤与做法(本节),推送到 Space。
|
| 120 |
- **步骤 2** ✅ 渲染引擎骨架:新页面入口 `/jiangnan/`(`jiangnan/index.html` + `src/jiangnan/main.tsx`,`vite.config.ts` 增加多页面入口,原页面不变);时间轴(10 个镜头、日落曲线、旁白时间);程序化天空(夕阳、晚霞、云、蓝调、星空、新月);山谷地形 + 远山 + 高精度梯田(水田镜面反光);实时平面反射的河面(涟漪、阳光碎金、灯影拉丝);高度雾与空气透视;后期(体积光、辉光、ACES、分色调色、暗角、颗粒)。
|
| 121 |
- **步骤 3** ✅ 村落与道具:217 栋徽派民居生成器(粉墙、三叠/二叠马头墙、黛瓦凹曲屋面、腰檐、门罩、木格窗、店铺排门);石驳岸、青石板河街、河埠头;半圆石拱桥(台阶、栏板、拱券石);老水车(辐条、叶板、竹筒、水槽);乌篷船;集市摊位(竹篮果蔬、陶罐、腊肉架、布篷);村口古樟(板根、虬枝)、石碾、石凳、古井、蓝印花布晾架;六角亭;柳树、竹林、山林、芦苇。并建立了无头浏览器自动检查画面(构图分类图 + 亮度图)的流程。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
---
|
|
|
|
| 119 |
- **步骤 1** ✅ 写好计划、大纲、步骤与做法(本节),推送到 Space。
|
| 120 |
- **步骤 2** ✅ 渲染引擎骨架:新页面入口 `/jiangnan/`(`jiangnan/index.html` + `src/jiangnan/main.tsx`,`vite.config.ts` 增加多页面入口,原页面不变);时间轴(10 个镜头、日落曲线、旁白时间);程序化天空(夕阳、晚霞、云、蓝调、星空、新月);山谷地形 + 远山 + 高精度梯田(水田镜面反光);实时平面反射的河面(涟漪、阳光碎金、灯影拉丝);高度雾与空气透视;后期(体积光、辉光、ACES、分色调色、暗角、颗粒)。
|
| 121 |
- **步骤 3** ✅ 村落与道具:217 栋徽派民居生成器(粉墙、三叠/二叠马头墙、黛瓦凹曲屋面、腰檐、门罩、木格窗、店铺排门);石驳岸、青石板河街、河埠头;半圆石拱桥(台阶、栏板、拱券石);老水车(辐条、叶板、竹筒、水槽);乌篷船;集市摊位(竹篮果蔬、陶罐、腊肉架、布篷);村口古樟(板根、虬枝)、石碾、石凳、古井、蓝印花布晾架;六角亭;柳树、竹林、山林、芦苇。并建立了无头浏览器自动检查画面(构图分类图 + 亮度图)的流程。
|
| 122 |
+
- **步骤 4** ✅ 生命感:挑担过桥的村民、集市行人与摊主、牛车(牛腿与车轮随行进转动)、树下歇脚的老人、7 只白鹭(拍翅)与 46 只归鸟群、按厨房点火时间升起的炊烟(26 团/烟囱,受风漂移)、蓝印花布随风摆动、夜里挂灯的乌篷船与摇橹人、红灯笼(从集市西头第一盏开始依次点亮,含点火闪烁)、窗户逐个亮灯、最近的灯笼变成真实点光源照亮粉墙;每个镜头都有横屏 / 竖屏两套机位。
|
| 123 |
+
- **步骤 5** ✅ 声音:10 句原创旁白(中文神经网络语音合成,纪录片男声),配乐在浏览器内实时合成(古筝 Karplus-Strong、竹箫、弦乐铺底、低音、木鱼、编钟、日落与终章的古筝刮奏),环境音效(流水、水车吱呀与溅水、鸟鸣、白鹭叫声与振翅、牛铃与蹄声、脚步、竹林风声、远处钟声、灯笼点火、虫鸣、蛙鸣、摇橹水声);离线混音(旁白时音乐自动压低、卷积混响、限幅)。实测响度:平均 -20 dB,峰值 -1.9 dB。
|
| 124 |
+
- **步骤 6** ✅ 播放器与 MP4 导出:播放 / 暂停 / 拖动 / 分镜跳转 / 字幕 / 静音 / 全屏 / 横竖屏切换,自适应分辨率;导出可选横屏 1920×1080 或竖屏 1080×1920、1080p/720p、24/30/60 fps、烧录字幕与否;WebCodecs H.264 + AAC(浏览器不支持 AAC 时自动用 Opus)+ mp4-muxer。已在无头浏览器里实测导出并用 ffprobe/ffmpeg 校验通过。
|
| 125 |
+
- **步骤 7** ✅ 封面与文案:16:9 / 4:3 / 3:4 三种比例 × 4 个画面(石桥夕照、灯火长街、星夜水乡、古樟斜阳),每张按比例重新取景渲染并排书法标题,可多选、单张或打包下载;小红书 / 抖音 / 视频号 / B站 各 3–4 个标题(字数实时校验,全部在限制内)、描述、话题,一键复制,也可下载 txt。
|
| 126 |
+
- **步骤 8** ✅ 实况图:把 LiveCanvas skill 的 Python 配对器移植成浏览器端 TypeScript(`src/jiangnan/export/livephoto.ts`),网页里一键生成 6 组 3:4 / 30fps / 3 秒实况图,压缩包内含同名 jpg + mov、.pvt 资源包、manifest、使用说明与图文文案。用 skill 原版 `verify_pair` + `package_live_photo.py` 校验浏览器生成的文件:标识一致、still-image-time 读回正确、ffprobe 时间样本正确、完整解码通过。
|
| 127 |
+
- **步骤 9** ✅ Blender 场景脚本 `blender/jiangnan_village.py`:已在沙盒里用 Blender 4.2 LTS(bpy)实际运行,生成 218 栋民居、178 盏灯笼(含关键帧点亮)、石拱桥、水车(旋转动画)、古樟、竹林、Nishita 天空 + 日落关键帧 + 蓝调天光、暮霭体积、10 个镜头(时间轴标记自动切换),并用 Cycles 试渲通过。
|
| 128 |
|
| 129 |
|
| 130 |
---
|
blender/jiangnan_village.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
《暮归江南》Blender 场景生成脚本 (Blender 4.0+ / 4.2 LTS / 4.x)
|
| 4 |
+
|
| 5 |
+
用法:Blender → Scripting 工作区 → 打开本文件 → Run Script。
|
| 6 |
+
脚本会清空当前场景,然后从零生成:
|
| 7 |
+
· 山谷地形、远山、梯田、蜿蜒小河(反射水面)、石驳岸、青石板街
|
| 8 |
+
· 徽派民居(粉墙、阶梯马头墙、黛瓦凹曲屋面、木格窗,窗户夜里亮灯)
|
| 9 |
+
· 半圆石拱桥、老水车(旋转动画)、乌篷船、集市摊位、村口古樟、石碾、六角亭、竹林
|
| 10 |
+
· 红灯笼(日落后按顺序逐个亮起)、炊烟(烟雾体积可选)
|
| 11 |
+
· Nishita 物理天空 + 太阳从 8.5° 落到 -10°(与网页版同一条日落曲线)、蓝调、星光
|
| 12 |
+
· 10 个镜头的摄像机与时间轴标记(自动切换镜头),24 fps,共 86 秒
|
| 13 |
+
与网页版共用同一套布局算法(同一条河道、同一个随机种子),所以村子的样子一致。
|
| 14 |
+
|
| 15 |
+
渲染:默认 Cycles(GPU 可在 偏好设置 → 系统 里开启),1920×1080,128 采样 + 降噪。
|
| 16 |
+
想先快速预览,可把 RENDER_ENGINE 改成 'BLENDER_EEVEE_NEXT'(4.2+)或 'BLENDER_EEVEE'。
|
| 17 |
+
"""
|
| 18 |
+
import bpy
|
| 19 |
+
import bmesh
|
| 20 |
+
import math
|
| 21 |
+
import random
|
| 22 |
+
from mathutils import Vector, Matrix, Euler
|
| 23 |
+
|
| 24 |
+
FPS = 24
|
| 25 |
+
DURATION = 86.0
|
| 26 |
+
RENDER_ENGINE = 'CYCLES'
|
| 27 |
+
SEED = 20261022
|
| 28 |
+
GROUND_Y = 1.7 # web units: y-up metres. Blender is z-up: (x, y, z)_web -> (x, -z, y)_blender
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def W2B(x, y, z):
|
| 32 |
+
"""web (x, y-up, z) -> blender (x, y, z-up)"""
|
| 33 |
+
return Vector((x, -z, y))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ------------------------------------------------------------------ deterministic noise (same as the web build)
|
| 37 |
+
def mulberry32(seed):
|
| 38 |
+
a = seed & 0xFFFFFFFF
|
| 39 |
+
|
| 40 |
+
def rnd():
|
| 41 |
+
nonlocal a
|
| 42 |
+
a = (a + 0x6D2B79F5) & 0xFFFFFFFF
|
| 43 |
+
t = a
|
| 44 |
+
t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
|
| 45 |
+
t ^= (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF)) & 0xFFFFFFFF
|
| 46 |
+
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
|
| 47 |
+
return rnd
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _imul(a, b):
|
| 51 |
+
return ((a & 0xFFFFFFFF) * (b & 0xFFFFFFFF)) & 0xFFFFFFFF
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def hash2(x, y, seed=0):
|
| 55 |
+
h = (_imul(int(x), 374761393) + _imul(int(y), 668265263) + _imul(int(seed), 2246822519)) & 0xFFFFFFFF
|
| 56 |
+
h = _imul(h ^ (h >> 13), 1274126177)
|
| 57 |
+
h ^= h >> 16
|
| 58 |
+
return (h & 0xFFFFFFFF) / 4294967296.0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fade(t):
|
| 62 |
+
return t * t * (3 - 2 * t)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def vnoise(x, y, seed=0):
|
| 66 |
+
xi, yi = math.floor(x), math.floor(y)
|
| 67 |
+
xf, yf = x - xi, y - yi
|
| 68 |
+
a, b = hash2(xi, yi, seed), hash2(xi + 1, yi, seed)
|
| 69 |
+
c, d = hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed)
|
| 70 |
+
u, v = fade(xf), fade(yf)
|
| 71 |
+
return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def fbm(x, y, octv=5, seed=0):
|
| 75 |
+
amp, f, s, n = 0.5, 1.0, 0.0, 0.0
|
| 76 |
+
for i in range(octv):
|
| 77 |
+
s += amp * vnoise(x * f, y * f, seed + i * 31)
|
| 78 |
+
n += amp
|
| 79 |
+
amp *= 0.5
|
| 80 |
+
f *= 2.03
|
| 81 |
+
return s / n
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def smoothstep(a, b, v):
|
| 85 |
+
t = min(1.0, max(0.0, (v - a) / (b - a)))
|
| 86 |
+
return t * t * (3 - 2 * t)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def lerp(a, b, t):
|
| 90 |
+
return a + (b - a) * t
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ------------------------------------------------------------------ river (same control points as layout.ts)
|
| 94 |
+
RIVER_CTRL = [(-1500, -330), (-1050, -260), (-760, -150), (-520, -70), (-330, -22), (-190, 6), (-80, 14),
|
| 95 |
+
(30, 4), (120, -10), (220, 6), (330, 46), (470, 92), (660, 104), (900, 60), (1300, -40), (1700, -120)]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def catmull(p0, p1, p2, p3, t):
|
| 99 |
+
t2, t3 = t * t, t * t * t
|
| 100 |
+
return 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build_river():
|
| 104 |
+
raw = []
|
| 105 |
+
n = len(RIVER_CTRL)
|
| 106 |
+
for i in range(n - 1):
|
| 107 |
+
p0, p1, p2, p3 = RIVER_CTRL[max(0, i - 1)], RIVER_CTRL[i], RIVER_CTRL[i + 1], RIVER_CTRL[min(n - 1, i + 2)]
|
| 108 |
+
for k in range(80):
|
| 109 |
+
t = k / 80
|
| 110 |
+
raw.append((catmull(p0[0], p1[0], p2[0], p3[0], t), catmull(p0[1], p1[1], p2[1], p3[1], t)))
|
| 111 |
+
raw.append(RIVER_CTRL[-1])
|
| 112 |
+
cum = [0.0]
|
| 113 |
+
for i in range(1, len(raw)):
|
| 114 |
+
cum.append(cum[-1] + math.hypot(raw[i][0] - raw[i - 1][0], raw[i][1] - raw[i - 1][1]))
|
| 115 |
+
out, j, s = [], 0, 0.0
|
| 116 |
+
while s <= cum[-1]:
|
| 117 |
+
while j < len(cum) - 2 and cum[j + 1] < s:
|
| 118 |
+
j += 1
|
| 119 |
+
f = (s - cum[j]) / max(1e-6, cum[j + 1] - cum[j])
|
| 120 |
+
out.append([lerp(raw[j][0], raw[j + 1][0], f), lerp(raw[j][1], raw[j + 1][1], f), 0.0, 0.0, s])
|
| 121 |
+
s += 2.0
|
| 122 |
+
for i in range(len(out)):
|
| 123 |
+
a, b = out[max(0, i - 2)], out[min(len(out) - 1, i + 2)]
|
| 124 |
+
l = math.hypot(b[0] - a[0], b[1] - a[1]) or 1
|
| 125 |
+
out[i][2] = (b[0] - a[0]) / l
|
| 126 |
+
out[i][3] = (b[1] - a[1]) / l
|
| 127 |
+
return out
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
RIVER = build_river()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def river_s_at_x(x):
|
| 134 |
+
return min(RIVER, key=lambda p: abs(p[0] - x))[4]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def river_point(s, off):
|
| 138 |
+
f = min(max(s / 2.0, 0), len(RIVER) - 1.001)
|
| 139 |
+
i = int(f)
|
| 140 |
+
t = f - i
|
| 141 |
+
a, b = RIVER[i], RIVER[i + 1]
|
| 142 |
+
tx, tz = lerp(a[2], b[2], t), lerp(a[3], b[3], t)
|
| 143 |
+
l = math.hypot(tx, tz) or 1
|
| 144 |
+
tx, tz = tx / l, tz / l
|
| 145 |
+
nx, nz = -tz, tx
|
| 146 |
+
return lerp(a[0], b[0], t) + nx * off, lerp(a[1], b[1], t) + nz * off, tx, tz, nx, nz
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
VILLAGE_S0 = river_s_at_x(-300)
|
| 150 |
+
VILLAGE_S1 = river_s_at_x(290)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def village_factor(s):
|
| 154 |
+
return smoothstep(VILLAGE_S0 - 30, VILLAGE_S0 + 10, s) * (1 - smoothstep(VILLAGE_S1 - 10, VILLAGE_S1 + 30, s))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def river_half_width(s):
|
| 158 |
+
inv = smoothstep(VILLAGE_S0 - 120, VILLAGE_S0, s) * (1 - smoothstep(VILLAGE_S1, VILLAGE_S1 + 120, s))
|
| 159 |
+
return lerp(13 + 3 * vnoise(s * 0.01, 3), 8, inv)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def river_query(x, z):
|
| 163 |
+
best, bi = 1e18, 0
|
| 164 |
+
for i in range(0, len(RIVER), 3):
|
| 165 |
+
p = RIVER[i]
|
| 166 |
+
d = (p[0] - x) ** 2 + (p[1] - z) ** 2
|
| 167 |
+
if d < best:
|
| 168 |
+
best, bi = d, i
|
| 169 |
+
for i in range(max(0, bi - 3), min(len(RIVER), bi + 4)):
|
| 170 |
+
p = RIVER[i]
|
| 171 |
+
d = (p[0] - x) ** 2 + (p[1] - z) ** 2
|
| 172 |
+
if d < best:
|
| 173 |
+
best, bi = d, i
|
| 174 |
+
return math.sqrt(best), RIVER[bi][4]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def terrain_height(x, z):
|
| 178 |
+
d, s = river_query(x, z)
|
| 179 |
+
h = GROUND_Y + (fbm(x * 0.012, z * 0.012, 3, 5) - 0.5) * 1.6 * smoothstep(50, 140, d)
|
| 180 |
+
far = math.hypot(x * 0.8, z)
|
| 181 |
+
hd = min(d, far * 0.9 + 40)
|
| 182 |
+
hill = smoothstep(130, 560, hd)
|
| 183 |
+
h += hill * hill * (60 + 190 * fbm(x * 0.0022 + 3, z * 0.0022, 5, 7))
|
| 184 |
+
w = river_half_width(s)
|
| 185 |
+
if d < w + 30:
|
| 186 |
+
bank_w = lerp(9, 1.2, village_factor(s))
|
| 187 |
+
bank = smoothstep(w - bank_w * 0.6, w + bank_w * 0.4, d)
|
| 188 |
+
h = lerp(-2.6, h, bank)
|
| 189 |
+
return h
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ------------------------------------------------------------------ helpers
|
| 193 |
+
def clear_scene():
|
| 194 |
+
bpy.ops.object.select_all(action='SELECT')
|
| 195 |
+
bpy.ops.object.delete(use_global=False)
|
| 196 |
+
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.lights, bpy.data.cameras, bpy.data.curves):
|
| 197 |
+
for b in list(coll):
|
| 198 |
+
if b.users == 0:
|
| 199 |
+
coll.remove(b)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def collection(name):
|
| 203 |
+
c = bpy.data.collections.get(name) or bpy.data.collections.new(name)
|
| 204 |
+
if c.name not in bpy.context.scene.collection.children:
|
| 205 |
+
bpy.context.scene.collection.children.link(c)
|
| 206 |
+
return c
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def mesh_object(name, verts, faces, coll, mat=None, smooth=False):
|
| 210 |
+
me = bpy.data.meshes.new(name)
|
| 211 |
+
me.from_pydata(verts, [], faces)
|
| 212 |
+
me.update()
|
| 213 |
+
ob = bpy.data.objects.new(name, me)
|
| 214 |
+
coll.objects.link(ob)
|
| 215 |
+
if mat:
|
| 216 |
+
ob.data.materials.append(mat)
|
| 217 |
+
if smooth:
|
| 218 |
+
for p in me.polygons:
|
| 219 |
+
p.use_smooth = True
|
| 220 |
+
return ob
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def principled(name, color, rough=0.8, metal=0.0, emission=None, strength=0.0, alpha=1.0):
|
| 224 |
+
m = bpy.data.materials.new(name)
|
| 225 |
+
m.use_nodes = True
|
| 226 |
+
bsdf = m.node_tree.nodes.get('Principled BSDF')
|
| 227 |
+
bsdf.inputs['Base Color'].default_value = (*color, 1)
|
| 228 |
+
bsdf.inputs['Roughness'].default_value = rough
|
| 229 |
+
bsdf.inputs['Metallic'].default_value = metal
|
| 230 |
+
if emission is not None:
|
| 231 |
+
key = 'Emission Color' if 'Emission Color' in bsdf.inputs else 'Emission'
|
| 232 |
+
bsdf.inputs[key].default_value = (*emission, 1)
|
| 233 |
+
bsdf.inputs['Emission Strength'].default_value = strength
|
| 234 |
+
return m
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def noise_material(name, c1, c2, scale, rough=0.85, bump=0.3):
|
| 238 |
+
"""Procedural weathered surface (lime plaster, tiles, stone)."""
|
| 239 |
+
m = bpy.data.materials.new(name)
|
| 240 |
+
m.use_nodes = True
|
| 241 |
+
nt = m.node_tree
|
| 242 |
+
bsdf = nt.nodes.get('Principled BSDF')
|
| 243 |
+
tex = nt.nodes.new('ShaderNodeTexNoise')
|
| 244 |
+
tex.inputs['Scale'].default_value = scale
|
| 245 |
+
tex.inputs['Detail'].default_value = 8
|
| 246 |
+
ramp = nt.nodes.new('ShaderNodeValToRGB')
|
| 247 |
+
ramp.color_ramp.elements[0].color = (*c1, 1)
|
| 248 |
+
ramp.color_ramp.elements[1].color = (*c2, 1)
|
| 249 |
+
nt.links.new(tex.outputs['Fac'], ramp.inputs['Fac'])
|
| 250 |
+
nt.links.new(ramp.outputs['Color'], bsdf.inputs['Base Color'])
|
| 251 |
+
bsdf.inputs['Roughness'].default_value = rough
|
| 252 |
+
bmp = nt.nodes.new('ShaderNodeBump')
|
| 253 |
+
bmp.inputs['Strength'].default_value = bump
|
| 254 |
+
nt.links.new(tex.outputs['Fac'], bmp.inputs['Height'])
|
| 255 |
+
nt.links.new(bmp.outputs['Normal'], bsdf.inputs['Normal'])
|
| 256 |
+
return m
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def add_box(bm, cx, cy, cz, sx, sy, sz, mat_index=0, matrix=None):
|
| 260 |
+
"""box in web local coords (x, y-up, z) -> appended to bm (blender coords)."""
|
| 261 |
+
res = bmesh.ops.create_cube(bm, size=1.0)
|
| 262 |
+
vs = res['verts']
|
| 263 |
+
for v in vs:
|
| 264 |
+
lx, ly, lz = v.co.x * sx + cx, v.co.y * sy + cy, v.co.z * sz + cz # cube axes treated as web axes
|
| 265 |
+
p = Vector((lx, ly, lz))
|
| 266 |
+
if matrix is not None:
|
| 267 |
+
p = matrix @ p
|
| 268 |
+
v.co = W2B(p.x, p.y, p.z)
|
| 269 |
+
for f in {f for v in vs for f in v.link_faces}:
|
| 270 |
+
f.material_index = mat_index
|
| 271 |
+
return vs
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def web_matrix(x, y, z, ry):
|
| 275 |
+
return Matrix.Translation((x, y, z)) @ Matrix.Rotation(ry, 4, 'Y')
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ------------------------------------------------------------------ build
|
| 279 |
+
def build_materials():
|
| 280 |
+
M = {}
|
| 281 |
+
M['wall'] = noise_material('粉墙', (0.78, 0.76, 0.71), (0.93, 0.92, 0.88), 6.0, 0.92, 0.15)
|
| 282 |
+
M['roof'] = noise_material('黛瓦', (0.035, 0.037, 0.042), (0.08, 0.085, 0.095), 40.0, 0.55, 0.6)
|
| 283 |
+
M['dark'] = principled('墨色', (0.03, 0.03, 0.032), 0.7)
|
| 284 |
+
M['wood'] = noise_material('老木', (0.12, 0.06, 0.03), (0.2, 0.11, 0.05), 20.0, 0.75, 0.2)
|
| 285 |
+
M['stone'] = noise_material('青石', (0.22, 0.23, 0.23), (0.36, 0.37, 0.36), 9.0, 0.85, 0.5)
|
| 286 |
+
M['window'] = principled('窗纸', (0.45, 0.4, 0.32), 0.9, emission=(1.0, 0.55, 0.22), strength=0.0)
|
| 287 |
+
M['lantern'] = principled('红灯笼', (0.7, 0.06, 0.03), 0.5, emission=(1.0, 0.32, 0.1), strength=0.0)
|
| 288 |
+
M['leaf'] = noise_material('樟叶', (0.04, 0.09, 0.025), (0.09, 0.16, 0.04), 30.0, 0.7, 0.4)
|
| 289 |
+
M['bark'] = noise_material('树皮', (0.05, 0.035, 0.025), (0.12, 0.09, 0.06), 25.0, 0.95, 0.8)
|
| 290 |
+
M['ground'] = noise_material('田野', (0.06, 0.1, 0.03), (0.2, 0.18, 0.07), 0.08, 0.95, 0.3)
|
| 291 |
+
M['bamboo'] = principled('竹', (0.18, 0.25, 0.07), 0.6)
|
| 292 |
+
M['cloth'] = principled('蓝印花布', (0.04, 0.08, 0.25), 0.9)
|
| 293 |
+
# water: glossy reflection with a jade body colour
|
| 294 |
+
w = bpy.data.materials.new('河水')
|
| 295 |
+
w.use_nodes = True
|
| 296 |
+
b = w.node_tree.nodes.get('Principled BSDF')
|
| 297 |
+
b.inputs['Base Color'].default_value = (0.01, 0.03, 0.025, 1)
|
| 298 |
+
b.inputs['Roughness'].default_value = 0.03
|
| 299 |
+
b.inputs['IOR'].default_value = 1.33
|
| 300 |
+
tex = w.node_tree.nodes.new('ShaderNodeTexNoise')
|
| 301 |
+
tex.inputs['Scale'].default_value = 3.0
|
| 302 |
+
bmp = w.node_tree.nodes.new('ShaderNodeBump')
|
| 303 |
+
bmp.inputs['Strength'].default_value = 0.08
|
| 304 |
+
w.node_tree.links.new(tex.outputs['Fac'], bmp.inputs['Height'])
|
| 305 |
+
w.node_tree.links.new(bmp.outputs['Normal'], b.inputs['Normal'])
|
| 306 |
+
M['water'] = w
|
| 307 |
+
return M
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def build_terrain(M, coll, step=6.0, extent=900):
|
| 311 |
+
verts, faces = [], []
|
| 312 |
+
n = int(2 * extent / step) + 1
|
| 313 |
+
for j in range(n):
|
| 314 |
+
for i in range(n):
|
| 315 |
+
x, z = -extent + i * step, -extent + j * step
|
| 316 |
+
verts.append(W2B(x, terrain_height(x, z), z))
|
| 317 |
+
for j in range(n - 1):
|
| 318 |
+
for i in range(n - 1):
|
| 319 |
+
a = j * n + i
|
| 320 |
+
faces.append((a, a + 1, a + n + 1, a + n))
|
| 321 |
+
ob = mesh_object('山谷地形', verts, faces, coll, M['ground'], smooth=True)
|
| 322 |
+
# far mountains ring
|
| 323 |
+
mverts, mfaces = [], []
|
| 324 |
+
rings = [extent * 1.05 * (1.12 ** k) for k in range(22)]
|
| 325 |
+
seg = 240
|
| 326 |
+
for r in rings:
|
| 327 |
+
for a in range(seg):
|
| 328 |
+
ang = a / seg * math.tau
|
| 329 |
+
x, z = math.cos(ang) * r, math.sin(ang) * r
|
| 330 |
+
d_sun = math.atan2(math.sin(ang - (math.pi + 0.2)), math.cos(ang - (math.pi + 0.2)))
|
| 331 |
+
west = math.exp(-(d_sun ** 2) / 0.5)
|
| 332 |
+
h = smoothstep(700, 2600, r) * (150 + 360 * fbm(x * 0.0009, z * 0.0009, 6, 77)) * (1 - 0.74 * west) + terrain_height(x, z) * 0.2
|
| 333 |
+
mverts.append(W2B(x, h, z))
|
| 334 |
+
for ri in range(len(rings) - 1):
|
| 335 |
+
for a in range(seg):
|
| 336 |
+
p, q = ri * seg + a, ri * seg + (a + 1) % seg
|
| 337 |
+
mfaces.append((p, q, q + seg, p + seg))
|
| 338 |
+
mesh_object('远山', mverts, mfaces, coll, M['ground'], smooth=True)
|
| 339 |
+
return ob
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def build_river(M, coll):
|
| 343 |
+
verts, faces = [], []
|
| 344 |
+
rows = 0
|
| 345 |
+
for k in range(0, len(RIVER), 2):
|
| 346 |
+
s = RIVER[k][4]
|
| 347 |
+
w = river_half_width(s) + 3
|
| 348 |
+
for off in (-w, w):
|
| 349 |
+
x, z, *_ = river_point(s, off)
|
| 350 |
+
verts.append(W2B(x, 0.0, z))
|
| 351 |
+
rows += 1
|
| 352 |
+
for r in range(rows - 1):
|
| 353 |
+
a = r * 2
|
| 354 |
+
faces.append((a, a + 1, a + 3, a + 2))
|
| 355 |
+
mesh_object('小河', verts, faces, coll, M['water'])
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def build_houses(M, coll):
|
| 359 |
+
"""Same row / spacing rules and RNG as buildHouses() in layout.ts."""
|
| 360 |
+
rng = mulberry32(SEED)
|
| 361 |
+
bridge_s, wheel_s = river_s_at_x(40), river_s_at_x(-196)
|
| 362 |
+
rows = [(-1, 14.6, True, 0.3), (1, 8.4, False, 0.2), (-1, 31, False, 2.5), (1, 25, False, 2.5), (-1, 50, False, 5), (1, 44, False, 5)]
|
| 363 |
+
houses = []
|
| 364 |
+
for side, off0, shop, jitter in rows:
|
| 365 |
+
s = VILLAGE_S0 + rng() * 6 + (10 if off0 > 20 else 0)
|
| 366 |
+
s_end = VILLAGE_S1 - (40 if off0 > 40 else 0)
|
| 367 |
+
while s < s_end:
|
| 368 |
+
w = 8 + rng() * 5
|
| 369 |
+
half = w / 2
|
| 370 |
+
sc = s + half
|
| 371 |
+
if off0 > 40 and rng() < 0.3:
|
| 372 |
+
s += w + 6
|
| 373 |
+
continue
|
| 374 |
+
blocked = abs(sc - bridge_s) < half + 8 or (side > 0 and abs(sc - wheel_s) < half + 10) or (side < 0 and sc < river_s_at_x(-264) + half)
|
| 375 |
+
if not blocked:
|
| 376 |
+
d = 8.5 + rng() * 3.5
|
| 377 |
+
off = side * (off0 + d / 2 + (rng() - 0.5) * jitter)
|
| 378 |
+
x, z, tx, tz, nx, nz = river_point(sc, off)
|
| 379 |
+
facing = math.atan2(-side * nx, -side * nz)
|
| 380 |
+
floors = 2 if rng() < (0.75 if shop else 0.55) else 1
|
| 381 |
+
rot = facing + (rng() - 0.5) * 0.05 * (3 if off0 > 20 else 1)
|
| 382 |
+
steps = (3 if rng() < 0.6 else 2) if floors == 2 else (2 if rng() < 0.5 else 1)
|
| 383 |
+
seed = int(rng() * 1e9)
|
| 384 |
+
chimney = rng() < 0.35
|
| 385 |
+
houses.append(dict(x=x, z=z, rot=rot, w=w, d=d, floors=floors, steps=steps, shop=shop, side=side, s=sc, chimney=chimney, lantern=off0 < 20))
|
| 386 |
+
s += w + ((0.2 + rng() * 0.6) if shop else (0.6 + rng() * 3.5))
|
| 387 |
+
if side > 0 and off0 < 10 and rng() < 0.18:
|
| 388 |
+
s += 5
|
| 389 |
+
bm = bmesh.new()
|
| 390 |
+
windows = []
|
| 391 |
+
lanterns = []
|
| 392 |
+
for h in houses:
|
| 393 |
+
m = web_matrix(h['x'], max(GROUND_Y, terrain_height(h['x'], h['z'])), h['z'], h['rot'])
|
| 394 |
+
w, d = h['w'], h['d']
|
| 395 |
+
H = h['floors'] * 3.3 + 0.5
|
| 396 |
+
rise = d * 0.27
|
| 397 |
+
add_box(bm, 0, H / 2 - 0.8, 0, w - 0.4, H + 1.6, d, 0, m) # lime walls
|
| 398 |
+
# stepped horse-head gables
|
| 399 |
+
n = h['steps']
|
| 400 |
+
seg = d / 2 / n
|
| 401 |
+
for sx in (-1, 1):
|
| 402 |
+
for i in range(n):
|
| 403 |
+
top = H + rise * (i + 1) / n + 0.55
|
| 404 |
+
z0 = d / 2 - i * seg if i else d / 2 + 0.15
|
| 405 |
+
z1 = d / 2 - (i + 1) * seg if i < n - 1 else -(d / 2 - i * seg if i else d / 2 + 0.15)
|
| 406 |
+
zc, zl = (z0 + z1) / 2, abs(z0 - z1)
|
| 407 |
+
for mirror in ((1,) if i == n - 1 else (1, -1)):
|
| 408 |
+
add_box(bm, sx * (w / 2 - 0.18), top / 2 - 0.8, mirror * zc, 0.36, top + 1.6, zl, 0, m)
|
| 409 |
+
add_box(bm, sx * (w / 2 - 0.18), top + 0.1, mirror * zc, 0.8, 0.2, zl + 0.3, 2, m)
|
| 410 |
+
# roof: two slopes (tiles)
|
| 411 |
+
for zs in (1, -1):
|
| 412 |
+
ang = math.atan2(rise, d / 2 + 0.8)
|
| 413 |
+
L = math.hypot(d / 2 + 0.85, rise)
|
| 414 |
+
rm = m @ Matrix.Translation((0, H + rise / 2 - 0.1, zs * (d / 4 + 0.42))) @ Matrix.Rotation(zs * ang, 4, 'X')
|
| 415 |
+
add_box(bm, 0, 0, 0, w - 0.6, 0.12, L, 1, rm)
|
| 416 |
+
add_box(bm, 0, H + rise + 0.1, 0, w - 0.6, 0.3, 0.4, 2, m) # ridge
|
| 417 |
+
# windows (emissive at night)
|
| 418 |
+
nw = max(2, int(w / 3.2))
|
| 419 |
+
for k in range(nw):
|
| 420 |
+
x = -w / 2 + (k + 0.5) * w / nw
|
| 421 |
+
windows.append((m, x, (3.3 + 1.7) if h['floors'] == 2 else 1.9, d / 2 + 0.03, h))
|
| 422 |
+
if h['lantern']:
|
| 423 |
+
for fx in (-0.3, 0.3):
|
| 424 |
+
p = m @ Vector((fx * w, 3.2 if h['floors'] == 2 else H - 0.6, d / 2 + 0.9))
|
| 425 |
+
lanterns.append((p, h['s'], h['side']))
|
| 426 |
+
me = bpy.data.meshes.new('徽派民居')
|
| 427 |
+
bm.to_mesh(me)
|
| 428 |
+
bm.free()
|
| 429 |
+
ob = bpy.data.objects.new('徽派民居', me)
|
| 430 |
+
for key in ('wall', 'roof', 'dark'):
|
| 431 |
+
ob.data.materials.append(M[key])
|
| 432 |
+
coll.objects.link(ob)
|
| 433 |
+
# window panes as one emissive mesh
|
| 434 |
+
bmw = bmesh.new()
|
| 435 |
+
for (m, x, y, z, h) in windows:
|
| 436 |
+
add_box(bmw, x, y, z, 1.1, 1.0, 0.04, 0, m)
|
| 437 |
+
mw = bpy.data.meshes.new('窗灯')
|
| 438 |
+
bmw.to_mesh(mw)
|
| 439 |
+
bmw.free()
|
| 440 |
+
ow = bpy.data.objects.new('窗灯', mw)
|
| 441 |
+
ow.data.materials.append(M['window'])
|
| 442 |
+
coll.objects.link(ow)
|
| 443 |
+
return houses, lanterns
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def build_bridge(M, coll):
|
| 447 |
+
s = river_s_at_x(40)
|
| 448 |
+
x, z, tx, tz, nx, nz = river_point(s, 0)
|
| 449 |
+
w = river_half_width(s)
|
| 450 |
+
R = w - 1.6
|
| 451 |
+
bpy.ops.mesh.primitive_cube_add(size=1)
|
| 452 |
+
body = bpy.context.active_object
|
| 453 |
+
body.name = '石拱桥'
|
| 454 |
+
body.scale = (2 * (w + 9), 4.4, R + 3.0)
|
| 455 |
+
body.location = (0, 0, (R + 3.0) / 2 - 2.2)
|
| 456 |
+
bpy.ops.mesh.primitive_cylinder_add(radius=R, depth=6, vertices=96)
|
| 457 |
+
hole = bpy.context.active_object
|
| 458 |
+
hole.rotation_euler = (math.pi / 2, 0, 0)
|
| 459 |
+
hole.location = (0, 0, -0.35)
|
| 460 |
+
mod = body.modifiers.new('拱洞', 'BOOLEAN')
|
| 461 |
+
mod.object = hole
|
| 462 |
+
mod.operation = 'DIFFERENCE'
|
| 463 |
+
bpy.context.view_layer.objects.active = body
|
| 464 |
+
bpy.ops.object.modifier_apply(modifier=mod.name)
|
| 465 |
+
bpy.data.objects.remove(hole, do_unlink=True)
|
| 466 |
+
# hump the deck: simple deform via vertex z offset
|
| 467 |
+
for v in body.data.vertices:
|
| 468 |
+
if v.co.z > 0:
|
| 469 |
+
fx = min(1.0, abs(v.co.x * body.scale.x) / (w + 9))
|
| 470 |
+
v.co.z += (1 - fx * fx) * 0.25
|
| 471 |
+
body.data.materials.append(M['stone'])
|
| 472 |
+
for c in body.users_collection:
|
| 473 |
+
c.objects.unlink(body)
|
| 474 |
+
coll.objects.link(body)
|
| 475 |
+
body.rotation_euler = (0, 0, math.atan2(-nz, nx) * -1 + 0)
|
| 476 |
+
ang = math.atan2(nz, nx)
|
| 477 |
+
body.rotation_euler = (0, 0, -ang)
|
| 478 |
+
body.location = W2B(x, 0, z) + Vector((0, 0, body.location.z))
|
| 479 |
+
return body
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def build_wheel(M, coll):
|
| 483 |
+
s = river_s_at_x(-196)
|
| 484 |
+
w = river_half_width(s)
|
| 485 |
+
x, z, tx, tz, nx, nz = river_point(s, w - 3.0)
|
| 486 |
+
bpy.ops.object.empty_add(type='PLAIN_AXES', location=W2B(x, 1.25, z))
|
| 487 |
+
pivot = bpy.context.active_object
|
| 488 |
+
pivot.name = '水车_轴心'
|
| 489 |
+
for c in pivot.users_collection:
|
| 490 |
+
c.objects.unlink(pivot)
|
| 491 |
+
coll.objects.link(pivot)
|
| 492 |
+
parts = []
|
| 493 |
+
for zs in (-0.55, 0.55):
|
| 494 |
+
bpy.ops.mesh.primitive_torus_add(major_radius=3.3, minor_radius=0.09, major_segments=64, minor_segments=6)
|
| 495 |
+
t = bpy.context.active_object
|
| 496 |
+
t.rotation_euler = (math.pi / 2, 0, 0)
|
| 497 |
+
t.location = (0, zs, 0)
|
| 498 |
+
parts.append(t)
|
| 499 |
+
for k in range(18):
|
| 500 |
+
a = k / 18 * math.tau
|
| 501 |
+
bpy.ops.mesh.primitive_cube_add(size=1)
|
| 502 |
+
p = bpy.context.active_object
|
| 503 |
+
p.scale = (0.62, 1.3, 0.05)
|
| 504 |
+
p.location = (math.cos(a) * 3.35, 0, math.sin(a) * 3.35)
|
| 505 |
+
p.rotation_euler = (0, -a, 0)
|
| 506 |
+
parts.append(p)
|
| 507 |
+
bpy.ops.mesh.primitive_cube_add(size=1)
|
| 508 |
+
sp = bpy.context.active_object
|
| 509 |
+
sp.scale = (3.3, 0.1, 0.1)
|
| 510 |
+
sp.location = (math.cos(a) * 1.65, 0.55 if k % 2 else -0.55, math.sin(a) * 1.65)
|
| 511 |
+
sp.rotation_euler = (0, -a, 0)
|
| 512 |
+
parts.append(sp)
|
| 513 |
+
for p in parts:
|
| 514 |
+
p.data.materials.append(M['wood'])
|
| 515 |
+
for c in p.users_collection:
|
| 516 |
+
c.objects.unlink(p)
|
| 517 |
+
coll.objects.link(p)
|
| 518 |
+
p.parent = pivot
|
| 519 |
+
pivot.rotation_euler = (0, 0, -math.atan2(nz, nx))
|
| 520 |
+
# rotation animation: -0.42 rad/s about the axle (same speed as the web version)
|
| 521 |
+
pivot.rotation_mode = 'XYZ'
|
| 522 |
+
pivot.keyframe_insert('rotation_euler', frame=1)
|
| 523 |
+
pivot.rotation_euler.rotate_axis('Y', 0.42 * DURATION)
|
| 524 |
+
pivot.keyframe_insert('rotation_euler', frame=int(DURATION * FPS) + 1)
|
| 525 |
+
for fc in pivot.animation_data.action.fcurves:
|
| 526 |
+
for kp in fc.keyframe_points:
|
| 527 |
+
kp.interpolation = 'LINEAR'
|
| 528 |
+
return pivot
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def build_camphor(M, coll):
|
| 532 |
+
tx, tz = river_point(river_s_at_x(-300), -34)[:2]
|
| 533 |
+
rnd = random.Random(777)
|
| 534 |
+
bm_bark, bm_leaf = bmesh.new(), bmesh.new()
|
| 535 |
+
|
| 536 |
+
def cyl(bm, a, b, r0, r1):
|
| 537 |
+
d = b - a
|
| 538 |
+
L = d.length
|
| 539 |
+
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=10, radius1=r0, radius2=r1, depth=L)
|
| 540 |
+
q = Vector((0, 0, 1)).rotation_difference(d.normalized())
|
| 541 |
+
bmesh.ops.rotate(bm, verts=res['verts'], cent=(0, 0, 0), matrix=q.to_matrix())
|
| 542 |
+
bmesh.ops.translate(bm, verts=res['verts'], vec=a + d * 0.5)
|
| 543 |
+
|
| 544 |
+
def branch(p, d, L, r, depth):
|
| 545 |
+
for i in range(3 if depth else 5):
|
| 546 |
+
d = (d + Vector((rnd.uniform(-0.28, 0.28), rnd.uniform(-0.28, 0.28), rnd.uniform(-0.1, 0.25)))).normalized()
|
| 547 |
+
np_ = p + d * (L / 3)
|
| 548 |
+
cyl(bm_bark, p, np_, r, r * 0.82)
|
| 549 |
+
p, r = np_, r * 0.82
|
| 550 |
+
if depth >= 4:
|
| 551 |
+
res = bmesh.ops.create_icosphere(bm_leaf, subdivisions=2, radius=rnd.uniform(1.6, 2.4))
|
| 552 |
+
bmesh.ops.translate(bm_leaf, verts=res['verts'], vec=p)
|
| 553 |
+
return
|
| 554 |
+
for k in range(rnd.randint(2, 4)):
|
| 555 |
+
az = k / 3 * math.tau + rnd.random()
|
| 556 |
+
nd = (d + Vector((math.cos(az), math.sin(az), 0)) * 0.9).normalized()
|
| 557 |
+
branch(p, nd, L * 0.72, r * 0.72, depth + 1)
|
| 558 |
+
|
| 559 |
+
base = W2B(tx, GROUND_Y - 0.4, tz)
|
| 560 |
+
branch(base, Vector((0, 0, 1)), 24 * 0.32, 1.25, 0)
|
| 561 |
+
for name, bm, mat in (('古樟_树干', bm_bark, M['bark']), ('古樟_树冠', bm_leaf, M['leaf'])):
|
| 562 |
+
me = bpy.data.meshes.new(name)
|
| 563 |
+
bm.to_mesh(me)
|
| 564 |
+
bm.free()
|
| 565 |
+
ob = bpy.data.objects.new(name, me)
|
| 566 |
+
ob.data.materials.append(mat)
|
| 567 |
+
coll.objects.link(ob)
|
| 568 |
+
if name.endswith('树冠'):
|
| 569 |
+
disp = ob.modifiers.new('叶团起伏', 'DISPLACE')
|
| 570 |
+
tex = bpy.data.textures.new('叶团噪声', 'CLOUDS')
|
| 571 |
+
tex.noise_scale = 0.8
|
| 572 |
+
disp.texture = tex
|
| 573 |
+
disp.strength = 0.9
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def build_bamboo(M, coll, count=380):
|
| 577 |
+
rnd = random.Random(31)
|
| 578 |
+
bm = bmesh.new()
|
| 579 |
+
placed = 0
|
| 580 |
+
tries = 0
|
| 581 |
+
while placed < count and tries < 20000:
|
| 582 |
+
tries += 1
|
| 583 |
+
x, z = rnd.uniform(-520, 560), rnd.uniform(-420, 380)
|
| 584 |
+
d, s = river_query(x, z)
|
| 585 |
+
if d < 60 or fbm(x * 0.012, z * 0.012, 3, 61) < 0.52:
|
| 586 |
+
continue
|
| 587 |
+
y = terrain_height(x, z)
|
| 588 |
+
for c in range(6):
|
| 589 |
+
a, r = rnd.random() * math.tau, rnd.random() * 1.2
|
| 590 |
+
p = W2B(x + math.cos(a) * r, y, z + math.sin(a) * r)
|
| 591 |
+
h = rnd.uniform(9, 14)
|
| 592 |
+
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=5, radius1=0.05, radius2=0.025, depth=h)
|
| 593 |
+
bmesh.ops.translate(bm, verts=res['verts'], vec=p + Vector((0, 0, h / 2)))
|
| 594 |
+
res = bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1.4)
|
| 595 |
+
bmesh.ops.scale(bm, verts=res['verts'], vec=(1, 1, 2.2))
|
| 596 |
+
bmesh.ops.translate(bm, verts=res['verts'], vec=p + Vector((0, 0, h * 0.8)))
|
| 597 |
+
placed += 1
|
| 598 |
+
me = bpy.data.meshes.new('竹林')
|
| 599 |
+
bm.to_mesh(me)
|
| 600 |
+
bm.free()
|
| 601 |
+
ob = bpy.data.objects.new('竹林', me)
|
| 602 |
+
ob.data.materials.append(M['bamboo'])
|
| 603 |
+
coll.objects.link(ob)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def build_lanterns(M, coll, lanterns):
|
| 607 |
+
"""Lanterns light one after another along the market street from 58.4 s."""
|
| 608 |
+
first_s = river_s_at_x(-128)
|
| 609 |
+
objs = []
|
| 610 |
+
for i, (p, s, side) in enumerate(lanterns):
|
| 611 |
+
f = min(1, max(0, (s - first_s) / (VILLAGE_S1 - first_s)))
|
| 612 |
+
on_t = 58.4 + 0.9 + 7 * f + (1.2 if side > 0 else 0) + (i % 7) * 0.07
|
| 613 |
+
mat = M['lantern'].copy()
|
| 614 |
+
mat.name = f'灯笼_{i:03d}'
|
| 615 |
+
bsdf = mat.node_tree.nodes.get('Principled BSDF')
|
| 616 |
+
sock = bsdf.inputs['Emission Strength']
|
| 617 |
+
sock.default_value = 0.0
|
| 618 |
+
sock.keyframe_insert('default_value', frame=max(1, int(on_t * FPS) - 1))
|
| 619 |
+
sock.default_value = 18.0
|
| 620 |
+
sock.keyframe_insert('default_value', frame=int(on_t * FPS) + 6)
|
| 621 |
+
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.3, segments=16, ring_count=10, location=W2B(p.x, p.y - 0.42, p.z))
|
| 622 |
+
ob = bpy.context.active_object
|
| 623 |
+
ob.scale = (1, 1, 1.25)
|
| 624 |
+
ob.name = f'灯笼_{i:03d}'
|
| 625 |
+
ob.data.materials.append(mat)
|
| 626 |
+
for c in ob.users_collection:
|
| 627 |
+
c.objects.unlink(ob)
|
| 628 |
+
coll.objects.link(ob)
|
| 629 |
+
# a small warm point light per lantern (Cycles handles hundreds fine)
|
| 630 |
+
ld = bpy.data.lights.new(f'灯光_{i:03d}', 'POINT')
|
| 631 |
+
ld.color = (1.0, 0.55, 0.25)
|
| 632 |
+
ld.shadow_soft_size = 0.3
|
| 633 |
+
ld.energy = 0
|
| 634 |
+
ld.keyframe_insert('energy', frame=max(1, int(on_t * FPS) - 1))
|
| 635 |
+
ld.energy = 40
|
| 636 |
+
ld.keyframe_insert('energy', frame=int(on_t * FPS) + 6)
|
| 637 |
+
lo = bpy.data.objects.new(f'灯光_{i:03d}', ld)
|
| 638 |
+
lo.location = W2B(p.x, p.y - 0.42, p.z)
|
| 639 |
+
coll.objects.link(lo)
|
| 640 |
+
objs.append(ob)
|
| 641 |
+
# windows glow later
|
| 642 |
+
w = bpy.data.materials.get('窗纸')
|
| 643 |
+
sock = w.node_tree.nodes.get('Principled BSDF').inputs['Emission Strength']
|
| 644 |
+
sock.default_value = 0
|
| 645 |
+
sock.keyframe_insert('default_value', frame=int(58.5 * FPS))
|
| 646 |
+
sock.default_value = 6
|
| 647 |
+
sock.keyframe_insert('default_value', frame=int(70 * FPS))
|
| 648 |
+
return objs
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
SUN_KEYS = [(0, 8.5), (17, 6.6), (32, 4.6), (44, 2.6), (52, 0.9), (57, -0.8), (62, -2.8), (68, -5.2), (76, -7.6), (86, -9.8)]
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def build_sky_and_sun(scene):
|
| 655 |
+
world = bpy.data.worlds.new('暮色天空')
|
| 656 |
+
scene.world = world
|
| 657 |
+
world.use_nodes = True
|
| 658 |
+
nt = world.node_tree
|
| 659 |
+
bg = nt.nodes.get('Background')
|
| 660 |
+
sky = nt.nodes.new('ShaderNodeTexSky')
|
| 661 |
+
sky.sky_type = 'NISHITA'
|
| 662 |
+
sky.sun_rotation = math.pi / 2 + 0.2 + math.pi / 2 # sets toward west-north-west
|
| 663 |
+
sky.air_density = 1.2
|
| 664 |
+
sky.dust_density = 2.5
|
| 665 |
+
nt.links.new(sky.outputs['Color'], bg.inputs['Color'])
|
| 666 |
+
# twilight layer: deep blue dome with a warm afterglow band, faded in after sunset
|
| 667 |
+
tw = nt.nodes.new('ShaderNodeBackground')
|
| 668 |
+
grad_tc = nt.nodes.new('ShaderNodeTexCoord')
|
| 669 |
+
sep = nt.nodes.new('ShaderNodeSeparateXYZ')
|
| 670 |
+
nt.links.new(grad_tc.outputs['Generated'], sep.inputs['Vector'])
|
| 671 |
+
ramp = nt.nodes.new('ShaderNodeValToRGB')
|
| 672 |
+
ramp.color_ramp.elements[0].position = 0.5
|
| 673 |
+
ramp.color_ramp.elements[0].color = (0.35, 0.16, 0.1, 1)
|
| 674 |
+
ramp.color_ramp.elements[1].position = 0.62
|
| 675 |
+
ramp.color_ramp.elements[1].color = (0.02, 0.035, 0.1, 1)
|
| 676 |
+
nt.links.new(sep.outputs['Z'], ramp.inputs['Fac'])
|
| 677 |
+
nt.links.new(ramp.outputs['Color'], tw.inputs['Color'])
|
| 678 |
+
add = nt.nodes.new('ShaderNodeAddShader')
|
| 679 |
+
nt.links.new(bg.outputs['Background'], add.inputs[0])
|
| 680 |
+
nt.links.new(tw.outputs['Background'], add.inputs[1])
|
| 681 |
+
nt.links.new(add.outputs['Shader'], nt.nodes.get('World Output').inputs['Surface'])
|
| 682 |
+
sun_data = bpy.data.lights.new('太阳', 'SUN')
|
| 683 |
+
sun_data.angle = math.radians(1.2)
|
| 684 |
+
sun = bpy.data.objects.new('太阳', sun_data)
|
| 685 |
+
scene.collection.objects.link(sun)
|
| 686 |
+
moon_data = bpy.data.lights.new('月光', 'SUN')
|
| 687 |
+
moon_data.color = (0.6, 0.7, 1.0)
|
| 688 |
+
moon_data.energy = 0.0
|
| 689 |
+
moon = bpy.data.objects.new('月光', moon_data)
|
| 690 |
+
moon.rotation_euler = (math.radians(66), 0, math.radians(-110))
|
| 691 |
+
scene.collection.objects.link(moon)
|
| 692 |
+
for t, e in SUN_KEYS:
|
| 693 |
+
f = int(t * FPS) + 1
|
| 694 |
+
er = math.radians(e)
|
| 695 |
+
# Nishita is undefined below the horizon (can return NaN = black frames): clamp it
|
| 696 |
+
# and let the twilight layer + exposure carry the blue hour instead.
|
| 697 |
+
sky.sun_elevation = max(er, math.radians(0.4))
|
| 698 |
+
sky.keyframe_insert('sun_elevation', frame=f)
|
| 699 |
+
sun.rotation_euler = (math.pi / 2 - er, 0, -(math.pi / 2 + 0.2 + math.pi / 2))
|
| 700 |
+
sun.keyframe_insert('rotation_euler', frame=f)
|
| 701 |
+
sun_data.energy = max(0.0, 4.0 * smoothstep(-1.2, 6, e))
|
| 702 |
+
sun_data.color = (1.0, lerp(0.38, 0.76, smoothstep(0, 9, e)), lerp(0.14, 0.52, smoothstep(0, 9, e)))
|
| 703 |
+
sun_data.keyframe_insert('energy', frame=f)
|
| 704 |
+
sun_data.keyframe_insert('color', frame=f)
|
| 705 |
+
bg.inputs['Strength'].default_value = lerp(0.0, 1.0, smoothstep(-5, 2, e))
|
| 706 |
+
bg.inputs['Strength'].keyframe_insert('default_value', frame=f)
|
| 707 |
+
moon_data.energy = 0.35 * smoothstep(-1.5, -8.5, e)
|
| 708 |
+
moon_data.keyframe_insert('energy', frame=f)
|
| 709 |
+
tw.inputs['Strength'].default_value = 0.6 * smoothstep(1.0, -4.0, e)
|
| 710 |
+
tw.inputs['Strength'].keyframe_insert('default_value', frame=f)
|
| 711 |
+
# camera eye adaptation, like the web version
|
| 712 |
+
scene.view_settings.exposure = lerp(0.0, 3.2, smoothstep(1, -9, e))
|
| 713 |
+
scene.view_settings.keyframe_insert('exposure', frame=f)
|
| 714 |
+
# thin evening mist: a bounded volume over the valley (a world volume would swallow the sky)
|
| 715 |
+
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 30))
|
| 716 |
+
mist = bpy.context.active_object
|
| 717 |
+
mist.name = '暮霭'
|
| 718 |
+
mist.scale = (2400, 2400, 60)
|
| 719 |
+
mm = bpy.data.materials.new('暮霭')
|
| 720 |
+
mm.use_nodes = True
|
| 721 |
+
nt2 = mm.node_tree
|
| 722 |
+
nt2.nodes.remove(nt2.nodes.get('Principled BSDF'))
|
| 723 |
+
vol = nt2.nodes.new('ShaderNodeVolumePrincipled')
|
| 724 |
+
vol.inputs['Density'].default_value = 0.0012
|
| 725 |
+
vol.inputs['Color'].default_value = (0.85, 0.8, 0.9, 1)
|
| 726 |
+
nt2.links.new(vol.outputs['Volume'], nt2.nodes.get('Material Output').inputs['Volume'])
|
| 727 |
+
mist.data.materials.append(mm)
|
| 728 |
+
mist.visible_shadow = False
|
| 729 |
+
vol.inputs['Density'].keyframe_insert('default_value', frame=1)
|
| 730 |
+
vol.inputs['Density'].default_value = 0.0026
|
| 731 |
+
vol.inputs['Density'].keyframe_insert('default_value', frame=int(80 * FPS))
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
SHOT_TIMES = [(1, 0), (2, 9), (3, 17), (4, 25), (5, 32), (6, 40), (7, 48), (8, 55), (9, 65), (10, 74)]
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def build_cameras(scene, coll):
|
| 738 |
+
"""Ten shots. Key poses (web coordinates) mirror camera.ts; markers switch the active camera."""
|
| 739 |
+
bs = river_s_at_x(40)
|
| 740 |
+
bx, bz, btx, btz, bnx, bnz = river_point(bs, 0)
|
| 741 |
+
ws = river_s_at_x(-196)
|
| 742 |
+
wx, wz, wtx, wtz, wnx, wnz = river_point(ws, river_half_width(ws) - 3)
|
| 743 |
+
tx, tz = river_point(river_s_at_x(-300), -34)[:2]
|
| 744 |
+
def rp(s_, off, y):
|
| 745 |
+
x_, z_ = river_point(s_, off)[:2]
|
| 746 |
+
return (x_, y, z_)
|
| 747 |
+
|
| 748 |
+
def qp(s_, lane, y): # point on the market quay, same as quayPoint() in layout.ts
|
| 749 |
+
return rp(s_, -(11 + lane), y)
|
| 750 |
+
|
| 751 |
+
shots = {
|
| 752 |
+
1: ((470, 132, 150), (-1100, 60, 160), (310, 70, 82), (-1000, 30, 130), 38),
|
| 753 |
+
2: (rp(bs + 150, 1.2, 2.0), rp(bs + 116, 0, 2.3), rp(bs + 62, 1.2, 2.0), rp(bs + 28, 0, 2.3), 46),
|
| 754 |
+
3: ((bx + btx * 40 - bnx * 3.2, 1.05, bz + btz * 40 - bnz * 3.2), (bx, 3.1, bz), (bx + btx * 33 + bnx * 2.6, 1.05, bz + btz * 33 + bnz * 2.6), (bx, 3.1, bz), 34),
|
| 755 |
+
4: ((wx - wnx * 15.5 + wtx * 7, 2.9, wz - wnz * 15.5 + wtz * 7), (wx, 2.2, wz), (wx - wnx * 12.2 + wtx * 3.2, 2.4, wz - wnz * 12.2 + wtz * 3.2), (wx, 2.2, wz), 40),
|
| 756 |
+
5: (qp(river_s_at_x(-175) + 3.8 + 15, -1.1, GROUND_Y + 1.75), qp(river_s_at_x(-175) + 3.8 - 6, 1.0, GROUND_Y + 1.6),
|
| 757 |
+
qp(river_s_at_x(-175) + 11.4 + 12.5, -1.1, GROUND_Y + 1.75), qp(river_s_at_x(-175) + 11.4 - 6, 1.0, GROUND_Y + 1.6), 44),
|
| 758 |
+
6: ((tx + 25, 3.5, tz + 16.5), (tx - 2, 9.7, tz - 4), (tx + 19, 4.4, tz + 12.5), (tx - 2, 11.7, tz - 4), 46),
|
| 759 |
+
7: ((110, 26, -120), (300, 140, -410), (98, 30, -126), (205, 70, -372), 21),
|
| 760 |
+
8: (qp(river_s_at_x(-150), -0.8, GROUND_Y + 2.6), qp(river_s_at_x(-150) + 26, -1.6, GROUND_Y + 2.4),
|
| 761 |
+
qp(river_s_at_x(-150) + 21, -0.8, GROUND_Y + 2.6), qp(river_s_at_x(-150) + 47, -1.6, GROUND_Y + 2.4), 48),
|
| 762 |
+
9: (rp(bs + 52, -4.6, 1.25), (bx + bnx * 1.5, 3.4, bz + bnz * 1.5), rp(bs + 45, -4.6, 1.25), (bx + bnx * 1.5, 3.4, bz + bnz * 1.5), 42),
|
| 763 |
+
10: ((95, 9, 34), (-10, 3, -4), (250, 125, 205), (-520, 60, -230), 44),
|
| 764 |
+
}
|
| 765 |
+
cams = {}
|
| 766 |
+
for i, (sid, t0) in enumerate(SHOT_TIMES):
|
| 767 |
+
t1 = SHOT_TIMES[i + 1][1] if i + 1 < len(SHOT_TIMES) else DURATION
|
| 768 |
+
p0, a0, p1, a1, fov = shots[sid]
|
| 769 |
+
cd = bpy.data.cameras.new(f'镜头{sid:02d}')
|
| 770 |
+
cd.lens_unit = 'FOV'
|
| 771 |
+
cd.angle = math.radians(fov * 16 / 9 * 0.92)
|
| 772 |
+
cd.sensor_fit = 'HORIZONTAL'
|
| 773 |
+
cam = bpy.data.objects.new(f'镜头{sid:02d}', cd)
|
| 774 |
+
coll.objects.link(cam)
|
| 775 |
+
tgt = bpy.data.objects.new(f'镜头{sid:02d}_目标', None)
|
| 776 |
+
coll.objects.link(tgt)
|
| 777 |
+
con = cam.constraints.new('TRACK_TO')
|
| 778 |
+
con.target = tgt
|
| 779 |
+
con.track_axis = 'TRACK_NEGATIVE_Z'
|
| 780 |
+
con.up_axis = 'UP_Y'
|
| 781 |
+
for (p, a), t in (((p0, a0), t0), ((p1, a1), t1)):
|
| 782 |
+
cam.location = W2B(*p)
|
| 783 |
+
tgt.location = W2B(*a)
|
| 784 |
+
cam.keyframe_insert('location', frame=int(t * FPS) + 1)
|
| 785 |
+
tgt.keyframe_insert('location', frame=int(t * FPS) + 1)
|
| 786 |
+
m = scene.timeline_markers.new(f'S{sid:02d}', frame=int(t0 * FPS) + 1)
|
| 787 |
+
m.camera = cam
|
| 788 |
+
cams[sid] = cam
|
| 789 |
+
scene.camera = cams[1]
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def setup_render(scene):
|
| 793 |
+
scene.render.fps = FPS
|
| 794 |
+
scene.frame_start = 1
|
| 795 |
+
scene.frame_end = int(DURATION * FPS)
|
| 796 |
+
scene.render.resolution_x = 1920
|
| 797 |
+
scene.render.resolution_y = 1080
|
| 798 |
+
try:
|
| 799 |
+
scene.render.engine = RENDER_ENGINE
|
| 800 |
+
except TypeError:
|
| 801 |
+
scene.render.engine = 'CYCLES'
|
| 802 |
+
if scene.render.engine == 'CYCLES':
|
| 803 |
+
scene.cycles.samples = 128
|
| 804 |
+
scene.cycles.use_denoising = True
|
| 805 |
+
scene.cycles.max_bounces = 6
|
| 806 |
+
scene.view_settings.view_transform = 'AgX' if 'AgX' in [i.identifier for i in scene.view_settings.bl_rna.properties['view_transform'].enum_items] else 'Filmic'
|
| 807 |
+
scene.view_settings.look = 'None'
|
| 808 |
+
scene.render.image_settings.file_format = 'FFMPEG'
|
| 809 |
+
scene.render.ffmpeg.format = 'MPEG4'
|
| 810 |
+
scene.render.ffmpeg.codec = 'H264'
|
| 811 |
+
scene.render.ffmpeg.constant_rate_factor = 'HIGH'
|
| 812 |
+
scene.render.filepath = '//暮归江南_blender_'
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def main():
|
| 816 |
+
clear_scene()
|
| 817 |
+
scene = bpy.context.scene
|
| 818 |
+
M = build_materials()
|
| 819 |
+
land = collection('地形与河流')
|
| 820 |
+
town = collection('村落')
|
| 821 |
+
life = collection('灯火')
|
| 822 |
+
cams = collection('镜头')
|
| 823 |
+
print('[暮归江南] 地形…')
|
| 824 |
+
build_terrain(M, land)
|
| 825 |
+
build_river(M, land)
|
| 826 |
+
print('[暮归江南] 民居…')
|
| 827 |
+
houses, lanterns = build_houses(M, town)
|
| 828 |
+
print('[暮归江南] 石桥、水车、��樟、竹林…')
|
| 829 |
+
build_bridge(M, town)
|
| 830 |
+
build_wheel(M, town)
|
| 831 |
+
build_camphor(M, town)
|
| 832 |
+
build_bamboo(M, land)
|
| 833 |
+
print('[暮归江南] 灯笼…')
|
| 834 |
+
build_lanterns(M, life, lanterns)
|
| 835 |
+
build_sky_and_sun(scene)
|
| 836 |
+
build_cameras(scene, cams)
|
| 837 |
+
setup_render(scene)
|
| 838 |
+
print(f'[暮归江南] 完成:{len(houses)} 栋民居,{len(lanterns)} 盏灯笼,{int(DURATION * FPS)} 帧。')
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
if __name__ == '__main__':
|
| 842 |
+
main()
|
src/jiangnan/JiangnanApp.tsx
CHANGED
|
@@ -1,30 +1,666 @@
|
|
| 1 |
-
import { useEffect, useRef, useState } from 'react'
|
| 2 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
// Temporary test harness (replaced by the full player UI)
|
| 5 |
export default function JiangnanApp() {
|
| 6 |
-
const
|
| 7 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
useEffect(() => {
|
| 9 |
-
|
| 10 |
-
const
|
| 11 |
const q = new URLSearchParams(location.search)
|
| 12 |
-
const
|
| 13 |
-
film
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
return (
|
| 25 |
-
<div
|
| 26 |
-
<
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
)
|
| 30 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
| 2 |
+
import './jiangnan.css'
|
| 3 |
+
import { buildMix } from './audio/mixer'
|
| 4 |
+
import blenderScript from './blender/jiangnan_village.py?raw'
|
| 5 |
+
import { chars, formatTags, LIVE_COPY, PLATFORMS } from './copy/platforms'
|
| 6 |
+
import { Film, Quality, renderIds } from './engine/Film'
|
| 7 |
+
import { DURATION, SHOTS, shotAt } from './engine/timeline'
|
| 8 |
+
import { canvasToBlob, COVER_DESIGNS, Ratio, RATIOS, renderCover } from './export/covers'
|
| 9 |
+
import { generateLivePhotos, LIVE_CLIPS, LiveResult } from './export/livegen'
|
| 10 |
+
import { exportMp4, exportSupport } from './export/mp4'
|
| 11 |
+
import { drawOverlay, loadFonts } from './player/overlay'
|
| 12 |
+
import { strToU8, zipSync, Zippable } from 'fflate'
|
| 13 |
+
|
| 14 |
+
type Aspect = 'landscape' | 'portrait'
|
| 15 |
+
|
| 16 |
+
const fmt = (t: number) => {
|
| 17 |
+
const m = Math.floor(t / 60), s = Math.floor(t % 60), cs = Math.floor((t % 1) * 10)
|
| 18 |
+
return `${m}:${String(s).padStart(2, '0')}.${cs}`
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function download(blob: Blob, name: string) {
|
| 22 |
+
const a = document.createElement('a')
|
| 23 |
+
a.href = URL.createObjectURL(blob)
|
| 24 |
+
a.download = name
|
| 25 |
+
document.body.appendChild(a)
|
| 26 |
+
a.click()
|
| 27 |
+
a.remove()
|
| 28 |
+
setTimeout(() => URL.revokeObjectURL(a.href), 60000)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function copyText(text: string) {
|
| 32 |
+
try {
|
| 33 |
+
await navigator.clipboard.writeText(text)
|
| 34 |
+
return true
|
| 35 |
+
} catch {
|
| 36 |
+
const ta = document.createElement('textarea')
|
| 37 |
+
ta.value = text
|
| 38 |
+
ta.style.position = 'fixed'
|
| 39 |
+
ta.style.opacity = '0'
|
| 40 |
+
document.body.appendChild(ta)
|
| 41 |
+
ta.select()
|
| 42 |
+
const ok = document.execCommand('copy')
|
| 43 |
+
ta.remove()
|
| 44 |
+
return ok
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function blobB64(b: Blob) {
|
| 49 |
+
const u = new Uint8Array(await b.arrayBuffer())
|
| 50 |
+
let s = ''
|
| 51 |
+
for (let i = 0; i < u.length; i += 32768) s += String.fromCharCode(...u.subarray(i, i + 32768))
|
| 52 |
+
return btoa(s)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const isMobile = () => typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
| 56 |
|
|
|
|
| 57 |
export default function JiangnanApp() {
|
| 58 |
+
const playerRef = useRef<HTMLDivElement>(null)
|
| 59 |
+
const glRef = useRef<HTMLCanvasElement>(null)
|
| 60 |
+
const ovRef = useRef<HTMLCanvasElement>(null)
|
| 61 |
+
const filmRef = useRef<Film | null>(null)
|
| 62 |
+
const audioRef = useRef<{ ctx: AudioContext | null; buf: AudioBuffer | null; src: AudioBufferSourceNode | null; gain: GainNode | null }>({ ctx: null, buf: null, src: null, gain: null })
|
| 63 |
+
const clockRef = useRef({ playing: false, startPerf: 0, startT: 0, t: 0 })
|
| 64 |
+
const scaleRef = useRef(1)
|
| 65 |
+
const busyRef = useRef(false)
|
| 66 |
+
const dirtyRef = useRef(true)
|
| 67 |
+
|
| 68 |
+
const [ready, setReady] = useState(false)
|
| 69 |
+
const [load, setLoad] = useState({ p: 0, label: '准备中' })
|
| 70 |
+
const [audioReady, setAudioReady] = useState(false)
|
| 71 |
+
const [playing, setPlaying] = useState(false)
|
| 72 |
+
const [time, setTime] = useState(0)
|
| 73 |
+
const [aspect, setAspect] = useState<Aspect>(() => (typeof window !== 'undefined' && window.innerHeight > window.innerWidth && isMobile() ? 'portrait' : 'landscape'))
|
| 74 |
+
const [subs, setSubs] = useState(true)
|
| 75 |
+
const [muted, setMuted] = useState(false)
|
| 76 |
+
const [toast, setToast] = useState('')
|
| 77 |
+
const [err, setErr] = useState('')
|
| 78 |
+
|
| 79 |
+
const say = useCallback((m: string) => {
|
| 80 |
+
setToast(m)
|
| 81 |
+
window.setTimeout(() => setToast(''), 1800)
|
| 82 |
+
}, [])
|
| 83 |
+
|
| 84 |
+
// ------------------------------------------------------------ init film + audio
|
| 85 |
useEffect(() => {
|
| 86 |
+
if (filmRef.current) return // keep the built world across hot reloads
|
| 87 |
+
const canvas = glRef.current!
|
| 88 |
const q = new URLSearchParams(location.search)
|
| 89 |
+
const quality: Quality = (q.get('q') as Quality) || (isMobile() ? 'medium' : 'high')
|
| 90 |
+
const film = new Film(canvas, quality)
|
| 91 |
+
filmRef.current = film
|
| 92 |
+
const w = Number(q.get('w') || 0), h = Number(q.get('h') || 0)
|
| 93 |
+
// automation hooks (used by the QA scripts)
|
| 94 |
+
const win = window as unknown as Record<string, unknown>
|
| 95 |
+
win.__film = film
|
| 96 |
+
win.__ids = (t: number) => renderIds(film, t)
|
| 97 |
+
win.__test = {
|
| 98 |
+
mp4: async (w: number, h: number, fps: 24 | 30 | 60, start: number, duration: number) => {
|
| 99 |
+
const r = await exportMp4(film, { width: w, height: h, fps, start, duration, audio: audioRef.current.buf, overlay: { subtitles: true, titles: true }, signal: { cancelled: false }, onProgress: () => {} })
|
| 100 |
+
return blobB64(r.blob)
|
| 101 |
+
},
|
| 102 |
+
live: async (w: number, h: number, frames: number) => {
|
| 103 |
+
const r = await generateLivePhotos(film, () => {}, { cancelled: false }, { w, h, frames, clips: 1 })
|
| 104 |
+
return blobB64(r.zip)
|
| 105 |
+
},
|
| 106 |
+
}
|
| 107 |
+
let cancelled = false
|
| 108 |
+
;(async () => {
|
| 109 |
+
try {
|
| 110 |
+
await loadFonts()
|
| 111 |
+
if (w && h) { film.width = w; film.height = h }
|
| 112 |
+
await film.init((p, label) => !cancelled && setLoad({ p: p * 0.85, label }))
|
| 113 |
+
if (w && h) film.setSize(w, h)
|
| 114 |
+
film.renderFrame(Number(q.get('t') || 0.01))
|
| 115 |
+
if (cancelled) return
|
| 116 |
+
setReady(true)
|
| 117 |
+
document.title = 'ready'
|
| 118 |
+
setLoad({ p: 0.9, label: '合成配乐与旁白' })
|
| 119 |
+
if (q.get('noaudio') !== '1') {
|
| 120 |
+
const buf = await buildMix(undefined, (p) => !cancelled && setLoad({ p: 0.85 + p * 0.15, label: '合成配乐与旁白' }))
|
| 121 |
+
audioRef.current.buf = buf
|
| 122 |
+
if (!cancelled) setAudioReady(true)
|
| 123 |
+
}
|
| 124 |
+
} catch (e) {
|
| 125 |
+
console.error(e)
|
| 126 |
+
setErr('加载失败:' + (e as Error).message)
|
| 127 |
+
document.title = 'error'
|
| 128 |
+
}
|
| 129 |
+
})()
|
| 130 |
+
return () => { cancelled = true }
|
| 131 |
+
}, [])
|
| 132 |
+
|
| 133 |
+
// ------------------------------------------------------------ sizing
|
| 134 |
+
const resize = useCallback(() => {
|
| 135 |
+
const film = filmRef.current
|
| 136 |
+
const el = playerRef.current
|
| 137 |
+
if (!film || !el || busyRef.current) return
|
| 138 |
+
const rect = el.getBoundingClientRect()
|
| 139 |
+
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
| 140 |
+
const maxLong = isMobile() ? 1100 : 1600
|
| 141 |
+
let W = rect.width * dpr * scaleRef.current, H = rect.height * dpr * scaleRef.current
|
| 142 |
+
const long = Math.max(W, H)
|
| 143 |
+
if (long > maxLong) { W *= maxLong / long; H *= maxLong / long }
|
| 144 |
+
W = Math.max(160, Math.round(W)), H = Math.max(90, Math.round(H))
|
| 145 |
+
if (W !== film.width || H !== film.height || !film.ready) {
|
| 146 |
+
if (film.ready) film.setSize(W, H)
|
| 147 |
+
else { film.width = W; film.height = H }
|
| 148 |
+
const ov = ovRef.current!
|
| 149 |
+
ov.width = Math.round(rect.width * dpr)
|
| 150 |
+
ov.height = Math.round(rect.height * dpr)
|
| 151 |
+
dirtyRef.current = true
|
| 152 |
+
}
|
| 153 |
+
}, [])
|
| 154 |
+
useEffect(() => {
|
| 155 |
+
resize()
|
| 156 |
+
const ro = new ResizeObserver(() => resize())
|
| 157 |
+
if (playerRef.current) ro.observe(playerRef.current)
|
| 158 |
+
return () => ro.disconnect()
|
| 159 |
+
}, [resize, aspect, ready])
|
| 160 |
+
|
| 161 |
+
// ------------------------------------------------------------ playback clock
|
| 162 |
+
const now = () => {
|
| 163 |
+
const c = clockRef.current
|
| 164 |
+
if (!c.playing) return c.t
|
| 165 |
+
const a = audioRef.current
|
| 166 |
+
if (a.ctx && a.src) return c.startT + (a.ctx.currentTime - c.startPerf)
|
| 167 |
+
return c.startT + (performance.now() - c.startPerf) / 1000
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const stopAudio = () => {
|
| 171 |
+
const a = audioRef.current
|
| 172 |
+
if (a.src) { try { a.src.stop() } catch { /* ignore */ } a.src.disconnect(); a.src = null }
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const play = useCallback(async (from?: number) => {
|
| 176 |
+
const c = clockRef.current
|
| 177 |
+
let t = from ?? c.t
|
| 178 |
+
if (t >= DURATION - 0.05) t = 0
|
| 179 |
+
const a = audioRef.current
|
| 180 |
+
stopAudio()
|
| 181 |
+
if (a.buf) {
|
| 182 |
+
if (!a.ctx) {
|
| 183 |
+
a.ctx = new AudioContext({ sampleRate: 48000 })
|
| 184 |
+
a.gain = a.ctx.createGain()
|
| 185 |
+
a.gain.connect(a.ctx.destination)
|
| 186 |
+
}
|
| 187 |
+
await a.ctx.resume()
|
| 188 |
+
a.gain!.gain.value = muted ? 0 : 1
|
| 189 |
+
const src = a.ctx.createBufferSource()
|
| 190 |
+
src.buffer = a.buf
|
| 191 |
+
src.connect(a.gain!)
|
| 192 |
+
src.start(0, t)
|
| 193 |
+
a.src = src
|
| 194 |
+
c.startPerf = a.ctx.currentTime
|
| 195 |
+
} else c.startPerf = performance.now()
|
| 196 |
+
c.startT = t
|
| 197 |
+
c.playing = true
|
| 198 |
+
setPlaying(true)
|
| 199 |
+
}, [muted])
|
| 200 |
+
|
| 201 |
+
const pause = useCallback(() => {
|
| 202 |
+
const c = clockRef.current
|
| 203 |
+
c.t = now()
|
| 204 |
+
c.playing = false
|
| 205 |
+
stopAudio()
|
| 206 |
+
setPlaying(false)
|
| 207 |
+
dirtyRef.current = true
|
| 208 |
}, [])
|
| 209 |
+
|
| 210 |
+
const seek = useCallback((t: number) => {
|
| 211 |
+
const c = clockRef.current
|
| 212 |
+
c.t = Math.max(0, Math.min(DURATION, t))
|
| 213 |
+
setTime(c.t)
|
| 214 |
+
dirtyRef.current = true
|
| 215 |
+
if (c.playing) play(c.t)
|
| 216 |
+
}, [play])
|
| 217 |
+
|
| 218 |
+
useEffect(() => {
|
| 219 |
+
const a = audioRef.current
|
| 220 |
+
if (a.gain) a.gain.gain.value = muted ? 0 : 1
|
| 221 |
+
}, [muted])
|
| 222 |
+
|
| 223 |
+
// ------------------------------------------------------------ render loop
|
| 224 |
+
useEffect(() => {
|
| 225 |
+
if (!ready) return
|
| 226 |
+
let raf = 0
|
| 227 |
+
let ema = 16
|
| 228 |
+
let lastUi = 0
|
| 229 |
+
const loop = () => {
|
| 230 |
+
raf = requestAnimationFrame(loop)
|
| 231 |
+
const film = filmRef.current
|
| 232 |
+
if (!film || !film.ready || busyRef.current) return
|
| 233 |
+
const c = clockRef.current
|
| 234 |
+
let t = now()
|
| 235 |
+
if (c.playing && t >= DURATION) {
|
| 236 |
+
c.t = DURATION
|
| 237 |
+
c.playing = false
|
| 238 |
+
stopAudio()
|
| 239 |
+
setPlaying(false)
|
| 240 |
+
t = DURATION
|
| 241 |
+
}
|
| 242 |
+
if (!c.playing && !dirtyRef.current) return
|
| 243 |
+
if (!c.playing) c.t = t
|
| 244 |
+
const t0 = performance.now()
|
| 245 |
+
film.renderFrame(Math.min(t, DURATION - 0.001))
|
| 246 |
+
const ov = ovRef.current!
|
| 247 |
+
const g = ov.getContext('2d')!
|
| 248 |
+
g.clearRect(0, 0, ov.width, ov.height)
|
| 249 |
+
drawOverlay(g, t, ov.width, ov.height, { subtitles: subs, titles: true })
|
| 250 |
+
dirtyRef.current = false
|
| 251 |
+
const dt = performance.now() - t0
|
| 252 |
+
ema = ema * 0.9 + dt * 0.1
|
| 253 |
+
if (c.playing) {
|
| 254 |
+
// adaptive resolution: keep playback smooth on slower devices
|
| 255 |
+
if (ema > 42 && scaleRef.current > 0.45) { scaleRef.current *= 0.85; ema = 30; resize() } else if (ema < 14 && scaleRef.current < 1) { scaleRef.current = Math.min(1, scaleRef.current * 1.1); ema = 20; resize() }
|
| 256 |
+
}
|
| 257 |
+
if (performance.now() - lastUi > 100) { setTime(t); lastUi = performance.now() }
|
| 258 |
+
}
|
| 259 |
+
raf = requestAnimationFrame(loop)
|
| 260 |
+
return () => cancelAnimationFrame(raf)
|
| 261 |
+
}, [ready, subs, resize])
|
| 262 |
+
|
| 263 |
+
useEffect(() => { dirtyRef.current = true }, [subs, aspect])
|
| 264 |
+
|
| 265 |
+
// keyboard
|
| 266 |
+
useEffect(() => {
|
| 267 |
+
const onKey = (e: KeyboardEvent) => {
|
| 268 |
+
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'TEXTAREA') return
|
| 269 |
+
if (e.code === 'Space') { e.preventDefault(); if (clockRef.current.playing) pause(); else play() }
|
| 270 |
+
if (e.code === 'ArrowRight') seek(now() + 5)
|
| 271 |
+
if (e.code === 'ArrowLeft') seek(now() - 5)
|
| 272 |
+
}
|
| 273 |
+
window.addEventListener('keydown', onKey)
|
| 274 |
+
return () => window.removeEventListener('keydown', onKey)
|
| 275 |
+
}, [play, pause, seek])
|
| 276 |
+
|
| 277 |
+
const fullscreen = () => {
|
| 278 |
+
const el = playerRef.current
|
| 279 |
+
if (!el) return
|
| 280 |
+
if (document.fullscreenElement) document.exitFullscreen()
|
| 281 |
+
else el.requestFullscreen?.().catch(() => say('当前浏览器不支持全屏'))
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
const curShot = shotAt(time)
|
| 285 |
+
|
| 286 |
+
// ------------------------------------------------------------ busy wrapper (export / covers / live)
|
| 287 |
+
const withBusy = async <T,>(fn: () => Promise<T>) => {
|
| 288 |
+
pause()
|
| 289 |
+
busyRef.current = true
|
| 290 |
+
try { return await fn() } finally {
|
| 291 |
+
busyRef.current = false
|
| 292 |
+
resize()
|
| 293 |
+
dirtyRef.current = true
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
return (
|
| 298 |
+
<div className="jn">
|
| 299 |
+
<div className="jn-wrap">
|
| 300 |
+
<header className="jn-head">
|
| 301 |
+
<div>
|
| 302 |
+
<h1 className="jn-title">暮归江南<span className="jn-seal">江南</span></h1>
|
| 303 |
+
<div className="jn-sub">一座水乡村庄的日落与灯火 · 原创 3D 动画 · {Math.round(DURATION)} 秒 · 配乐 / 音效 / 旁白</div>
|
| 304 |
+
</div>
|
| 305 |
+
<div className="jn-links">浏览器实时渲染 · 本地导出,不上传任何数据</div>
|
| 306 |
+
</header>
|
| 307 |
+
|
| 308 |
+
<div ref={playerRef} className={`jn-player ${aspect}`}>
|
| 309 |
+
<canvas ref={glRef} />
|
| 310 |
+
<canvas ref={ovRef} />
|
| 311 |
+
{!ready && (
|
| 312 |
+
<div className="jn-loading">
|
| 313 |
+
<div className="t">暮归江南</div>
|
| 314 |
+
<div className="jn-bar"><i style={{ width: `${Math.round(load.p * 100)}%` }} /></div>
|
| 315 |
+
<small>{err || `${load.label}…`}</small>
|
| 316 |
+
</div>
|
| 317 |
+
)}
|
| 318 |
+
{ready && !playing && !busyRef.current && (
|
| 319 |
+
<button className="jn-big-play" onClick={() => play()} aria-label="播放">
|
| 320 |
+
<span><svg width="30" height="30" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="#fff3e2" /></svg></span>
|
| 321 |
+
</button>
|
| 322 |
+
)}
|
| 323 |
+
</div>
|
| 324 |
+
|
| 325 |
+
<div className="jn-controls">
|
| 326 |
+
<button className="jn-btn primary" disabled={!ready} onClick={() => (playing ? pause() : play())}>{playing ? '暂停' : '播放'}</button>
|
| 327 |
+
<span className="jn-time">{fmt(time)} / {fmt(DURATION)}</span>
|
| 328 |
+
<div className="jn-scrub">
|
| 329 |
+
<input type="range" min={0} max={DURATION} step={0.05} value={time} onChange={(e) => seek(Number(e.target.value))} disabled={!ready} />
|
| 330 |
+
</div>
|
| 331 |
+
<button className={`jn-btn ${aspect === 'landscape' ? 'on' : ''}`} onClick={() => setAspect('landscape')}>横屏 16:9</button>
|
| 332 |
+
<button className={`jn-btn ${aspect === 'portrait' ? 'on' : ''}`} onClick={() => setAspect('portrait')}>竖屏 9:16</button>
|
| 333 |
+
<button className={`jn-btn ghost ${subs ? 'on' : ''}`} onClick={() => setSubs(!subs)}>字幕{subs ? '开' : '关'}</button>
|
| 334 |
+
<button className={`jn-btn ghost ${muted ? '' : 'on'}`} onClick={() => setMuted(!muted)}>{muted ? '已静音' : '声音开'}</button>
|
| 335 |
+
<button className="jn-btn ghost" onClick={fullscreen}>全屏</button>
|
| 336 |
+
</div>
|
| 337 |
+
{!audioReady && ready && <div className="jn-note">配乐与旁白正在浏览器里合成,稍等几秒后播放会有声音。</div>}
|
| 338 |
+
<div className="jn-shots">
|
| 339 |
+
{SHOTS.map((s) => (
|
| 340 |
+
<button key={s.id} className={`jn-chip ${curShot.id === s.id ? 'active' : ''}`} onClick={() => seek(s.t0 + 0.01)}>
|
| 341 |
+
{String(s.id).padStart(2, '0')} {s.name}
|
| 342 |
+
</button>
|
| 343 |
+
))}
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<ExportSection film={filmRef} audio={audioRef} ready={ready && audioReady} withBusy={withBusy} />
|
| 347 |
+
<CoverSection film={filmRef} ready={ready} withBusy={withBusy} />
|
| 348 |
+
<CopySection say={say} />
|
| 349 |
+
<LiveSection film={filmRef} ready={ready} withBusy={withBusy} say={say} />
|
| 350 |
+
|
| 351 |
+
<section className="jn-sec">
|
| 352 |
+
<h2>Blender 场景脚本</h2>
|
| 353 |
+
<p className="lead">同一座村庄的 Blender 版本:在 Blender 4.x 的「脚本」面板里打开并运行,会自动生成地形、河道、徽派民居、石拱桥、水车、古樟、灯笼、日落到夜晚的灯光关键帧和 10 个镜头的摄像机,可用 Cycles 离线渲染 4K。</p>
|
| 354 |
+
<div className="jn-row">
|
| 355 |
+
<button className="jn-btn" onClick={() => download(new Blob([blenderScript], { type: 'text/x-python' }), 'jiangnan_village.py')}>下载 jiangnan_village.py</button>
|
| 356 |
+
<span className="jn-note" style={{ margin: 0 }}>使用:Blender → Scripting → 打开脚本 → Run Script(约 1 分钟)。</span>
|
| 357 |
+
</div>
|
| 358 |
+
</section>
|
| 359 |
+
|
| 360 |
+
<footer className="jn-foot">
|
| 361 |
+
《暮归江南》全部画面在浏览器中实时生成:程序化地形与民居、平面反射水面、高度雾、体积光、辉光与胶片调色;配乐为程序合成的五声音阶原创曲(古筝物理建模、竹箫、弦乐、编钟),环境音效同样为程序合成;旁白为原创文案的中文语音合成。字体:马善政楷书、霞鹜文楷(SIL OFL)。
|
| 362 |
+
</footer>
|
| 363 |
+
</div>
|
| 364 |
+
{toast && <div className="jn-toast">{toast}</div>}
|
| 365 |
</div>
|
| 366 |
)
|
| 367 |
}
|
| 368 |
+
|
| 369 |
+
// ====================================================================== export
|
| 370 |
+
type FilmRef = React.MutableRefObject<Film | null>
|
| 371 |
+
type Busy = <T>(fn: () => Promise<T>) => Promise<T>
|
| 372 |
+
|
| 373 |
+
function ExportSection({ film, audio, ready, withBusy }: { film: FilmRef; audio: React.MutableRefObject<{ buf: AudioBuffer | null }>; ready: boolean; withBusy: Busy }) {
|
| 374 |
+
const [orient, setOrient] = useState<Aspect>('landscape')
|
| 375 |
+
const [res, setRes] = useState<720 | 1080>(1080)
|
| 376 |
+
const [fps, setFps] = useState<24 | 30 | 60>(30)
|
| 377 |
+
const [subs, setSubs] = useState(true)
|
| 378 |
+
const [prog, setProg] = useState<{ p: number; label: string } | null>(null)
|
| 379 |
+
const [out, setOut] = useState<{ url: string; name: string; size: number; note: string } | null>(null)
|
| 380 |
+
const [err, setErr] = useState('')
|
| 381 |
+
const sig = useRef({ cancelled: false })
|
| 382 |
+
const sup = useMemo(() => exportSupport(), [])
|
| 383 |
+
const dims = orient === 'landscape' ? { w: res === 1080 ? 1920 : 1280, h: res === 1080 ? 1080 : 720 } : { w: res === 1080 ? 1080 : 720, h: res === 1080 ? 1920 : 1280 }
|
| 384 |
+
const start = async () => {
|
| 385 |
+
if (!film.current) return
|
| 386 |
+
setErr('')
|
| 387 |
+
setOut(null)
|
| 388 |
+
sig.current = { cancelled: false }
|
| 389 |
+
try {
|
| 390 |
+
await withBusy(async () => {
|
| 391 |
+
const r = await exportMp4(film.current!, {
|
| 392 |
+
width: dims.w, height: dims.h, fps, audio: audio.current.buf, overlay: { subtitles: subs, titles: true }, signal: sig.current,
|
| 393 |
+
onProgress: (p, info) => setProg({ p, label: `${info.stage} ${info.frame}/${info.total} 帧${info.eta > 1 ? ` · 预计还需 ${Math.ceil(info.eta)} 秒` : ''}` }),
|
| 394 |
+
})
|
| 395 |
+
const name = `暮归江南_${orient === 'landscape' ? '横屏' : '竖屏'}_${dims.w}x${dims.h}_${fps}fps.mp4`
|
| 396 |
+
setOut({ url: URL.createObjectURL(r.blob), name, size: r.blob.size, note: r.audioCodec ? `音轨:${r.audioCodec === 'aac' ? 'AAC' : 'Opus'}` : '当前浏览器不支持音频编码,导出的视频没有声音(建议用电脑版 Chrome / Edge)' })
|
| 397 |
+
})
|
| 398 |
+
} catch (e) {
|
| 399 |
+
setErr((e as Error).message || String(e))
|
| 400 |
+
} finally {
|
| 401 |
+
setProg(null)
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
return (
|
| 405 |
+
<section className="jn-sec">
|
| 406 |
+
<h2>导出 MP4</h2>
|
| 407 |
+
<p className="lead">在你的浏览器里逐帧渲染并编码,画面与预览完全一致(不会掉帧),配乐、音效、旁白一起写入。视频不上传任何服务器。</p>
|
| 408 |
+
<div className="jn-grid">
|
| 409 |
+
<div className="jn-card">
|
| 410 |
+
<div className="jn-label">画幅</div>
|
| 411 |
+
<div className="jn-opt">
|
| 412 |
+
<button className={`jn-btn ${orient === 'landscape' ? 'on' : ''}`} onClick={() => setOrient('landscape')}>横屏 16:9</button>
|
| 413 |
+
<button className={`jn-btn ${orient === 'portrait' ? 'on' : ''}`} onClick={() => setOrient('portrait')}>竖屏 9:16(手机)</button>
|
| 414 |
+
</div>
|
| 415 |
+
<div className="jn-label">清晰度</div>
|
| 416 |
+
<div className="jn-opt">
|
| 417 |
+
<button className={`jn-btn ${res === 1080 ? 'on' : ''}`} onClick={() => setRes(1080)}>1080p</button>
|
| 418 |
+
<button className={`jn-btn ${res === 720 ? 'on' : ''}`} onClick={() => setRes(720)}>720p(更快)</button>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
<div className="jn-card">
|
| 422 |
+
<div className="jn-label">帧率</div>
|
| 423 |
+
<div className="jn-opt">
|
| 424 |
+
{([24, 30, 60] as const).map((f) => (
|
| 425 |
+
<button key={f} className={`jn-btn ${fps === f ? 'on' : ''}`} onClick={() => setFps(f)}>{f} fps</button>
|
| 426 |
+
))}
|
| 427 |
+
</div>
|
| 428 |
+
<div className="jn-label">字幕</div>
|
| 429 |
+
<div className="jn-opt">
|
| 430 |
+
<button className={`jn-btn ${subs ? 'on' : ''}`} onClick={() => setSubs(true)}>烧录字幕</button>
|
| 431 |
+
<button className={`jn-btn ${!subs ? 'on' : ''}`} onClick={() => setSubs(false)}>无字幕</button>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
<div className="jn-card">
|
| 435 |
+
<div className="jn-label">输出</div>
|
| 436 |
+
<div style={{ fontSize: 15, margin: '6px 0 12px' }}>{dims.w}×{dims.h} · {fps} fps · {Math.round(DURATION * fps)} 帧</div>
|
| 437 |
+
<div className="jn-row">
|
| 438 |
+
{!prog ? (
|
| 439 |
+
<button className="jn-btn primary" disabled={!ready || !sup.video} onClick={start}>开始导出</button>
|
| 440 |
+
) : (
|
| 441 |
+
<button className="jn-btn" onClick={() => { sig.current.cancelled = true }}>取消</button>
|
| 442 |
+
)}
|
| 443 |
+
</div>
|
| 444 |
+
{!sup.video && <div className="jn-err">当前浏览器不支持 WebCodecs 视频编码,请用电脑版 Chrome / Edge 或 Safari 16.4+。</div>}
|
| 445 |
+
{!ready && sup.video && <div className="jn-note">等待场景和配乐准备完成…</div>}
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
{prog && (
|
| 449 |
+
<div className="jn-progress">
|
| 450 |
+
<div className="jn-bar"><i style={{ width: `${(prog.p * 100).toFixed(1)}%` }} /></div>
|
| 451 |
+
<small>{prog.label}(导出期间请保持本页在前台)</small>
|
| 452 |
+
</div>
|
| 453 |
+
)}
|
| 454 |
+
{err && <div className="jn-err">导出失败:{err}</div>}
|
| 455 |
+
{out && (
|
| 456 |
+
<div className="jn-result">
|
| 457 |
+
<video src={out.url} controls playsInline />
|
| 458 |
+
<div className="jn-row" style={{ marginTop: 10 }}>
|
| 459 |
+
<a className="jn-btn primary" href={out.url} download={out.name}>保存 {out.name}({(out.size / 1048576).toFixed(1)} MB)</a>
|
| 460 |
+
<span className="jn-note" style={{ margin: 0 }}>{out.note}</span>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
)}
|
| 464 |
+
<p className="jn-note">提示:1080p 60fps 共 {Math.round(DURATION * 60)} 帧,独立显卡电脑约 2–4 分钟;手机建议导出 720p。iPhone 上导出后点「保存」→「存储视频」。</p>
|
| 465 |
+
</section>
|
| 466 |
+
)
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
// ====================================================================== covers
|
| 470 |
+
interface CoverItem { key: string; ratio: Ratio; name: string; url: string; blob: Blob }
|
| 471 |
+
|
| 472 |
+
function CoverSection({ film, ready, withBusy }: { film: FilmRef; ready: boolean; withBusy: Busy }) {
|
| 473 |
+
const [ratios, setRatios] = useState<Ratio[]>(['16:9', '4:3', '3:4'])
|
| 474 |
+
const [designs, setDesigns] = useState<string[]>(COVER_DESIGNS.map((d) => d.key))
|
| 475 |
+
const [items, setItems] = useState<CoverItem[]>([])
|
| 476 |
+
const [sel, setSel] = useState<Set<string>>(new Set())
|
| 477 |
+
const [prog, setProg] = useState<number | null>(null)
|
| 478 |
+
const toggle = <T,>(arr: T[], v: T) => (arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v])
|
| 479 |
+
const gen = async () => {
|
| 480 |
+
if (!film.current) return
|
| 481 |
+
await withBusy(async () => {
|
| 482 |
+
const out: CoverItem[] = []
|
| 483 |
+
const jobs = ratios.flatMap((r) => COVER_DESIGNS.filter((d) => designs.includes(d.key)).map((d) => ({ r, d })))
|
| 484 |
+
for (let i = 0; i < jobs.length; i++) {
|
| 485 |
+
setProg(i / jobs.length)
|
| 486 |
+
await new Promise((r) => setTimeout(r, 0))
|
| 487 |
+
const { r, d } = jobs[i]
|
| 488 |
+
const c = renderCover(film.current!, d, r)
|
| 489 |
+
const blob = await canvasToBlob(c)
|
| 490 |
+
out.push({ key: `${r}-${d.key}`, ratio: r, name: `暮归江南_封面_${r.replace(':', 'x')}_${d.name}.jpg`, url: URL.createObjectURL(blob), blob })
|
| 491 |
+
}
|
| 492 |
+
setItems(out)
|
| 493 |
+
setSel(new Set(out.map((o) => o.key)))
|
| 494 |
+
setProg(null)
|
| 495 |
+
})
|
| 496 |
+
}
|
| 497 |
+
const dl = () => {
|
| 498 |
+
const chosen = items.filter((i) => sel.has(i.key))
|
| 499 |
+
if (chosen.length === 1) return download(chosen[0].blob, chosen[0].name)
|
| 500 |
+
Promise.all(chosen.map(async (c) => [c.name, new Uint8Array(await c.blob.arrayBuffer())] as const)).then((list) => {
|
| 501 |
+
const z: Zippable = {}
|
| 502 |
+
list.forEach(([n, b]) => { z['暮归江南_封面/' + n] = [b, { level: 0 }] })
|
| 503 |
+
download(new Blob([zipSync(z) as Uint8Array<ArrayBuffer>], { type: 'application/zip' }), `暮归江南_封面_${chosen.length}张.zip`)
|
| 504 |
+
})
|
| 505 |
+
}
|
| 506 |
+
return (
|
| 507 |
+
<section className="jn-sec">
|
| 508 |
+
<h2>封面</h2>
|
| 509 |
+
<p className="lead">每个比例都按该比例重新取景渲染(不是裁切),再排上书法标题。选择比例与画面,生成后勾选需要的封面下载。</p>
|
| 510 |
+
<div className="jn-label">比例(可多选)</div>
|
| 511 |
+
<div className="jn-opt">
|
| 512 |
+
{RATIOS.map((r) => (
|
| 513 |
+
<button key={r.id} className={`jn-btn ${ratios.includes(r.id) ? 'on' : ''}`} onClick={() => setRatios(toggle(ratios, r.id))}>{ratios.includes(r.id) ? '✓ ' : ''}{r.label}</button>
|
| 514 |
+
))}
|
| 515 |
+
</div>
|
| 516 |
+
<div className="jn-label">画面(可多选)</div>
|
| 517 |
+
<div className="jn-opt">
|
| 518 |
+
{COVER_DESIGNS.map((d) => (
|
| 519 |
+
<button key={d.key} className={`jn-btn ${designs.includes(d.key) ? 'on' : ''}`} onClick={() => setDesigns(toggle(designs, d.key))}>{designs.includes(d.key) ? '✓ ' : ''}{d.name}</button>
|
| 520 |
+
))}
|
| 521 |
+
</div>
|
| 522 |
+
<div className="jn-row">
|
| 523 |
+
<button className="jn-btn primary" disabled={!ready || prog !== null || !ratios.length || !designs.length} onClick={gen}>生成 {ratios.length * designs.length} 张封面</button>
|
| 524 |
+
{items.length > 0 && (
|
| 525 |
+
<>
|
| 526 |
+
<button className="jn-btn" onClick={() => setSel(new Set(items.map((i) => i.key)))}>全选</button>
|
| 527 |
+
<button className="jn-btn" onClick={() => setSel(new Set())}>全不选</button>
|
| 528 |
+
<button className="jn-btn primary" disabled={!sel.size} onClick={dl}>下载选中({sel.size})</button>
|
| 529 |
+
</>
|
| 530 |
+
)}
|
| 531 |
+
</div>
|
| 532 |
+
{prog !== null && <div className="jn-progress"><div className="jn-bar"><i style={{ width: `${prog * 100}%` }} /></div><small>正在渲染封面…</small></div>}
|
| 533 |
+
<div className="jn-covers">
|
| 534 |
+
{items.map((it) => (
|
| 535 |
+
<div key={it.key} className={`jn-cover ${sel.has(it.key) ? 'sel' : ''}`} onClick={() => { const s = new Set(sel); if (s.has(it.key)) s.delete(it.key); else s.add(it.key); setSel(s) }}>
|
| 536 |
+
<img src={it.url} alt={it.name} />
|
| 537 |
+
<span className="chk">{sel.has(it.key) ? '✓' : ''}</span>
|
| 538 |
+
<div className="meta"><span>{it.ratio} · {it.name.split('_').pop()?.replace('.jpg', '')}</span><a href={it.url} download={it.name} onClick={(e) => e.stopPropagation()}>下载</a></div>
|
| 539 |
+
</div>
|
| 540 |
+
))}
|
| 541 |
+
</div>
|
| 542 |
+
</section>
|
| 543 |
+
)
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
// ====================================================================== copy
|
| 547 |
+
function CopySection({ say }: { say: (m: string) => void }) {
|
| 548 |
+
const [tab, setTab] = useState(PLATFORMS[0].id)
|
| 549 |
+
const [pick, setPick] = useState<Record<string, number>>({})
|
| 550 |
+
const p = PLATFORMS.find((x) => x.id === tab)!
|
| 551 |
+
const ti = pick[p.id] ?? 0
|
| 552 |
+
const title = p.titles[ti]
|
| 553 |
+
const tagStr = formatTags(p.tags, p.tagStyle)
|
| 554 |
+
const cp = async (t: string, what: string) => say((await copyText(t)) ? `已复制${what}` : '复制失败,请手动选择')
|
| 555 |
+
const tagOk = p.tagMax === null || p.tags.length <= p.tagMax
|
| 556 |
+
return (
|
| 557 |
+
<section className="jn-sec">
|
| 558 |
+
<h2>发布文案</h2>
|
| 559 |
+
<p className="lead">四个平台各自的标题、描述和话题,字数与话题数量已按平台限制校验,点一下即可复制。</p>
|
| 560 |
+
<div className="jn-tabs">
|
| 561 |
+
{PLATFORMS.map((x) => (
|
| 562 |
+
<button key={x.id} className={`jn-chip ${tab === x.id ? 'active' : ''}`} style={{ fontSize: 14, padding: '6px 14px' }} onClick={() => setTab(x.id)}>{x.name}</button>
|
| 563 |
+
))}
|
| 564 |
+
</div>
|
| 565 |
+
<div className="jn-copyrow"><div className="jn-label">标题({p.note})· 点选一个</div><button className="jn-btn" onClick={() => cp(title, '标题')}>复制标题</button></div>
|
| 566 |
+
<div className="jn-titles">
|
| 567 |
+
{p.titles.map((t, i) => {
|
| 568 |
+
const n = chars(t)
|
| 569 |
+
return (
|
| 570 |
+
<div key={i} className={`jn-tline ${i === ti ? 'sel' : ''}`} onClick={() => setPick({ ...pick, [p.id]: i })}>
|
| 571 |
+
<span className="txt">{t}</span>
|
| 572 |
+
<span className={`jn-count ${n <= p.titleMax ? 'ok' : 'bad'}`}>{n}/{p.titleMax}</span>
|
| 573 |
+
<button className="jn-btn ghost" onClick={(e) => { e.stopPropagation(); cp(t, '标题') }}>复制</button>
|
| 574 |
+
</div>
|
| 575 |
+
)
|
| 576 |
+
})}
|
| 577 |
+
</div>
|
| 578 |
+
<div className="jn-copyrow"><div className="jn-label">描述</div><button className="jn-btn" onClick={() => cp(p.desc, '描述')}>复制描述</button></div>
|
| 579 |
+
<div className="jn-box">{p.desc}</div>
|
| 580 |
+
<div className="jn-copyrow">
|
| 581 |
+
<div className="jn-label">{p.tagStyle === 'plain' ? '标签' : '话题'}({p.tags.length}{p.tagMax !== null ? `/${p.tagMax}` : ',不限'})<span className={`jn-count ${tagOk ? 'ok' : 'bad'}`} style={{ marginLeft: 8 }}>{tagOk ? '符合限制' : '超出限制'}</span></div>
|
| 582 |
+
<button className="jn-btn" onClick={() => cp(tagStr, p.tagStyle === 'plain' ? '标签' : '话题')}>复制{p.tagStyle === 'plain' ? '标签' : '话题'}</button>
|
| 583 |
+
</div>
|
| 584 |
+
<div className="jn-tags">{p.tags.map((t) => <span key={t} className="jn-tag">{p.tagStyle === 'hash' ? '#' : ''}{t}</span>)}</div>
|
| 585 |
+
<div className="jn-row" style={{ marginTop: 18 }}>
|
| 586 |
+
<button className="jn-btn primary" onClick={() => cp(`${title}\n\n${p.desc}\n\n${tagStr}`, '全部文案')}>复制全部(标题 + 描述 + {p.tagStyle === 'plain' ? '标签' : '话题'})</button>
|
| 587 |
+
</div>
|
| 588 |
+
</section>
|
| 589 |
+
)
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
// ====================================================================== live photos
|
| 593 |
+
function LiveSection({ film, ready, withBusy, say }: { film: FilmRef; ready: boolean; withBusy: Busy; say: (m: string) => void }) {
|
| 594 |
+
const [prog, setProg] = useState<{ p: number; label: string } | null>(null)
|
| 595 |
+
const [res, setRes] = useState<LiveResult | null>(null)
|
| 596 |
+
const [err, setErr] = useState('')
|
| 597 |
+
const sig = useRef({ cancelled: false })
|
| 598 |
+
const sup = useMemo(() => exportSupport(), [])
|
| 599 |
+
const gen = async () => {
|
| 600 |
+
if (!film.current) return
|
| 601 |
+
setErr('')
|
| 602 |
+
sig.current = { cancelled: false }
|
| 603 |
+
try {
|
| 604 |
+
await withBusy(async () => {
|
| 605 |
+
const r = await generateLivePhotos(film.current!, (p, label) => setProg({ p, label }), sig.current)
|
| 606 |
+
setRes(r)
|
| 607 |
+
})
|
| 608 |
+
} catch (e) {
|
| 609 |
+
setErr((e as Error).message)
|
| 610 |
+
} finally {
|
| 611 |
+
setProg(null)
|
| 612 |
+
}
|
| 613 |
+
}
|
| 614 |
+
const cp = async (t: string, what: string) => say((await copyText(t)) ? `已复制${what}` : '复制失败')
|
| 615 |
+
return (
|
| 616 |
+
<section className="jn-sec">
|
| 617 |
+
<h2>实况图(Live Photo)</h2>
|
| 618 |
+
<p className="lead">按 LiveCanvas 规范生成 {LIVE_CLIPS.length} 组苹果实况照片:3:4(1080×1440)、30 fps、3 秒、封面取自动画中的同一帧。压缩包里是同名的 <b>jpg + mov</b>(Mac 拖进「照片」即成实况)以及 .pvt 实况资源包,可直接用于小红书、抖音图文。</p>
|
| 619 |
+
<div className="jn-row">
|
| 620 |
+
{!prog ? (
|
| 621 |
+
<button className="jn-btn primary" disabled={!ready || !sup.video} onClick={gen}>生成 {LIVE_CLIPS.length} 组实况图</button>
|
| 622 |
+
) : (
|
| 623 |
+
<button className="jn-btn" onClick={() => { sig.current.cancelled = true }}>取消</button>
|
| 624 |
+
)}
|
| 625 |
+
{res && <button className="jn-btn primary" onClick={() => download(res.zip, '暮归江南_实况图.zip')}>下载压缩包({(res.zip.size / 1048576).toFixed(1)} MB)</button>}
|
| 626 |
+
</div>
|
| 627 |
+
<div className="jn-note">画面:{LIVE_CLIPS.map((c) => c.name).join(' · ')}</div>
|
| 628 |
+
{prog && <div className="jn-progress"><div className="jn-bar"><i style={{ width: `${prog.p * 100}%` }} /></div><small>{prog.label}</small></div>}
|
| 629 |
+
{err && <div className="jn-err">生成失败:{err}</div>}
|
| 630 |
+
{res && (
|
| 631 |
+
<div className="jn-live">
|
| 632 |
+
{res.previews.map((p) => <figure key={p.name}><img src={p.url} alt={p.name} /><figcaption>{p.name}</figcaption></figure>)}
|
| 633 |
+
</div>
|
| 634 |
+
)}
|
| 635 |
+
{LIVE_COPY.map((c) => (
|
| 636 |
+
<div key={c.id} className="jn-card" style={{ marginTop: 16 }}>
|
| 637 |
+
<div className="jn-copyrow" style={{ marginTop: 0 }}><div style={{ fontSize: 17 }}>{c.name}</div><button className="jn-btn" onClick={() => cp(`${c.titles[0]}\n\n${c.body}\n\n${c.tags.map((t) => '#' + t).join(' ')}`, '全部图文文案')}>复制全部</button></div>
|
| 638 |
+
<div className="jn-label">标题(≤{c.titleMax} 字)</div>
|
| 639 |
+
<div className="jn-titles">
|
| 640 |
+
{c.titles.map((t) => (
|
| 641 |
+
<div key={t} className="jn-tline" onClick={() => cp(t, '标题')}>
|
| 642 |
+
<span className="txt">{t}</span>
|
| 643 |
+
<span className={`jn-count ${chars(t) <= c.titleMax ? 'ok' : 'bad'}`}>{chars(t)}/{c.titleMax}</span>
|
| 644 |
+
<span className="jn-note" style={{ margin: 0 }}>点击复制</span>
|
| 645 |
+
</div>
|
| 646 |
+
))}
|
| 647 |
+
</div>
|
| 648 |
+
<div className="jn-copyrow"><div className="jn-label">正文</div><button className="jn-btn ghost" onClick={() => cp(c.body, '正文')}>复制正文</button></div>
|
| 649 |
+
<div className="jn-box">{c.body}</div>
|
| 650 |
+
<div className="jn-copyrow"><div className="jn-label">话题({c.tags.length}/{c.tagMax})</div><button className="jn-btn ghost" onClick={() => cp(c.tags.map((t) => '#' + t).join(' '), '话题')}>复制话题</button></div>
|
| 651 |
+
<div className="jn-tags">{c.tags.map((t) => <span key={t} className="jn-tag">#{t}</span>)}</div>
|
| 652 |
+
</div>
|
| 653 |
+
))}
|
| 654 |
+
<p className="jn-note">说明:实况配对信息(Apple 内容标识 + still-image-time 定时元数据)完全在浏览器内写入,写入逻辑已用 LiveCanvas skill 原版 Python 校验器验证;导入「照片」与手机播放需在你的设备上完成。</p>
|
| 655 |
+
<TextDownload />
|
| 656 |
+
</section>
|
| 657 |
+
)
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
function TextDownload() {
|
| 661 |
+
const all = () => {
|
| 662 |
+
const txt = [...PLATFORMS.map((p) => `【${p.name}】(${p.note})\n标题:\n${p.titles.map((t) => `· ${t}(${chars(t)}字)`).join('\n')}\n\n描述:\n${p.desc}\n\n${p.tagStyle === 'plain' ? '标签' : '话题'}:\n${formatTags(p.tags, p.tagStyle)}\n`), ...LIVE_COPY.map((c) => `【${c.name}】\n标题:\n${c.titles.map((t) => '· ' + t).join('\n')}\n\n正文:\n${c.body}\n\n话题:\n${c.tags.map((t) => '#' + t).join(' ')}\n`)].join('\n————————————\n\n')
|
| 663 |
+
download(new Blob([strToU8(txt) as Uint8Array<ArrayBuffer>], { type: 'text/plain;charset=utf-8' }), '暮归江南_发布文案.txt')
|
| 664 |
+
}
|
| 665 |
+
return <button className="jn-btn ghost" style={{ marginTop: 12 }} onClick={all}>下载全部平台文案(txt)</button>
|
| 666 |
+
}
|
src/jiangnan/assets/fonts/title.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:63d01c7d2c75f65eccdc89e79f1cafb080f04af1869be4c2e1c875739b81a0a2
|
| 3 |
+
size 152776
|
src/jiangnan/assets/fonts/wenkai.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7316dfca75b5cbbee358a61eacb711e6cd44f476959c4fd63a0ea7362be2e322
|
| 3 |
+
size 100608
|
src/jiangnan/assets/voice/n01.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9441bfce9568db7f6ee7d54d3ca272a95fc4bde4514e83e955e399889891d026
|
| 3 |
+
size 19872
|
src/jiangnan/assets/voice/n02.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1e2582ef2e3f91e54f4b60b05cd8cbb5237eb18018d0d73458c2c4e8ae8f4991
|
| 3 |
+
size 27648
|
src/jiangnan/assets/voice/n03.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b072049639a1d41f97bd804a48b78a7e18b17420708f9aa36d75a1afea05f018
|
| 3 |
+
size 29520
|
src/jiangnan/assets/voice/n04.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8297a0c1aec1717c713ed0ccd7ffa6348aa22d76cb2bdd87185c03723f1423fb
|
| 3 |
+
size 35712
|
src/jiangnan/assets/voice/n05.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1df103680324a49b87156639dededc13a1b97ad144cb864981c656a14bac0ea3
|
| 3 |
+
size 43200
|
src/jiangnan/assets/voice/n06.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8f8a874dd78a5574064893c8ce372657b56c174592ab330dcb86062e929b9c22
|
| 3 |
+
size 42480
|
src/jiangnan/assets/voice/n07.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ce3191d4c78002855af9c038c40b98a471303c6b58ab75bb4ae460bb9e9115b1
|
| 3 |
+
size 32400
|
src/jiangnan/assets/voice/n08.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:41a49b0aae1a4235607c904a747d33742c28ca95fe1b141d6b4ff2ac52de826a
|
| 3 |
+
size 37296
|
src/jiangnan/assets/voice/n09.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3e90da6356f0dac3b0b8910a069082118888d320f4092dc90c9d4e6d5eb5f8c1
|
| 3 |
+
size 24624
|
src/jiangnan/assets/voice/n10.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3cde233982818af986713f36aa49a49d0917094f012aef38cb8a15bcf5642d0b
|
| 3 |
+
size 42768
|
src/jiangnan/audio/mixer.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Offline mix: music + sound effects + narration → one stereo AudioBuffer.
|
| 2 |
+
// The same buffer is used for live playback and for MP4 export.
|
| 3 |
+
import { DURATION, NARRATION } from '../engine/timeline'
|
| 4 |
+
import { composeMusic, composeSfx, SR, Stem } from './synth'
|
| 5 |
+
|
| 6 |
+
const voiceUrls = import.meta.glob('../assets/voice/*.mp3', { eager: true, query: '?url', import: 'default' }) as Record<string, string>
|
| 7 |
+
|
| 8 |
+
function impulse(ctx: BaseAudioContext, seconds: number, decay: number) {
|
| 9 |
+
const len = Math.floor(seconds * ctx.sampleRate)
|
| 10 |
+
const buf = ctx.createBuffer(2, len, ctx.sampleRate)
|
| 11 |
+
let s = 12345
|
| 12 |
+
const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff }
|
| 13 |
+
for (let c = 0; c < 2; c++) {
|
| 14 |
+
const d = buf.getChannelData(c)
|
| 15 |
+
let lp = 0
|
| 16 |
+
for (let i = 0; i < len; i++) {
|
| 17 |
+
const t = i / len
|
| 18 |
+
lp += ((rnd() * 2 - 1) - lp) * (0.6 - 0.45 * t) // darker tail
|
| 19 |
+
d[i] = lp * Math.pow(1 - t, decay) * (i < 40 ? i / 40 : 1)
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
return buf
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function stemToBuffer(ctx: BaseAudioContext, s: Stem) {
|
| 26 |
+
const b = ctx.createBuffer(2, s.L.length, SR)
|
| 27 |
+
b.copyToChannel(s.L as Float32Array<ArrayBuffer>, 0)
|
| 28 |
+
b.copyToChannel(s.R as Float32Array<ArrayBuffer>, 1)
|
| 29 |
+
return b
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface MixOptions { music: number; sfx: number; voice: number }
|
| 33 |
+
|
| 34 |
+
let cachedStems: { music: Stem; sfx: Stem } | null = null
|
| 35 |
+
let cachedVoices: Record<string, ArrayBuffer> | null = null
|
| 36 |
+
|
| 37 |
+
export async function loadVoices() {
|
| 38 |
+
if (cachedVoices) return cachedVoices
|
| 39 |
+
const out: Record<string, ArrayBuffer> = {}
|
| 40 |
+
await Promise.all(Object.entries(voiceUrls).map(async ([path, url]) => {
|
| 41 |
+
const id = path.split('/').pop()!.replace('.mp3', '')
|
| 42 |
+
out[id] = await (await fetch(url)).arrayBuffer()
|
| 43 |
+
}))
|
| 44 |
+
cachedVoices = out
|
| 45 |
+
return out
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export async function buildMix(opts: MixOptions = { music: 1, sfx: 1, voice: 1 }, progress?: (p: number) => void): Promise<AudioBuffer> {
|
| 49 |
+
if (!cachedStems) {
|
| 50 |
+
progress?.(0.05)
|
| 51 |
+
await new Promise((r) => setTimeout(r, 0))
|
| 52 |
+
const music = composeMusic()
|
| 53 |
+
progress?.(0.45)
|
| 54 |
+
await new Promise((r) => setTimeout(r, 0))
|
| 55 |
+
const sfx = composeSfx()
|
| 56 |
+
cachedStems = { music, sfx }
|
| 57 |
+
}
|
| 58 |
+
progress?.(0.7)
|
| 59 |
+
const voices = await loadVoices()
|
| 60 |
+
const len = Math.ceil(DURATION * SR)
|
| 61 |
+
const ctx = new OfflineAudioContext(2, len, SR)
|
| 62 |
+
const master = ctx.createGain()
|
| 63 |
+
master.gain.value = 0.9
|
| 64 |
+
const comp = ctx.createDynamicsCompressor()
|
| 65 |
+
comp.threshold.value = -10
|
| 66 |
+
comp.knee.value = 6
|
| 67 |
+
comp.ratio.value = 6
|
| 68 |
+
comp.attack.value = 0.004
|
| 69 |
+
comp.release.value = 0.2
|
| 70 |
+
master.connect(comp).connect(ctx.destination)
|
| 71 |
+
const verb = ctx.createConvolver()
|
| 72 |
+
verb.buffer = impulse(ctx, 3.4, 2.6)
|
| 73 |
+
const verbGain = ctx.createGain()
|
| 74 |
+
verbGain.gain.value = 0.32
|
| 75 |
+
verb.connect(verbGain).connect(master)
|
| 76 |
+
|
| 77 |
+
// music with ducking under narration
|
| 78 |
+
const mSrc = ctx.createBufferSource()
|
| 79 |
+
mSrc.buffer = stemToBuffer(ctx, cachedStems.music)
|
| 80 |
+
const mGain = ctx.createGain()
|
| 81 |
+
const g = mGain.gain
|
| 82 |
+
const base = 0.85 * opts.music
|
| 83 |
+
g.setValueAtTime(base, 0)
|
| 84 |
+
const sfxSrc = ctx.createBufferSource()
|
| 85 |
+
sfxSrc.buffer = stemToBuffer(ctx, cachedStems.sfx)
|
| 86 |
+
const sGain = ctx.createGain()
|
| 87 |
+
sGain.gain.setValueAtTime(0.9 * opts.sfx, 0)
|
| 88 |
+
for (const c of NARRATION) {
|
| 89 |
+
const a = Math.max(0, c.t - 0.35), b = c.t + c.dur - 0.2
|
| 90 |
+
g.setValueAtTime(base, a)
|
| 91 |
+
g.linearRampToValueAtTime(base * 0.5, a + 0.35)
|
| 92 |
+
g.setValueAtTime(base * 0.5, b)
|
| 93 |
+
g.linearRampToValueAtTime(base, b + 0.8)
|
| 94 |
+
sGain.gain.setValueAtTime(0.9 * opts.sfx, a)
|
| 95 |
+
sGain.gain.linearRampToValueAtTime(0.65 * opts.sfx, a + 0.35)
|
| 96 |
+
sGain.gain.setValueAtTime(0.65 * opts.sfx, b)
|
| 97 |
+
sGain.gain.linearRampToValueAtTime(0.9 * opts.sfx, b + 0.8)
|
| 98 |
+
}
|
| 99 |
+
mSrc.connect(mGain)
|
| 100 |
+
mGain.connect(master)
|
| 101 |
+
const mSend = ctx.createGain()
|
| 102 |
+
mSend.gain.value = 0.55
|
| 103 |
+
mGain.connect(mSend).connect(verb)
|
| 104 |
+
sfxSrc.connect(sGain)
|
| 105 |
+
sGain.connect(master)
|
| 106 |
+
const sSend = ctx.createGain()
|
| 107 |
+
sSend.gain.value = 0.2
|
| 108 |
+
sGain.connect(sSend).connect(verb)
|
| 109 |
+
mSrc.start(0)
|
| 110 |
+
sfxSrc.start(0)
|
| 111 |
+
|
| 112 |
+
// narration (decoded into the offline context)
|
| 113 |
+
const vGain = ctx.createGain()
|
| 114 |
+
vGain.gain.value = 1.25 * opts.voice
|
| 115 |
+
// a gentle presence lift + low cut for the voice
|
| 116 |
+
const hp = ctx.createBiquadFilter()
|
| 117 |
+
hp.type = 'highpass'
|
| 118 |
+
hp.frequency.value = 90
|
| 119 |
+
const pres = ctx.createBiquadFilter()
|
| 120 |
+
pres.type = 'peaking'
|
| 121 |
+
pres.frequency.value = 3200
|
| 122 |
+
pres.gain.value = 2.5
|
| 123 |
+
vGain.connect(hp).connect(pres).connect(master)
|
| 124 |
+
const vSend = ctx.createGain()
|
| 125 |
+
vSend.gain.value = 0.12
|
| 126 |
+
pres.connect(vSend).connect(verb)
|
| 127 |
+
for (const c of NARRATION) {
|
| 128 |
+
const data = voices[c.id]
|
| 129 |
+
if (!data) continue
|
| 130 |
+
const buf = await ctx.decodeAudioData(data.slice(0))
|
| 131 |
+
const src = ctx.createBufferSource()
|
| 132 |
+
src.buffer = buf
|
| 133 |
+
src.connect(vGain)
|
| 134 |
+
src.start(c.t)
|
| 135 |
+
}
|
| 136 |
+
progress?.(0.8)
|
| 137 |
+
const rendered = await ctx.startRendering()
|
| 138 |
+
progress?.(1)
|
| 139 |
+
return rendered
|
| 140 |
+
}
|
src/jiangnan/audio/synth.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Original score + sound effects, synthesised sample-by-sample (deterministic).
|
| 2 |
+
// Pentatonic D gong mode (宫调式): D E F# A B.
|
| 3 |
+
import { DURATION, LANTERN_START } from '../engine/timeline'
|
| 4 |
+
|
| 5 |
+
export const SR = 48000
|
| 6 |
+
|
| 7 |
+
export interface Stem { L: Float32Array; R: Float32Array }
|
| 8 |
+
const stem = (len: number): Stem => ({ L: new Float32Array(len), R: new Float32Array(len) })
|
| 9 |
+
|
| 10 |
+
function rng(seed: number) {
|
| 11 |
+
let a = seed >>> 0
|
| 12 |
+
return () => {
|
| 13 |
+
a = (a + 0x6d2b79f5) >>> 0
|
| 14 |
+
let t = a
|
| 15 |
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
| 16 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
| 17 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const midi = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
|
| 22 |
+
// note names → midi
|
| 23 |
+
const N: Record<string, number> = {}
|
| 24 |
+
;['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'].forEach((n, i) => {
|
| 25 |
+
for (let o = 1; o < 8; o++) N[n + o] = 12 * (o + 1) + i
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
function addPan(s: Stem, i: number, v: number, pan: number) {
|
| 29 |
+
// equal-power pan, pan in [-1,1]
|
| 30 |
+
const a = (pan + 1) * Math.PI / 4
|
| 31 |
+
s.L[i] += v * Math.cos(a)
|
| 32 |
+
s.R[i] += v * Math.sin(a)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// ------------------------------------------------------------------ instruments
|
| 36 |
+
|
| 37 |
+
/** Guzheng pluck: Karplus–Strong string with optional press-bend (按音). */
|
| 38 |
+
function guzheng(out: Stem, t0: number, note: number, vel: number, pan: number, r: () => number, bend = 0, dur = 4.5) {
|
| 39 |
+
const f = midi(note)
|
| 40 |
+
const start = Math.floor(t0 * SR)
|
| 41 |
+
const len = Math.min(Math.floor(dur * SR), out.L.length - start)
|
| 42 |
+
if (len <= 0) return
|
| 43 |
+
const period = SR / f
|
| 44 |
+
const size = Math.ceil(period) + 2
|
| 45 |
+
const buf = new Float32Array(size)
|
| 46 |
+
// pluck excitation: bright noise shaped by pluck position
|
| 47 |
+
for (let i = 0; i < size; i++) buf[i] = (r() * 2 - 1) * (0.6 + 0.4 * Math.sin((i / size) * Math.PI))
|
| 48 |
+
let idx = 0
|
| 49 |
+
let prev = 0
|
| 50 |
+
const damp = 0.996 - Math.max(0, (note - 60)) * 0.0004
|
| 51 |
+
let lp = 0
|
| 52 |
+
for (let n = 0; n < len; n++) {
|
| 53 |
+
// pitch bend by modulating the read position (fractional delay)
|
| 54 |
+
const tb = n / SR
|
| 55 |
+
const bendAmt = bend ? bend * Math.min(1, Math.max(0, (tb - 0.18) / 0.22)) : 0
|
| 56 |
+
const p = period / Math.pow(2, bendAmt / 12)
|
| 57 |
+
const rd = (idx - p + size * 4) % size
|
| 58 |
+
const i0 = Math.floor(rd), fr = rd - i0
|
| 59 |
+
const s = buf[i0 % size] * (1 - fr) + buf[(i0 + 1) % size] * fr
|
| 60 |
+
const y = damp * 0.5 * (s + prev)
|
| 61 |
+
prev = s
|
| 62 |
+
buf[idx % size] = y
|
| 63 |
+
idx = (idx + 1) % size
|
| 64 |
+
// body: gentle lowpass + attack transient
|
| 65 |
+
lp += (y - lp) * 0.55
|
| 66 |
+
const env = Math.min(1, n / 40)
|
| 67 |
+
addPan(out, start + n, lp * vel * env * 0.55, pan)
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/** Bamboo flute (xiao/dizi-like): sine + odd/even partials, breath noise, delayed vibrato. */
|
| 72 |
+
function flute(out: Stem, t0: number, notes: [number, number][], beat: number, vel: number, pan: number, r: () => number) {
|
| 73 |
+
let t = t0
|
| 74 |
+
let phase = 0
|
| 75 |
+
let curF = midi(notes[0][0])
|
| 76 |
+
let lpN = 0, bpN = 0
|
| 77 |
+
for (let k = 0; k < notes.length; k++) {
|
| 78 |
+
const [note, beats] = notes[k]
|
| 79 |
+
const dur = beats * beat
|
| 80 |
+
const target = note < 0 ? curF : midi(note)
|
| 81 |
+
const s0 = Math.floor(t * SR), s1 = Math.floor((t + dur) * SR)
|
| 82 |
+
const rest = note < 0
|
| 83 |
+
for (let i = s0; i < s1 && i < out.L.length; i++) {
|
| 84 |
+
const tl = (i - s0) / SR
|
| 85 |
+
// portamento into the note
|
| 86 |
+
curF += (target - curF) * 0.0025
|
| 87 |
+
const vib = 1 + 0.006 * Math.min(1, Math.max(0, (tl - 0.35) / 0.4)) * Math.sin(2 * Math.PI * 5.2 * tl)
|
| 88 |
+
phase += (2 * Math.PI * curF * vib) / SR
|
| 89 |
+
const atk = Math.min(1, tl / 0.09)
|
| 90 |
+
const rel = Math.min(1, (dur - tl) / 0.14)
|
| 91 |
+
const swell = 0.85 + 0.15 * Math.sin(Math.PI * Math.min(1, tl / dur))
|
| 92 |
+
const env = rest ? 0 : Math.max(0, atk * rel * swell)
|
| 93 |
+
const tone = Math.sin(phase) + 0.18 * Math.sin(2 * phase) + 0.07 * Math.sin(3 * phase + 0.4)
|
| 94 |
+
// breath: noise band-limited around the fundamental, stronger at the attack
|
| 95 |
+
const nz = r() * 2 - 1
|
| 96 |
+
lpN += (nz - lpN) * 0.08
|
| 97 |
+
bpN += (lpN - bpN) * 0.02
|
| 98 |
+
const breath = (lpN - bpN) * (0.25 + 0.75 * Math.exp(-tl * 8))
|
| 99 |
+
addPan(out, i, (tone * 0.5 + breath * 0.9) * env * vel, pan)
|
| 100 |
+
}
|
| 101 |
+
t += dur
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/** Warm string pad: detuned saws through a slow low-pass. */
|
| 106 |
+
function pad(out: Stem, t0: number, t1: number, notes: number[], vel: number, cutoff: number, r: () => number) {
|
| 107 |
+
const s0 = Math.floor(t0 * SR), s1 = Math.min(out.L.length, Math.floor(t1 * SR))
|
| 108 |
+
const voices: { ph: number; inc: number; pan: number }[] = []
|
| 109 |
+
for (const n of notes) for (const det of [-0.08, 0, 0.07]) {
|
| 110 |
+
voices.push({ ph: r(), inc: midi(n + det) / SR, pan: (r() - 0.5) * 1.2 })
|
| 111 |
+
}
|
| 112 |
+
let l1 = 0, l2 = 0, r1 = 0, r2 = 0
|
| 113 |
+
const atk = 1.8, rel = 2.2
|
| 114 |
+
for (let i = s0; i < s1; i++) {
|
| 115 |
+
const t = (i - s0) / SR
|
| 116 |
+
const tt = (s1 - i) / SR
|
| 117 |
+
const env = Math.min(1, t / atk) * Math.min(1, tt / rel)
|
| 118 |
+
let L = 0, R = 0
|
| 119 |
+
for (const v of voices) {
|
| 120 |
+
v.ph += v.inc
|
| 121 |
+
if (v.ph > 1) v.ph -= 1
|
| 122 |
+
const saw = v.ph * 2 - 1
|
| 123 |
+
L += saw * (1 - v.pan) * 0.5
|
| 124 |
+
R += saw * (1 + v.pan) * 0.5
|
| 125 |
+
}
|
| 126 |
+
const c = (cutoff * (0.8 + 0.2 * Math.sin(t * 0.4))) / SR * 6.28
|
| 127 |
+
l1 += (L - l1) * c; l2 += (l1 - l2) * c
|
| 128 |
+
r1 += (R - r1) * c; r2 += (r1 - r2) * c
|
| 129 |
+
const g = (env * vel) / voices.length
|
| 130 |
+
out.L[i] += l2 * g
|
| 131 |
+
out.R[i] += r2 * g
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/** Bronze bell (编钟-like): inharmonic partials with long decay. */
|
| 136 |
+
function bell(out: Stem, t0: number, f: number, vel: number, pan: number, decay = 5) {
|
| 137 |
+
const parts = [[1, 1, 1], [2.76, 0.5, 0.6], [5.4, 0.25, 0.4], [8.93, 0.12, 0.25], [0.5, 0.35, 1.3]]
|
| 138 |
+
const s0 = Math.floor(t0 * SR)
|
| 139 |
+
const len = Math.min(Math.floor(decay * 1.6 * SR), out.L.length - s0)
|
| 140 |
+
for (let n = 0; n < len; n++) {
|
| 141 |
+
const t = n / SR
|
| 142 |
+
let v = 0
|
| 143 |
+
for (const [m, a, d] of parts) v += Math.sin(2 * Math.PI * f * m * t + m) * a * Math.exp((-t / decay) / d * 1.0)
|
| 144 |
+
const atk = Math.min(1, n / 60)
|
| 145 |
+
addPan(out, s0 + n, v * vel * atk * 0.25, pan)
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/** Wooden clapper / muyu tick. */
|
| 150 |
+
function woodTick(out: Stem, t0: number, f: number, vel: number, pan: number, r: () => number) {
|
| 151 |
+
const s0 = Math.floor(t0 * SR)
|
| 152 |
+
const len = Math.min(Math.floor(0.12 * SR), out.L.length - s0)
|
| 153 |
+
for (let n = 0; n < len; n++) {
|
| 154 |
+
const t = n / SR
|
| 155 |
+
const v = Math.sin(2 * Math.PI * f * t) * Math.exp(-t * 55) + (r() * 2 - 1) * Math.exp(-t * 300) * 0.3
|
| 156 |
+
addPan(out, s0 + n, v * vel, pan)
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function bassNote(out: Stem, t0: number, dur: number, note: number, vel: number) {
|
| 161 |
+
const f = midi(note)
|
| 162 |
+
const s0 = Math.floor(t0 * SR), len = Math.min(Math.floor(dur * SR), out.L.length - s0)
|
| 163 |
+
for (let n = 0; n < len; n++) {
|
| 164 |
+
const t = n / SR
|
| 165 |
+
const env = Math.min(1, t / 0.4) * Math.min(1, (dur - t) / 0.8)
|
| 166 |
+
const v = (Math.sin(2 * Math.PI * f * t) + 0.25 * Math.sin(4 * Math.PI * f * t)) * env * vel
|
| 167 |
+
out.L[s0 + n] += v
|
| 168 |
+
out.R[s0 + n] += v
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ------------------------------------------------------------------ score
|
| 173 |
+
|
| 174 |
+
export function composeMusic(): Stem {
|
| 175 |
+
const len = Math.ceil((DURATION + 1) * SR)
|
| 176 |
+
const out = stem(len)
|
| 177 |
+
const r = rng(1902)
|
| 178 |
+
const BPM = 72
|
| 179 |
+
const beat = 60 / BPM
|
| 180 |
+
const bar = beat * 4
|
| 181 |
+
|
| 182 |
+
// harmony: one chord per two bars (pentatonic-friendly voicings)
|
| 183 |
+
const chords: { root: number; notes: number[] }[] = [
|
| 184 |
+
{ root: N.D2, notes: [N.D3, N.A3, N.E4, N['F#4']] },
|
| 185 |
+
{ root: N.B1, notes: [N.B2, N['F#3'], N.D4, N.A4] },
|
| 186 |
+
{ root: N.G1, notes: [N.G2, N.D3, N.B3, N['F#4']] },
|
| 187 |
+
{ root: N.A1, notes: [N.A2, N.E3, N.B3, N.D4] },
|
| 188 |
+
]
|
| 189 |
+
const start = 0.4
|
| 190 |
+
let ci = 0
|
| 191 |
+
for (let t = start; t < DURATION - 3; t += bar * 2, ci++) {
|
| 192 |
+
const c = chords[ci % 4]
|
| 193 |
+
const night = t > 62
|
| 194 |
+
const vel = t < 8 ? 0.22 : t > 78 ? 0.34 : night ? 0.26 : 0.3
|
| 195 |
+
pad(out, t, Math.min(DURATION + 0.5, t + bar * 2 + 1.6), c.notes, vel, night ? 900 : 1500, r)
|
| 196 |
+
if (t > 6) bassNote(out, t, bar * 2 + 0.2, c.root + 12, t > 48 ? 0.16 : 0.12)
|
| 197 |
+
// guzheng arpeggios (sparser in the intro and at night)
|
| 198 |
+
const arp = [c.notes[0] + 12, c.notes[1] + 12, c.notes[2] + 12, c.notes[3] + 12, c.notes[2] + 12, c.notes[1] + 24]
|
| 199 |
+
const density = t < 9 ? 3 : t < 32 ? 6 : t < 56 ? 8 : night && t < 74 ? 4 : 6
|
| 200 |
+
for (let k = 0; k < density; k++) {
|
| 201 |
+
const tt = t + (k * bar * 2) / density + (r() - 0.5) * 0.03
|
| 202 |
+
const note = arp[k % arp.length] + (r() < 0.12 ? 12 : 0)
|
| 203 |
+
guzheng(out, tt, note, 0.5 + r() * 0.25, -0.4 + r() * 0.3, r, r() < 0.15 ? 2 : 0)
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// flute theme
|
| 208 |
+
const theme1: [number, number][] = [
|
| 209 |
+
[N.A4, 2], [N.B4, 1], [N.D5, 1], [N.E5, 3], [N.D5, 1], [N.B4, 2], [N.A4, 1], [N['F#4'], 1], [N.A4, 4],
|
| 210 |
+
[N.D5, 2], [N.E5, 1], [N['F#5'], 1], [N.E5, 2], [N.D5, 1], [N.B4, 1], [N.A4, 1.5], [N.B4, 0.5], [N.A4, 1], [N['F#4'], 1], [N.E4, 4],
|
| 211 |
+
]
|
| 212 |
+
const theme2: [number, number][] = [
|
| 213 |
+
[N['F#5'], 2], [N.E5, 1], [N.D5, 1], [N.B4, 3], [N.A4, 1], [N.D5, 1], [N.B4, 1], [N.A4, 1], [N['F#4'], 1], [N.A4, 4],
|
| 214 |
+
[N.B4, 2], [N.D5, 1], [N.E5, 1], [N.D5, 2], [N.B4, 2], [N.A4, 1.5], [N['F#4'], 0.5], [N.E4, 1], [N['F#4'], 1], [N.D4, 4],
|
| 215 |
+
]
|
| 216 |
+
flute(out, start + bar * 3, theme1, beat, 0.2, 0.15, r)
|
| 217 |
+
flute(out, start + bar * 3 + bar * 8, theme2, beat, 0.22, 0.15, r)
|
| 218 |
+
// night: lower, slower xiao phrase
|
| 219 |
+
const night: [number, number][] = [[N.A3, 3], [N.D4, 1], [N.E4, 2], [N['F#4'], 2], [N.E4, 3], [N.D4, 1], [N.B3, 4], [-1, 2], [N.D4, 2], [N.E4, 2], [N.A4, 4], [N['F#4'], 2], [N.E4, 2], [N.D4, 6]]
|
| 220 |
+
flute(out, 64.5, night, beat * 1.15, 0.2, -0.1, r)
|
| 221 |
+
|
| 222 |
+
// light percussion in the market / development section
|
| 223 |
+
for (let t = 32; t < 55; t += beat) {
|
| 224 |
+
const b = Math.round((t - 32) / beat) % 4
|
| 225 |
+
if (b === 0 || b === 2) woodTick(out, t, b === 0 ? 820 : 1150, 0.12, 0.3, r)
|
| 226 |
+
}
|
| 227 |
+
// sunset glissando & lantern bells
|
| 228 |
+
for (let k = 0; k < 14; k++) guzheng(out, 54.6 + k * 0.045, [N.D4, N.E4, N['F#4'], N.A4, N.B4][k % 5] + 12 * Math.floor(k / 5), 0.42, 0.3, r)
|
| 229 |
+
bell(out, LANTERN_START, midi(N.D5), 0.55, 0.2, 4)
|
| 230 |
+
bell(out, LANTERN_START + 2.2, midi(N.A4), 0.35, -0.3, 4)
|
| 231 |
+
bell(out, LANTERN_START + 4.1, midi(N['F#5']), 0.3, 0.4, 4)
|
| 232 |
+
// finale: rising gliss + low bell
|
| 233 |
+
for (let k = 0; k < 18; k++) guzheng(out, 77 + k * 0.06, [N.D4, N.E4, N['F#4'], N.A4, N.B4][k % 5] + 12 * Math.floor(k / 5), 0.38, -0.2 + k * 0.03, r)
|
| 234 |
+
bell(out, 80.2, midi(N.D3), 0.6, 0, 7)
|
| 235 |
+
bell(out, 80.25, midi(N.A4), 0.25, 0.2, 6)
|
| 236 |
+
// master fade
|
| 237 |
+
fadeEnds(out, 0.6, 3.5)
|
| 238 |
+
return out
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function fadeEnds(s: Stem, inS: number, outS: number) {
|
| 242 |
+
const n = s.L.length
|
| 243 |
+
const a = Math.floor(inS * SR), b = Math.floor(outS * SR)
|
| 244 |
+
const end = Math.floor(DURATION * SR)
|
| 245 |
+
for (let i = 0; i < n; i++) {
|
| 246 |
+
let g = 1
|
| 247 |
+
if (i < a) g = i / a
|
| 248 |
+
if (i > end - b) g *= Math.max(0, (end - i) / b)
|
| 249 |
+
s.L[i] *= g
|
| 250 |
+
s.R[i] *= g
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// ------------------------------------------------------------------ sound effects
|
| 255 |
+
|
| 256 |
+
/** piecewise-linear automation helper */
|
| 257 |
+
function auto(keys: [number, number][]) {
|
| 258 |
+
return (t: number) => {
|
| 259 |
+
if (t <= keys[0][0]) return keys[0][1]
|
| 260 |
+
for (let i = 1; i < keys.length; i++) {
|
| 261 |
+
if (t <= keys[i][0]) {
|
| 262 |
+
const f = (t - keys[i - 1][0]) / (keys[i][0] - keys[i - 1][0])
|
| 263 |
+
return keys[i - 1][1] + (keys[i][1] - keys[i - 1][1]) * f
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
return keys[keys.length - 1][1]
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
export function composeSfx(): Stem {
|
| 271 |
+
const len = Math.ceil((DURATION + 1) * SR)
|
| 272 |
+
const out = stem(len)
|
| 273 |
+
const r = rng(77)
|
| 274 |
+
|
| 275 |
+
// river: brown noise + gurgles, level follows the shots
|
| 276 |
+
const riverLv = auto([[0, 0.05], [8.5, 0.06], [9.2, 0.32], [17, 0.3], [25, 0.38], [32, 0.08], [40, 0.06], [48, 0.04], [55, 0.1], [65, 0.3], [74, 0.18], [86, 0.08]])
|
| 277 |
+
let b1 = 0, b2 = 0, hp = 0
|
| 278 |
+
for (let i = 0; i < len; i++) {
|
| 279 |
+
const t = i / SR
|
| 280 |
+
const w = r() * 2 - 1
|
| 281 |
+
b1 = (b1 + 0.02 * w) / 1.02
|
| 282 |
+
b2 += (b1 - b2) * 0.3
|
| 283 |
+
hp = b2 - hp * 0.0 // keep
|
| 284 |
+
const lv = riverLv(t)
|
| 285 |
+
const mod = 0.8 + 0.2 * Math.sin(t * 0.7) * Math.sin(t * 1.9)
|
| 286 |
+
out.L[i] += b1 * 3.2 * lv * mod
|
| 287 |
+
out.R[i] += b2 * 3.2 * lv * (1.6 - mod)
|
| 288 |
+
}
|
| 289 |
+
// gurgles: short rising sine blips
|
| 290 |
+
for (let t = 0.5; t < DURATION; t += 0.05 + r() * 0.25) {
|
| 291 |
+
const lv = riverLv(t)
|
| 292 |
+
if (lv < 0.1 || r() < 0.4) continue
|
| 293 |
+
const f0 = 250 + r() * 600
|
| 294 |
+
const s0 = Math.floor(t * SR), L = Math.floor((0.02 + r() * 0.05) * SR)
|
| 295 |
+
const pan = r() * 1.6 - 0.8
|
| 296 |
+
let ph = 0
|
| 297 |
+
for (let n = 0; n < L && s0 + n < len; n++) {
|
| 298 |
+
const f = f0 * (1 + n / L * 0.8)
|
| 299 |
+
ph += 2 * Math.PI * f / SR
|
| 300 |
+
addPan(out, s0 + n, Math.sin(ph) * Math.sin(Math.PI * n / L) * lv * 0.18, pan)
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// water wheel (shot 4, audible from 23 s to 33 s): creaks + pouring trickle
|
| 305 |
+
const wheelLv = auto([[22.5, 0], [25, 1], [31.5, 1], [33, 0.1], [34, 0]])
|
| 306 |
+
for (let t = 23.0; t < 34; t += 1.25 + r() * 0.3) {
|
| 307 |
+
const lv = wheelLv(t)
|
| 308 |
+
if (lv <= 0) continue
|
| 309 |
+
const s0 = Math.floor(t * SR), L = Math.floor(0.55 * SR)
|
| 310 |
+
const f0 = 380 + r() * 220
|
| 311 |
+
let ph = 0
|
| 312 |
+
for (let n = 0; n < L && s0 + n < len; n++) {
|
| 313 |
+
const x = n / L
|
| 314 |
+
const f = f0 * (1 + 0.35 * Math.sin(x * Math.PI)) + Math.sin(n * 0.02) * 12
|
| 315 |
+
ph += 2 * Math.PI * f / SR
|
| 316 |
+
// stick-slip friction: pulse train amplitude-modulated
|
| 317 |
+
const stick = Math.pow(Math.abs(Math.sin(ph * 0.5)), 6)
|
| 318 |
+
const env = Math.sin(Math.PI * x) * (0.6 + 0.4 * Math.sin(x * 37))
|
| 319 |
+
addPan(out, s0 + n, (stick - 0.2) * env * lv * 0.1, -0.35)
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
{
|
| 323 |
+
let lp = 0
|
| 324 |
+
for (let i = Math.floor(23 * SR); i < Math.floor(34 * SR); i++) {
|
| 325 |
+
const t = i / SR
|
| 326 |
+
const w = r() * 2 - 1
|
| 327 |
+
lp += (w - lp) * 0.35
|
| 328 |
+
const splash = (w - lp) * (0.5 + 0.5 * Math.pow(Math.max(0, Math.sin(t * 2 * Math.PI / 1.1)), 4))
|
| 329 |
+
addPan(out, i, splash * wheelLv(t) * 0.09, -0.2)
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// birds: chirps by day, egret calls when they fly by
|
| 334 |
+
const birdLv = auto([[0, 0.5], [17, 0.6], [40, 0.9], [52, 0.8], [57, 0.25], [62, 0]])
|
| 335 |
+
for (let t = 0.8; t < 60; t += 0.4 + r() * 1.6) {
|
| 336 |
+
const lv = birdLv(t)
|
| 337 |
+
if (lv <= 0.05) continue
|
| 338 |
+
const pan = r() * 1.6 - 0.8
|
| 339 |
+
const notes = 2 + Math.floor(r() * 4)
|
| 340 |
+
const base = 2600 + r() * 2200
|
| 341 |
+
for (let k = 0; k < notes; k++) {
|
| 342 |
+
const s0 = Math.floor((t + k * (0.07 + r() * 0.05)) * SR), L = Math.floor((0.04 + r() * 0.05) * SR)
|
| 343 |
+
let ph = 0
|
| 344 |
+
const sweep = (r() - 0.3) * 1800
|
| 345 |
+
for (let n = 0; n < L && s0 + n < len; n++) {
|
| 346 |
+
const x = n / L
|
| 347 |
+
ph += 2 * Math.PI * (base + sweep * x + 300 * Math.sin(x * 40)) / SR
|
| 348 |
+
addPan(out, s0 + n, Math.sin(ph) * Math.sin(Math.PI * x) * lv * 0.045, pan)
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
for (const t of [18.6, 20.1, 47.2, 49.0]) {
|
| 353 |
+
// egret: harsh low croak
|
| 354 |
+
const s0 = Math.floor(t * SR), L = Math.floor(0.32 * SR)
|
| 355 |
+
let ph = 0
|
| 356 |
+
for (let n = 0; n < L && s0 + n < len; n++) {
|
| 357 |
+
const x = n / L
|
| 358 |
+
ph += 2 * Math.PI * (520 - 140 * x) / SR
|
| 359 |
+
const buzz = Math.sign(Math.sin(ph)) * 0.4 + Math.sin(ph * 2)
|
| 360 |
+
addPan(out, s0 + n, buzz * Math.sin(Math.PI * x) * 0.035 * (1 + 0.5 * Math.sin(n * 0.05)), 0.4)
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
// wing beats as the egrets cross the river
|
| 364 |
+
for (let t = 17.5; t < 23; t += 0.38) woodLow(out, t, 0.05, r)
|
| 365 |
+
|
| 366 |
+
// market: ox bell, hooves, cart creak, footsteps (shot 5)
|
| 367 |
+
for (let t = 31.5; t < 41; t += 1.05) {
|
| 368 |
+
const lv = t < 32.5 ? (t - 31.5) : t > 39.5 ? Math.max(0, 41 - t) / 1.5 : 1
|
| 369 |
+
cowbell(out, t + 0.02 * Math.sin(t), 0.12 * lv, 0.15)
|
| 370 |
+
woodTick(out, t + 0.5, 300, 0.05 * lv, -0.1, r)
|
| 371 |
+
woodTick(out, t + 0.02, 260, 0.05 * lv, 0.1, r)
|
| 372 |
+
}
|
| 373 |
+
for (let t = 32; t < 40; t += 0.55 + r() * 0.1) woodTick(out, t, 1800 + r() * 400, 0.025, r() - 0.5, r) // footsteps
|
| 374 |
+
|
| 375 |
+
// bamboo / leaves rustle (shots 6-7)
|
| 376 |
+
const windLv = auto([[38, 0], [41, 0.6], [48, 0.5], [55, 0.35], [60, 0.1], [86, 0.12]])
|
| 377 |
+
{
|
| 378 |
+
let a = 0, b = 0
|
| 379 |
+
for (let i = Math.floor(38 * SR); i < len; i++) {
|
| 380 |
+
const t = i / SR
|
| 381 |
+
const w = r() * 2 - 1
|
| 382 |
+
a += (w - a) * 0.25
|
| 383 |
+
b += (a - b) * 0.05
|
| 384 |
+
const gust = 0.5 + 0.5 * Math.sin(t * 0.9) * Math.sin(t * 0.37 + 1)
|
| 385 |
+
const v = (a - b) * windLv(t) * gust * 0.22
|
| 386 |
+
out.L[i] += v
|
| 387 |
+
out.R[i] += v * (0.7 + 0.3 * Math.sin(t))
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
// temple bell far away at dusk + lantern ignitions
|
| 392 |
+
bellFar(out, 55.6, 0.22)
|
| 393 |
+
for (const t of [LANTERN_START, LANTERN_START + 0.7, LANTERN_START + 1.3, LANTERN_START + 1.9, LANTERN_START + 2.4]) whoomp(out, t, 0.08, r)
|
| 394 |
+
|
| 395 |
+
// night: crickets and frogs from ~61 s
|
| 396 |
+
const nightLv = auto([[60, 0], [64, 0.8], [86, 1]])
|
| 397 |
+
for (let t = 60.5; t < DURATION; t += 0.18 + r() * 0.5) {
|
| 398 |
+
const lv = nightLv(t)
|
| 399 |
+
const f = 4200 + r() * 900
|
| 400 |
+
const s0 = Math.floor(t * SR), L = Math.floor((0.08 + r() * 0.12) * SR)
|
| 401 |
+
const pan = r() * 1.8 - 0.9
|
| 402 |
+
for (let n = 0; n < L && s0 + n < len; n++) {
|
| 403 |
+
const tt = n / SR
|
| 404 |
+
const am = Math.max(0, Math.sin(2 * Math.PI * 34 * tt))
|
| 405 |
+
addPan(out, s0 + n, Math.sin(2 * Math.PI * f * tt) * am * Math.sin(Math.PI * n / L) * lv * 0.02, pan)
|
| 406 |
+
}
|
| 407 |
+
}
|
| 408 |
+
for (let t = 62; t < DURATION - 1; t += 0.9 + r() * 2.2) {
|
| 409 |
+
const lv = nightLv(t)
|
| 410 |
+
const s0 = Math.floor(t * SR), L = Math.floor(0.22 * SR)
|
| 411 |
+
const f = 180 + r() * 90
|
| 412 |
+
const pan = r() * 1.4 - 0.7
|
| 413 |
+
for (let n = 0; n < L && s0 + n < len; n++) {
|
| 414 |
+
const tt = n / SR
|
| 415 |
+
const pulse = Math.max(0, Math.sin(2 * Math.PI * 22 * tt))
|
| 416 |
+
addPan(out, s0 + n, Math.sin(2 * Math.PI * f * tt) * pulse * Math.sin(Math.PI * n / L) * lv * 0.06, pan)
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
// oar strokes of the night boat (shot 9)
|
| 420 |
+
for (let t = 64.8; t < 75; t += 2.9) splash(out, t, 0.07, r)
|
| 421 |
+
fadeEnds(out, 1.0, 2.5)
|
| 422 |
+
return out
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
function woodLow(out: Stem, t0: number, vel: number, r: () => number) {
|
| 426 |
+
const s0 = Math.floor(t0 * SR), L = Math.floor(0.12 * SR)
|
| 427 |
+
let lp = 0
|
| 428 |
+
for (let n = 0; n < L && s0 + n < out.L.length; n++) {
|
| 429 |
+
lp += ((r() * 2 - 1) - lp) * 0.04
|
| 430 |
+
addPan(out, s0 + n, lp * Math.sin(Math.PI * n / L) * vel * 3, 0.2)
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
function cowbell(out: Stem, t0: number, vel: number, pan: number) {
|
| 434 |
+
const s0 = Math.floor(t0 * SR), L = Math.floor(0.7 * SR)
|
| 435 |
+
for (let n = 0; n < L && s0 + n < out.L.length; n++) {
|
| 436 |
+
const t = n / SR
|
| 437 |
+
const v = (Math.sign(Math.sin(2 * Math.PI * 562 * t)) * 0.3 + Math.sin(2 * Math.PI * 845 * t) + 0.5 * Math.sin(2 * Math.PI * 1310 * t)) * Math.exp(-t * 7)
|
| 438 |
+
addPan(out, s0 + n, v * vel, pan)
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
function bellFar(out: Stem, t0: number, vel: number) {
|
| 442 |
+
const s0 = Math.floor(t0 * SR), L = Math.floor(7 * SR)
|
| 443 |
+
for (let n = 0; n < L && s0 + n < out.L.length; n++) {
|
| 444 |
+
const t = n / SR
|
| 445 |
+
const v = (Math.sin(2 * Math.PI * 98 * t) + 0.6 * Math.sin(2 * Math.PI * 98 * 2.41 * t) * Math.exp(-t * 0.6) + 0.3 * Math.sin(2 * Math.PI * 98 * 3.9 * t) * Math.exp(-t * 1.2)) * Math.exp(-t * 0.45) * (1 + 0.15 * Math.sin(2 * Math.PI * 1.3 * t))
|
| 446 |
+
addPan(out, s0 + n, v * vel * Math.min(1, n / 200), -0.5)
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
function whoomp(out: Stem, t0: number, vel: number, r: () => number) {
|
| 450 |
+
const s0 = Math.floor(t0 * SR), L = Math.floor(0.45 * SR)
|
| 451 |
+
let lp = 0
|
| 452 |
+
for (let n = 0; n < L && s0 + n < out.L.length; n++) {
|
| 453 |
+
const x = n / L
|
| 454 |
+
lp += ((r() * 2 - 1) - lp) * (0.02 + 0.1 * x)
|
| 455 |
+
addPan(out, s0 + n, lp * Math.sin(Math.PI * x) * vel * 4, 0.1)
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
function splash(out: Stem, t0: number, vel: number, r: () => number) {
|
| 459 |
+
const s0 = Math.floor(t0 * SR), L = Math.floor(0.8 * SR)
|
| 460 |
+
let lp = 0
|
| 461 |
+
for (let n = 0; n < L && s0 + n < out.L.length; n++) {
|
| 462 |
+
const x = n / L
|
| 463 |
+
const w = r() * 2 - 1
|
| 464 |
+
lp += (w - lp) * 0.3
|
| 465 |
+
addPan(out, s0 + n, (w - lp) * Math.exp(-x * 5) * vel * (0.6 + 0.4 * Math.sin(n * 0.001)), 0.3)
|
| 466 |
+
}
|
| 467 |
+
}
|
src/jiangnan/blender/jiangnan_village.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
《暮归江南》Blender 场景生成脚本 (Blender 4.0+ / 4.2 LTS / 4.x)
|
| 4 |
+
|
| 5 |
+
用法:Blender → Scripting 工作区 → 打开本文件 → Run Script。
|
| 6 |
+
脚本会清空当前场景,然后从零生成:
|
| 7 |
+
· 山谷地形、远山、梯田、蜿蜒小河(反射水面)、石驳岸、青石板街
|
| 8 |
+
· 徽派民居(粉墙、阶梯马头墙、黛瓦凹曲屋面、木格窗,窗户夜里亮灯)
|
| 9 |
+
· 半圆石拱桥、老水车(旋转动画)、乌篷船、集市摊位、村口古樟、石碾、六角亭、竹林
|
| 10 |
+
· 红灯笼(日落后按顺序逐个亮起)、炊烟(烟雾体积可选)
|
| 11 |
+
· Nishita 物理天空 + 太阳从 8.5° 落到 -10°(与网页版同一条日落曲线)、蓝调、星光
|
| 12 |
+
· 10 个镜头的摄像机与时间轴标记(自动切换镜头),24 fps,共 86 秒
|
| 13 |
+
与网页版共用同一套布局算法(同一条河道、同一个随机种子),所以村子的样子一致。
|
| 14 |
+
|
| 15 |
+
渲染:默认 Cycles(GPU 可在 偏好设置 → 系统 里开启),1920×1080,128 采样 + 降噪。
|
| 16 |
+
想先快速预览,可把 RENDER_ENGINE 改成 'BLENDER_EEVEE_NEXT'(4.2+)或 'BLENDER_EEVEE'。
|
| 17 |
+
"""
|
| 18 |
+
import bpy
|
| 19 |
+
import bmesh
|
| 20 |
+
import math
|
| 21 |
+
import random
|
| 22 |
+
from mathutils import Vector, Matrix, Euler
|
| 23 |
+
|
| 24 |
+
FPS = 24
|
| 25 |
+
DURATION = 86.0
|
| 26 |
+
RENDER_ENGINE = 'CYCLES'
|
| 27 |
+
SEED = 20261022
|
| 28 |
+
GROUND_Y = 1.7 # web units: y-up metres. Blender is z-up: (x, y, z)_web -> (x, -z, y)_blender
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def W2B(x, y, z):
|
| 32 |
+
"""web (x, y-up, z) -> blender (x, y, z-up)"""
|
| 33 |
+
return Vector((x, -z, y))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ------------------------------------------------------------------ deterministic noise (same as the web build)
|
| 37 |
+
def mulberry32(seed):
|
| 38 |
+
a = seed & 0xFFFFFFFF
|
| 39 |
+
|
| 40 |
+
def rnd():
|
| 41 |
+
nonlocal a
|
| 42 |
+
a = (a + 0x6D2B79F5) & 0xFFFFFFFF
|
| 43 |
+
t = a
|
| 44 |
+
t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
|
| 45 |
+
t ^= (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF)) & 0xFFFFFFFF
|
| 46 |
+
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
|
| 47 |
+
return rnd
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _imul(a, b):
|
| 51 |
+
return ((a & 0xFFFFFFFF) * (b & 0xFFFFFFFF)) & 0xFFFFFFFF
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def hash2(x, y, seed=0):
|
| 55 |
+
h = (_imul(int(x), 374761393) + _imul(int(y), 668265263) + _imul(int(seed), 2246822519)) & 0xFFFFFFFF
|
| 56 |
+
h = _imul(h ^ (h >> 13), 1274126177)
|
| 57 |
+
h ^= h >> 16
|
| 58 |
+
return (h & 0xFFFFFFFF) / 4294967296.0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fade(t):
|
| 62 |
+
return t * t * (3 - 2 * t)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def vnoise(x, y, seed=0):
|
| 66 |
+
xi, yi = math.floor(x), math.floor(y)
|
| 67 |
+
xf, yf = x - xi, y - yi
|
| 68 |
+
a, b = hash2(xi, yi, seed), hash2(xi + 1, yi, seed)
|
| 69 |
+
c, d = hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed)
|
| 70 |
+
u, v = fade(xf), fade(yf)
|
| 71 |
+
return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def fbm(x, y, octv=5, seed=0):
|
| 75 |
+
amp, f, s, n = 0.5, 1.0, 0.0, 0.0
|
| 76 |
+
for i in range(octv):
|
| 77 |
+
s += amp * vnoise(x * f, y * f, seed + i * 31)
|
| 78 |
+
n += amp
|
| 79 |
+
amp *= 0.5
|
| 80 |
+
f *= 2.03
|
| 81 |
+
return s / n
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def smoothstep(a, b, v):
|
| 85 |
+
t = min(1.0, max(0.0, (v - a) / (b - a)))
|
| 86 |
+
return t * t * (3 - 2 * t)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def lerp(a, b, t):
|
| 90 |
+
return a + (b - a) * t
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ------------------------------------------------------------------ river (same control points as layout.ts)
|
| 94 |
+
RIVER_CTRL = [(-1500, -330), (-1050, -260), (-760, -150), (-520, -70), (-330, -22), (-190, 6), (-80, 14),
|
| 95 |
+
(30, 4), (120, -10), (220, 6), (330, 46), (470, 92), (660, 104), (900, 60), (1300, -40), (1700, -120)]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def catmull(p0, p1, p2, p3, t):
|
| 99 |
+
t2, t3 = t * t, t * t * t
|
| 100 |
+
return 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build_river():
|
| 104 |
+
raw = []
|
| 105 |
+
n = len(RIVER_CTRL)
|
| 106 |
+
for i in range(n - 1):
|
| 107 |
+
p0, p1, p2, p3 = RIVER_CTRL[max(0, i - 1)], RIVER_CTRL[i], RIVER_CTRL[i + 1], RIVER_CTRL[min(n - 1, i + 2)]
|
| 108 |
+
for k in range(80):
|
| 109 |
+
t = k / 80
|
| 110 |
+
raw.append((catmull(p0[0], p1[0], p2[0], p3[0], t), catmull(p0[1], p1[1], p2[1], p3[1], t)))
|
| 111 |
+
raw.append(RIVER_CTRL[-1])
|
| 112 |
+
cum = [0.0]
|
| 113 |
+
for i in range(1, len(raw)):
|
| 114 |
+
cum.append(cum[-1] + math.hypot(raw[i][0] - raw[i - 1][0], raw[i][1] - raw[i - 1][1]))
|
| 115 |
+
out, j, s = [], 0, 0.0
|
| 116 |
+
while s <= cum[-1]:
|
| 117 |
+
while j < len(cum) - 2 and cum[j + 1] < s:
|
| 118 |
+
j += 1
|
| 119 |
+
f = (s - cum[j]) / max(1e-6, cum[j + 1] - cum[j])
|
| 120 |
+
out.append([lerp(raw[j][0], raw[j + 1][0], f), lerp(raw[j][1], raw[j + 1][1], f), 0.0, 0.0, s])
|
| 121 |
+
s += 2.0
|
| 122 |
+
for i in range(len(out)):
|
| 123 |
+
a, b = out[max(0, i - 2)], out[min(len(out) - 1, i + 2)]
|
| 124 |
+
l = math.hypot(b[0] - a[0], b[1] - a[1]) or 1
|
| 125 |
+
out[i][2] = (b[0] - a[0]) / l
|
| 126 |
+
out[i][3] = (b[1] - a[1]) / l
|
| 127 |
+
return out
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
RIVER = build_river()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def river_s_at_x(x):
|
| 134 |
+
return min(RIVER, key=lambda p: abs(p[0] - x))[4]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def river_point(s, off):
|
| 138 |
+
f = min(max(s / 2.0, 0), len(RIVER) - 1.001)
|
| 139 |
+
i = int(f)
|
| 140 |
+
t = f - i
|
| 141 |
+
a, b = RIVER[i], RIVER[i + 1]
|
| 142 |
+
tx, tz = lerp(a[2], b[2], t), lerp(a[3], b[3], t)
|
| 143 |
+
l = math.hypot(tx, tz) or 1
|
| 144 |
+
tx, tz = tx / l, tz / l
|
| 145 |
+
nx, nz = -tz, tx
|
| 146 |
+
return lerp(a[0], b[0], t) + nx * off, lerp(a[1], b[1], t) + nz * off, tx, tz, nx, nz
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
VILLAGE_S0 = river_s_at_x(-300)
|
| 150 |
+
VILLAGE_S1 = river_s_at_x(290)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def village_factor(s):
|
| 154 |
+
return smoothstep(VILLAGE_S0 - 30, VILLAGE_S0 + 10, s) * (1 - smoothstep(VILLAGE_S1 - 10, VILLAGE_S1 + 30, s))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def river_half_width(s):
|
| 158 |
+
inv = smoothstep(VILLAGE_S0 - 120, VILLAGE_S0, s) * (1 - smoothstep(VILLAGE_S1, VILLAGE_S1 + 120, s))
|
| 159 |
+
return lerp(13 + 3 * vnoise(s * 0.01, 3), 8, inv)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def river_query(x, z):
|
| 163 |
+
best, bi = 1e18, 0
|
| 164 |
+
for i in range(0, len(RIVER), 3):
|
| 165 |
+
p = RIVER[i]
|
| 166 |
+
d = (p[0] - x) ** 2 + (p[1] - z) ** 2
|
| 167 |
+
if d < best:
|
| 168 |
+
best, bi = d, i
|
| 169 |
+
for i in range(max(0, bi - 3), min(len(RIVER), bi + 4)):
|
| 170 |
+
p = RIVER[i]
|
| 171 |
+
d = (p[0] - x) ** 2 + (p[1] - z) ** 2
|
| 172 |
+
if d < best:
|
| 173 |
+
best, bi = d, i
|
| 174 |
+
return math.sqrt(best), RIVER[bi][4]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def terrain_height(x, z):
|
| 178 |
+
d, s = river_query(x, z)
|
| 179 |
+
h = GROUND_Y + (fbm(x * 0.012, z * 0.012, 3, 5) - 0.5) * 1.6 * smoothstep(50, 140, d)
|
| 180 |
+
far = math.hypot(x * 0.8, z)
|
| 181 |
+
hd = min(d, far * 0.9 + 40)
|
| 182 |
+
hill = smoothstep(130, 560, hd)
|
| 183 |
+
h += hill * hill * (60 + 190 * fbm(x * 0.0022 + 3, z * 0.0022, 5, 7))
|
| 184 |
+
w = river_half_width(s)
|
| 185 |
+
if d < w + 30:
|
| 186 |
+
bank_w = lerp(9, 1.2, village_factor(s))
|
| 187 |
+
bank = smoothstep(w - bank_w * 0.6, w + bank_w * 0.4, d)
|
| 188 |
+
h = lerp(-2.6, h, bank)
|
| 189 |
+
return h
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ------------------------------------------------------------------ helpers
|
| 193 |
+
def clear_scene():
|
| 194 |
+
bpy.ops.object.select_all(action='SELECT')
|
| 195 |
+
bpy.ops.object.delete(use_global=False)
|
| 196 |
+
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.lights, bpy.data.cameras, bpy.data.curves):
|
| 197 |
+
for b in list(coll):
|
| 198 |
+
if b.users == 0:
|
| 199 |
+
coll.remove(b)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def collection(name):
|
| 203 |
+
c = bpy.data.collections.get(name) or bpy.data.collections.new(name)
|
| 204 |
+
if c.name not in bpy.context.scene.collection.children:
|
| 205 |
+
bpy.context.scene.collection.children.link(c)
|
| 206 |
+
return c
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def mesh_object(name, verts, faces, coll, mat=None, smooth=False):
|
| 210 |
+
me = bpy.data.meshes.new(name)
|
| 211 |
+
me.from_pydata(verts, [], faces)
|
| 212 |
+
me.update()
|
| 213 |
+
ob = bpy.data.objects.new(name, me)
|
| 214 |
+
coll.objects.link(ob)
|
| 215 |
+
if mat:
|
| 216 |
+
ob.data.materials.append(mat)
|
| 217 |
+
if smooth:
|
| 218 |
+
for p in me.polygons:
|
| 219 |
+
p.use_smooth = True
|
| 220 |
+
return ob
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def principled(name, color, rough=0.8, metal=0.0, emission=None, strength=0.0, alpha=1.0):
|
| 224 |
+
m = bpy.data.materials.new(name)
|
| 225 |
+
m.use_nodes = True
|
| 226 |
+
bsdf = m.node_tree.nodes.get('Principled BSDF')
|
| 227 |
+
bsdf.inputs['Base Color'].default_value = (*color, 1)
|
| 228 |
+
bsdf.inputs['Roughness'].default_value = rough
|
| 229 |
+
bsdf.inputs['Metallic'].default_value = metal
|
| 230 |
+
if emission is not None:
|
| 231 |
+
key = 'Emission Color' if 'Emission Color' in bsdf.inputs else 'Emission'
|
| 232 |
+
bsdf.inputs[key].default_value = (*emission, 1)
|
| 233 |
+
bsdf.inputs['Emission Strength'].default_value = strength
|
| 234 |
+
return m
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def noise_material(name, c1, c2, scale, rough=0.85, bump=0.3):
|
| 238 |
+
"""Procedural weathered surface (lime plaster, tiles, stone)."""
|
| 239 |
+
m = bpy.data.materials.new(name)
|
| 240 |
+
m.use_nodes = True
|
| 241 |
+
nt = m.node_tree
|
| 242 |
+
bsdf = nt.nodes.get('Principled BSDF')
|
| 243 |
+
tex = nt.nodes.new('ShaderNodeTexNoise')
|
| 244 |
+
tex.inputs['Scale'].default_value = scale
|
| 245 |
+
tex.inputs['Detail'].default_value = 8
|
| 246 |
+
ramp = nt.nodes.new('ShaderNodeValToRGB')
|
| 247 |
+
ramp.color_ramp.elements[0].color = (*c1, 1)
|
| 248 |
+
ramp.color_ramp.elements[1].color = (*c2, 1)
|
| 249 |
+
nt.links.new(tex.outputs['Fac'], ramp.inputs['Fac'])
|
| 250 |
+
nt.links.new(ramp.outputs['Color'], bsdf.inputs['Base Color'])
|
| 251 |
+
bsdf.inputs['Roughness'].default_value = rough
|
| 252 |
+
bmp = nt.nodes.new('ShaderNodeBump')
|
| 253 |
+
bmp.inputs['Strength'].default_value = bump
|
| 254 |
+
nt.links.new(tex.outputs['Fac'], bmp.inputs['Height'])
|
| 255 |
+
nt.links.new(bmp.outputs['Normal'], bsdf.inputs['Normal'])
|
| 256 |
+
return m
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def add_box(bm, cx, cy, cz, sx, sy, sz, mat_index=0, matrix=None):
|
| 260 |
+
"""box in web local coords (x, y-up, z) -> appended to bm (blender coords)."""
|
| 261 |
+
res = bmesh.ops.create_cube(bm, size=1.0)
|
| 262 |
+
vs = res['verts']
|
| 263 |
+
for v in vs:
|
| 264 |
+
lx, ly, lz = v.co.x * sx + cx, v.co.y * sy + cy, v.co.z * sz + cz # cube axes treated as web axes
|
| 265 |
+
p = Vector((lx, ly, lz))
|
| 266 |
+
if matrix is not None:
|
| 267 |
+
p = matrix @ p
|
| 268 |
+
v.co = W2B(p.x, p.y, p.z)
|
| 269 |
+
for f in {f for v in vs for f in v.link_faces}:
|
| 270 |
+
f.material_index = mat_index
|
| 271 |
+
return vs
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def web_matrix(x, y, z, ry):
|
| 275 |
+
return Matrix.Translation((x, y, z)) @ Matrix.Rotation(ry, 4, 'Y')
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ------------------------------------------------------------------ build
|
| 279 |
+
def build_materials():
|
| 280 |
+
M = {}
|
| 281 |
+
M['wall'] = noise_material('粉墙', (0.78, 0.76, 0.71), (0.93, 0.92, 0.88), 6.0, 0.92, 0.15)
|
| 282 |
+
M['roof'] = noise_material('黛瓦', (0.035, 0.037, 0.042), (0.08, 0.085, 0.095), 40.0, 0.55, 0.6)
|
| 283 |
+
M['dark'] = principled('墨色', (0.03, 0.03, 0.032), 0.7)
|
| 284 |
+
M['wood'] = noise_material('老木', (0.12, 0.06, 0.03), (0.2, 0.11, 0.05), 20.0, 0.75, 0.2)
|
| 285 |
+
M['stone'] = noise_material('青石', (0.22, 0.23, 0.23), (0.36, 0.37, 0.36), 9.0, 0.85, 0.5)
|
| 286 |
+
M['window'] = principled('窗纸', (0.45, 0.4, 0.32), 0.9, emission=(1.0, 0.55, 0.22), strength=0.0)
|
| 287 |
+
M['lantern'] = principled('红灯笼', (0.7, 0.06, 0.03), 0.5, emission=(1.0, 0.32, 0.1), strength=0.0)
|
| 288 |
+
M['leaf'] = noise_material('樟叶', (0.04, 0.09, 0.025), (0.09, 0.16, 0.04), 30.0, 0.7, 0.4)
|
| 289 |
+
M['bark'] = noise_material('树皮', (0.05, 0.035, 0.025), (0.12, 0.09, 0.06), 25.0, 0.95, 0.8)
|
| 290 |
+
M['ground'] = noise_material('田野', (0.06, 0.1, 0.03), (0.2, 0.18, 0.07), 0.08, 0.95, 0.3)
|
| 291 |
+
M['bamboo'] = principled('竹', (0.18, 0.25, 0.07), 0.6)
|
| 292 |
+
M['cloth'] = principled('蓝印花布', (0.04, 0.08, 0.25), 0.9)
|
| 293 |
+
# water: glossy reflection with a jade body colour
|
| 294 |
+
w = bpy.data.materials.new('河水')
|
| 295 |
+
w.use_nodes = True
|
| 296 |
+
b = w.node_tree.nodes.get('Principled BSDF')
|
| 297 |
+
b.inputs['Base Color'].default_value = (0.01, 0.03, 0.025, 1)
|
| 298 |
+
b.inputs['Roughness'].default_value = 0.03
|
| 299 |
+
b.inputs['IOR'].default_value = 1.33
|
| 300 |
+
tex = w.node_tree.nodes.new('ShaderNodeTexNoise')
|
| 301 |
+
tex.inputs['Scale'].default_value = 3.0
|
| 302 |
+
bmp = w.node_tree.nodes.new('ShaderNodeBump')
|
| 303 |
+
bmp.inputs['Strength'].default_value = 0.08
|
| 304 |
+
w.node_tree.links.new(tex.outputs['Fac'], bmp.inputs['Height'])
|
| 305 |
+
w.node_tree.links.new(bmp.outputs['Normal'], b.inputs['Normal'])
|
| 306 |
+
M['water'] = w
|
| 307 |
+
return M
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def build_terrain(M, coll, step=6.0, extent=900):
|
| 311 |
+
verts, faces = [], []
|
| 312 |
+
n = int(2 * extent / step) + 1
|
| 313 |
+
for j in range(n):
|
| 314 |
+
for i in range(n):
|
| 315 |
+
x, z = -extent + i * step, -extent + j * step
|
| 316 |
+
verts.append(W2B(x, terrain_height(x, z), z))
|
| 317 |
+
for j in range(n - 1):
|
| 318 |
+
for i in range(n - 1):
|
| 319 |
+
a = j * n + i
|
| 320 |
+
faces.append((a, a + 1, a + n + 1, a + n))
|
| 321 |
+
ob = mesh_object('山谷地形', verts, faces, coll, M['ground'], smooth=True)
|
| 322 |
+
# far mountains ring
|
| 323 |
+
mverts, mfaces = [], []
|
| 324 |
+
rings = [extent * 1.05 * (1.12 ** k) for k in range(22)]
|
| 325 |
+
seg = 240
|
| 326 |
+
for r in rings:
|
| 327 |
+
for a in range(seg):
|
| 328 |
+
ang = a / seg * math.tau
|
| 329 |
+
x, z = math.cos(ang) * r, math.sin(ang) * r
|
| 330 |
+
d_sun = math.atan2(math.sin(ang - (math.pi + 0.2)), math.cos(ang - (math.pi + 0.2)))
|
| 331 |
+
west = math.exp(-(d_sun ** 2) / 0.5)
|
| 332 |
+
h = smoothstep(700, 2600, r) * (150 + 360 * fbm(x * 0.0009, z * 0.0009, 6, 77)) * (1 - 0.74 * west) + terrain_height(x, z) * 0.2
|
| 333 |
+
mverts.append(W2B(x, h, z))
|
| 334 |
+
for ri in range(len(rings) - 1):
|
| 335 |
+
for a in range(seg):
|
| 336 |
+
p, q = ri * seg + a, ri * seg + (a + 1) % seg
|
| 337 |
+
mfaces.append((p, q, q + seg, p + seg))
|
| 338 |
+
mesh_object('远山', mverts, mfaces, coll, M['ground'], smooth=True)
|
| 339 |
+
return ob
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def build_river(M, coll):
|
| 343 |
+
verts, faces = [], []
|
| 344 |
+
rows = 0
|
| 345 |
+
for k in range(0, len(RIVER), 2):
|
| 346 |
+
s = RIVER[k][4]
|
| 347 |
+
w = river_half_width(s) + 3
|
| 348 |
+
for off in (-w, w):
|
| 349 |
+
x, z, *_ = river_point(s, off)
|
| 350 |
+
verts.append(W2B(x, 0.0, z))
|
| 351 |
+
rows += 1
|
| 352 |
+
for r in range(rows - 1):
|
| 353 |
+
a = r * 2
|
| 354 |
+
faces.append((a, a + 1, a + 3, a + 2))
|
| 355 |
+
mesh_object('小河', verts, faces, coll, M['water'])
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def build_houses(M, coll):
|
| 359 |
+
"""Same row / spacing rules and RNG as buildHouses() in layout.ts."""
|
| 360 |
+
rng = mulberry32(SEED)
|
| 361 |
+
bridge_s, wheel_s = river_s_at_x(40), river_s_at_x(-196)
|
| 362 |
+
rows = [(-1, 14.6, True, 0.3), (1, 8.4, False, 0.2), (-1, 31, False, 2.5), (1, 25, False, 2.5), (-1, 50, False, 5), (1, 44, False, 5)]
|
| 363 |
+
houses = []
|
| 364 |
+
for side, off0, shop, jitter in rows:
|
| 365 |
+
s = VILLAGE_S0 + rng() * 6 + (10 if off0 > 20 else 0)
|
| 366 |
+
s_end = VILLAGE_S1 - (40 if off0 > 40 else 0)
|
| 367 |
+
while s < s_end:
|
| 368 |
+
w = 8 + rng() * 5
|
| 369 |
+
half = w / 2
|
| 370 |
+
sc = s + half
|
| 371 |
+
if off0 > 40 and rng() < 0.3:
|
| 372 |
+
s += w + 6
|
| 373 |
+
continue
|
| 374 |
+
blocked = abs(sc - bridge_s) < half + 8 or (side > 0 and abs(sc - wheel_s) < half + 10) or (side < 0 and sc < river_s_at_x(-264) + half)
|
| 375 |
+
if not blocked:
|
| 376 |
+
d = 8.5 + rng() * 3.5
|
| 377 |
+
off = side * (off0 + d / 2 + (rng() - 0.5) * jitter)
|
| 378 |
+
x, z, tx, tz, nx, nz = river_point(sc, off)
|
| 379 |
+
facing = math.atan2(-side * nx, -side * nz)
|
| 380 |
+
floors = 2 if rng() < (0.75 if shop else 0.55) else 1
|
| 381 |
+
rot = facing + (rng() - 0.5) * 0.05 * (3 if off0 > 20 else 1)
|
| 382 |
+
steps = (3 if rng() < 0.6 else 2) if floors == 2 else (2 if rng() < 0.5 else 1)
|
| 383 |
+
seed = int(rng() * 1e9)
|
| 384 |
+
chimney = rng() < 0.35
|
| 385 |
+
houses.append(dict(x=x, z=z, rot=rot, w=w, d=d, floors=floors, steps=steps, shop=shop, side=side, s=sc, chimney=chimney, lantern=off0 < 20))
|
| 386 |
+
s += w + ((0.2 + rng() * 0.6) if shop else (0.6 + rng() * 3.5))
|
| 387 |
+
if side > 0 and off0 < 10 and rng() < 0.18:
|
| 388 |
+
s += 5
|
| 389 |
+
bm = bmesh.new()
|
| 390 |
+
windows = []
|
| 391 |
+
lanterns = []
|
| 392 |
+
for h in houses:
|
| 393 |
+
m = web_matrix(h['x'], max(GROUND_Y, terrain_height(h['x'], h['z'])), h['z'], h['rot'])
|
| 394 |
+
w, d = h['w'], h['d']
|
| 395 |
+
H = h['floors'] * 3.3 + 0.5
|
| 396 |
+
rise = d * 0.27
|
| 397 |
+
add_box(bm, 0, H / 2 - 0.8, 0, w - 0.4, H + 1.6, d, 0, m) # lime walls
|
| 398 |
+
# stepped horse-head gables
|
| 399 |
+
n = h['steps']
|
| 400 |
+
seg = d / 2 / n
|
| 401 |
+
for sx in (-1, 1):
|
| 402 |
+
for i in range(n):
|
| 403 |
+
top = H + rise * (i + 1) / n + 0.55
|
| 404 |
+
z0 = d / 2 - i * seg if i else d / 2 + 0.15
|
| 405 |
+
z1 = d / 2 - (i + 1) * seg if i < n - 1 else -(d / 2 - i * seg if i else d / 2 + 0.15)
|
| 406 |
+
zc, zl = (z0 + z1) / 2, abs(z0 - z1)
|
| 407 |
+
for mirror in ((1,) if i == n - 1 else (1, -1)):
|
| 408 |
+
add_box(bm, sx * (w / 2 - 0.18), top / 2 - 0.8, mirror * zc, 0.36, top + 1.6, zl, 0, m)
|
| 409 |
+
add_box(bm, sx * (w / 2 - 0.18), top + 0.1, mirror * zc, 0.8, 0.2, zl + 0.3, 2, m)
|
| 410 |
+
# roof: two slopes (tiles)
|
| 411 |
+
for zs in (1, -1):
|
| 412 |
+
ang = math.atan2(rise, d / 2 + 0.8)
|
| 413 |
+
L = math.hypot(d / 2 + 0.85, rise)
|
| 414 |
+
rm = m @ Matrix.Translation((0, H + rise / 2 - 0.1, zs * (d / 4 + 0.42))) @ Matrix.Rotation(zs * ang, 4, 'X')
|
| 415 |
+
add_box(bm, 0, 0, 0, w - 0.6, 0.12, L, 1, rm)
|
| 416 |
+
add_box(bm, 0, H + rise + 0.1, 0, w - 0.6, 0.3, 0.4, 2, m) # ridge
|
| 417 |
+
# windows (emissive at night)
|
| 418 |
+
nw = max(2, int(w / 3.2))
|
| 419 |
+
for k in range(nw):
|
| 420 |
+
x = -w / 2 + (k + 0.5) * w / nw
|
| 421 |
+
windows.append((m, x, (3.3 + 1.7) if h['floors'] == 2 else 1.9, d / 2 + 0.03, h))
|
| 422 |
+
if h['lantern']:
|
| 423 |
+
for fx in (-0.3, 0.3):
|
| 424 |
+
p = m @ Vector((fx * w, 3.2 if h['floors'] == 2 else H - 0.6, d / 2 + 0.9))
|
| 425 |
+
lanterns.append((p, h['s'], h['side']))
|
| 426 |
+
me = bpy.data.meshes.new('徽派民居')
|
| 427 |
+
bm.to_mesh(me)
|
| 428 |
+
bm.free()
|
| 429 |
+
ob = bpy.data.objects.new('徽派民居', me)
|
| 430 |
+
for key in ('wall', 'roof', 'dark'):
|
| 431 |
+
ob.data.materials.append(M[key])
|
| 432 |
+
coll.objects.link(ob)
|
| 433 |
+
# window panes as one emissive mesh
|
| 434 |
+
bmw = bmesh.new()
|
| 435 |
+
for (m, x, y, z, h) in windows:
|
| 436 |
+
add_box(bmw, x, y, z, 1.1, 1.0, 0.04, 0, m)
|
| 437 |
+
mw = bpy.data.meshes.new('窗灯')
|
| 438 |
+
bmw.to_mesh(mw)
|
| 439 |
+
bmw.free()
|
| 440 |
+
ow = bpy.data.objects.new('窗灯', mw)
|
| 441 |
+
ow.data.materials.append(M['window'])
|
| 442 |
+
coll.objects.link(ow)
|
| 443 |
+
return houses, lanterns
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def build_bridge(M, coll):
|
| 447 |
+
s = river_s_at_x(40)
|
| 448 |
+
x, z, tx, tz, nx, nz = river_point(s, 0)
|
| 449 |
+
w = river_half_width(s)
|
| 450 |
+
R = w - 1.6
|
| 451 |
+
bpy.ops.mesh.primitive_cube_add(size=1)
|
| 452 |
+
body = bpy.context.active_object
|
| 453 |
+
body.name = '石拱桥'
|
| 454 |
+
body.scale = (2 * (w + 9), 4.4, R + 3.0)
|
| 455 |
+
body.location = (0, 0, (R + 3.0) / 2 - 2.2)
|
| 456 |
+
bpy.ops.mesh.primitive_cylinder_add(radius=R, depth=6, vertices=96)
|
| 457 |
+
hole = bpy.context.active_object
|
| 458 |
+
hole.rotation_euler = (math.pi / 2, 0, 0)
|
| 459 |
+
hole.location = (0, 0, -0.35)
|
| 460 |
+
mod = body.modifiers.new('拱洞', 'BOOLEAN')
|
| 461 |
+
mod.object = hole
|
| 462 |
+
mod.operation = 'DIFFERENCE'
|
| 463 |
+
bpy.context.view_layer.objects.active = body
|
| 464 |
+
bpy.ops.object.modifier_apply(modifier=mod.name)
|
| 465 |
+
bpy.data.objects.remove(hole, do_unlink=True)
|
| 466 |
+
# hump the deck: simple deform via vertex z offset
|
| 467 |
+
for v in body.data.vertices:
|
| 468 |
+
if v.co.z > 0:
|
| 469 |
+
fx = min(1.0, abs(v.co.x * body.scale.x) / (w + 9))
|
| 470 |
+
v.co.z += (1 - fx * fx) * 0.25
|
| 471 |
+
body.data.materials.append(M['stone'])
|
| 472 |
+
for c in body.users_collection:
|
| 473 |
+
c.objects.unlink(body)
|
| 474 |
+
coll.objects.link(body)
|
| 475 |
+
body.rotation_euler = (0, 0, math.atan2(-nz, nx) * -1 + 0)
|
| 476 |
+
ang = math.atan2(nz, nx)
|
| 477 |
+
body.rotation_euler = (0, 0, -ang)
|
| 478 |
+
body.location = W2B(x, 0, z) + Vector((0, 0, body.location.z))
|
| 479 |
+
return body
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def build_wheel(M, coll):
|
| 483 |
+
s = river_s_at_x(-196)
|
| 484 |
+
w = river_half_width(s)
|
| 485 |
+
x, z, tx, tz, nx, nz = river_point(s, w - 3.0)
|
| 486 |
+
bpy.ops.object.empty_add(type='PLAIN_AXES', location=W2B(x, 1.25, z))
|
| 487 |
+
pivot = bpy.context.active_object
|
| 488 |
+
pivot.name = '水车_轴心'
|
| 489 |
+
for c in pivot.users_collection:
|
| 490 |
+
c.objects.unlink(pivot)
|
| 491 |
+
coll.objects.link(pivot)
|
| 492 |
+
parts = []
|
| 493 |
+
for zs in (-0.55, 0.55):
|
| 494 |
+
bpy.ops.mesh.primitive_torus_add(major_radius=3.3, minor_radius=0.09, major_segments=64, minor_segments=6)
|
| 495 |
+
t = bpy.context.active_object
|
| 496 |
+
t.rotation_euler = (math.pi / 2, 0, 0)
|
| 497 |
+
t.location = (0, zs, 0)
|
| 498 |
+
parts.append(t)
|
| 499 |
+
for k in range(18):
|
| 500 |
+
a = k / 18 * math.tau
|
| 501 |
+
bpy.ops.mesh.primitive_cube_add(size=1)
|
| 502 |
+
p = bpy.context.active_object
|
| 503 |
+
p.scale = (0.62, 1.3, 0.05)
|
| 504 |
+
p.location = (math.cos(a) * 3.35, 0, math.sin(a) * 3.35)
|
| 505 |
+
p.rotation_euler = (0, -a, 0)
|
| 506 |
+
parts.append(p)
|
| 507 |
+
bpy.ops.mesh.primitive_cube_add(size=1)
|
| 508 |
+
sp = bpy.context.active_object
|
| 509 |
+
sp.scale = (3.3, 0.1, 0.1)
|
| 510 |
+
sp.location = (math.cos(a) * 1.65, 0.55 if k % 2 else -0.55, math.sin(a) * 1.65)
|
| 511 |
+
sp.rotation_euler = (0, -a, 0)
|
| 512 |
+
parts.append(sp)
|
| 513 |
+
for p in parts:
|
| 514 |
+
p.data.materials.append(M['wood'])
|
| 515 |
+
for c in p.users_collection:
|
| 516 |
+
c.objects.unlink(p)
|
| 517 |
+
coll.objects.link(p)
|
| 518 |
+
p.parent = pivot
|
| 519 |
+
pivot.rotation_euler = (0, 0, -math.atan2(nz, nx))
|
| 520 |
+
# rotation animation: -0.42 rad/s about the axle (same speed as the web version)
|
| 521 |
+
pivot.rotation_mode = 'XYZ'
|
| 522 |
+
pivot.keyframe_insert('rotation_euler', frame=1)
|
| 523 |
+
pivot.rotation_euler.rotate_axis('Y', 0.42 * DURATION)
|
| 524 |
+
pivot.keyframe_insert('rotation_euler', frame=int(DURATION * FPS) + 1)
|
| 525 |
+
for fc in pivot.animation_data.action.fcurves:
|
| 526 |
+
for kp in fc.keyframe_points:
|
| 527 |
+
kp.interpolation = 'LINEAR'
|
| 528 |
+
return pivot
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def build_camphor(M, coll):
|
| 532 |
+
tx, tz = river_point(river_s_at_x(-300), -34)[:2]
|
| 533 |
+
rnd = random.Random(777)
|
| 534 |
+
bm_bark, bm_leaf = bmesh.new(), bmesh.new()
|
| 535 |
+
|
| 536 |
+
def cyl(bm, a, b, r0, r1):
|
| 537 |
+
d = b - a
|
| 538 |
+
L = d.length
|
| 539 |
+
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=10, radius1=r0, radius2=r1, depth=L)
|
| 540 |
+
q = Vector((0, 0, 1)).rotation_difference(d.normalized())
|
| 541 |
+
bmesh.ops.rotate(bm, verts=res['verts'], cent=(0, 0, 0), matrix=q.to_matrix())
|
| 542 |
+
bmesh.ops.translate(bm, verts=res['verts'], vec=a + d * 0.5)
|
| 543 |
+
|
| 544 |
+
def branch(p, d, L, r, depth):
|
| 545 |
+
for i in range(3 if depth else 5):
|
| 546 |
+
d = (d + Vector((rnd.uniform(-0.28, 0.28), rnd.uniform(-0.28, 0.28), rnd.uniform(-0.1, 0.25)))).normalized()
|
| 547 |
+
np_ = p + d * (L / 3)
|
| 548 |
+
cyl(bm_bark, p, np_, r, r * 0.82)
|
| 549 |
+
p, r = np_, r * 0.82
|
| 550 |
+
if depth >= 4:
|
| 551 |
+
res = bmesh.ops.create_icosphere(bm_leaf, subdivisions=2, radius=rnd.uniform(1.6, 2.4))
|
| 552 |
+
bmesh.ops.translate(bm_leaf, verts=res['verts'], vec=p)
|
| 553 |
+
return
|
| 554 |
+
for k in range(rnd.randint(2, 4)):
|
| 555 |
+
az = k / 3 * math.tau + rnd.random()
|
| 556 |
+
nd = (d + Vector((math.cos(az), math.sin(az), 0)) * 0.9).normalized()
|
| 557 |
+
branch(p, nd, L * 0.72, r * 0.72, depth + 1)
|
| 558 |
+
|
| 559 |
+
base = W2B(tx, GROUND_Y - 0.4, tz)
|
| 560 |
+
branch(base, Vector((0, 0, 1)), 24 * 0.32, 1.25, 0)
|
| 561 |
+
for name, bm, mat in (('古樟_树干', bm_bark, M['bark']), ('古樟_树冠', bm_leaf, M['leaf'])):
|
| 562 |
+
me = bpy.data.meshes.new(name)
|
| 563 |
+
bm.to_mesh(me)
|
| 564 |
+
bm.free()
|
| 565 |
+
ob = bpy.data.objects.new(name, me)
|
| 566 |
+
ob.data.materials.append(mat)
|
| 567 |
+
coll.objects.link(ob)
|
| 568 |
+
if name.endswith('树冠'):
|
| 569 |
+
disp = ob.modifiers.new('叶团起伏', 'DISPLACE')
|
| 570 |
+
tex = bpy.data.textures.new('叶团噪声', 'CLOUDS')
|
| 571 |
+
tex.noise_scale = 0.8
|
| 572 |
+
disp.texture = tex
|
| 573 |
+
disp.strength = 0.9
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def build_bamboo(M, coll, count=380):
|
| 577 |
+
rnd = random.Random(31)
|
| 578 |
+
bm = bmesh.new()
|
| 579 |
+
placed = 0
|
| 580 |
+
tries = 0
|
| 581 |
+
while placed < count and tries < 20000:
|
| 582 |
+
tries += 1
|
| 583 |
+
x, z = rnd.uniform(-520, 560), rnd.uniform(-420, 380)
|
| 584 |
+
d, s = river_query(x, z)
|
| 585 |
+
if d < 60 or fbm(x * 0.012, z * 0.012, 3, 61) < 0.52:
|
| 586 |
+
continue
|
| 587 |
+
y = terrain_height(x, z)
|
| 588 |
+
for c in range(6):
|
| 589 |
+
a, r = rnd.random() * math.tau, rnd.random() * 1.2
|
| 590 |
+
p = W2B(x + math.cos(a) * r, y, z + math.sin(a) * r)
|
| 591 |
+
h = rnd.uniform(9, 14)
|
| 592 |
+
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=5, radius1=0.05, radius2=0.025, depth=h)
|
| 593 |
+
bmesh.ops.translate(bm, verts=res['verts'], vec=p + Vector((0, 0, h / 2)))
|
| 594 |
+
res = bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1.4)
|
| 595 |
+
bmesh.ops.scale(bm, verts=res['verts'], vec=(1, 1, 2.2))
|
| 596 |
+
bmesh.ops.translate(bm, verts=res['verts'], vec=p + Vector((0, 0, h * 0.8)))
|
| 597 |
+
placed += 1
|
| 598 |
+
me = bpy.data.meshes.new('竹林')
|
| 599 |
+
bm.to_mesh(me)
|
| 600 |
+
bm.free()
|
| 601 |
+
ob = bpy.data.objects.new('竹林', me)
|
| 602 |
+
ob.data.materials.append(M['bamboo'])
|
| 603 |
+
coll.objects.link(ob)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def build_lanterns(M, coll, lanterns):
|
| 607 |
+
"""Lanterns light one after another along the market street from 58.4 s."""
|
| 608 |
+
first_s = river_s_at_x(-128)
|
| 609 |
+
objs = []
|
| 610 |
+
for i, (p, s, side) in enumerate(lanterns):
|
| 611 |
+
f = min(1, max(0, (s - first_s) / (VILLAGE_S1 - first_s)))
|
| 612 |
+
on_t = 58.4 + 0.9 + 7 * f + (1.2 if side > 0 else 0) + (i % 7) * 0.07
|
| 613 |
+
mat = M['lantern'].copy()
|
| 614 |
+
mat.name = f'灯笼_{i:03d}'
|
| 615 |
+
bsdf = mat.node_tree.nodes.get('Principled BSDF')
|
| 616 |
+
sock = bsdf.inputs['Emission Strength']
|
| 617 |
+
sock.default_value = 0.0
|
| 618 |
+
sock.keyframe_insert('default_value', frame=max(1, int(on_t * FPS) - 1))
|
| 619 |
+
sock.default_value = 18.0
|
| 620 |
+
sock.keyframe_insert('default_value', frame=int(on_t * FPS) + 6)
|
| 621 |
+
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.3, segments=16, ring_count=10, location=W2B(p.x, p.y - 0.42, p.z))
|
| 622 |
+
ob = bpy.context.active_object
|
| 623 |
+
ob.scale = (1, 1, 1.25)
|
| 624 |
+
ob.name = f'灯笼_{i:03d}'
|
| 625 |
+
ob.data.materials.append(mat)
|
| 626 |
+
for c in ob.users_collection:
|
| 627 |
+
c.objects.unlink(ob)
|
| 628 |
+
coll.objects.link(ob)
|
| 629 |
+
# a small warm point light per lantern (Cycles handles hundreds fine)
|
| 630 |
+
ld = bpy.data.lights.new(f'灯光_{i:03d}', 'POINT')
|
| 631 |
+
ld.color = (1.0, 0.55, 0.25)
|
| 632 |
+
ld.shadow_soft_size = 0.3
|
| 633 |
+
ld.energy = 0
|
| 634 |
+
ld.keyframe_insert('energy', frame=max(1, int(on_t * FPS) - 1))
|
| 635 |
+
ld.energy = 40
|
| 636 |
+
ld.keyframe_insert('energy', frame=int(on_t * FPS) + 6)
|
| 637 |
+
lo = bpy.data.objects.new(f'灯光_{i:03d}', ld)
|
| 638 |
+
lo.location = W2B(p.x, p.y - 0.42, p.z)
|
| 639 |
+
coll.objects.link(lo)
|
| 640 |
+
objs.append(ob)
|
| 641 |
+
# windows glow later
|
| 642 |
+
w = bpy.data.materials.get('窗纸')
|
| 643 |
+
sock = w.node_tree.nodes.get('Principled BSDF').inputs['Emission Strength']
|
| 644 |
+
sock.default_value = 0
|
| 645 |
+
sock.keyframe_insert('default_value', frame=int(58.5 * FPS))
|
| 646 |
+
sock.default_value = 6
|
| 647 |
+
sock.keyframe_insert('default_value', frame=int(70 * FPS))
|
| 648 |
+
return objs
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
SUN_KEYS = [(0, 8.5), (17, 6.6), (32, 4.6), (44, 2.6), (52, 0.9), (57, -0.8), (62, -2.8), (68, -5.2), (76, -7.6), (86, -9.8)]
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def build_sky_and_sun(scene):
|
| 655 |
+
world = bpy.data.worlds.new('暮色天空')
|
| 656 |
+
scene.world = world
|
| 657 |
+
world.use_nodes = True
|
| 658 |
+
nt = world.node_tree
|
| 659 |
+
bg = nt.nodes.get('Background')
|
| 660 |
+
sky = nt.nodes.new('ShaderNodeTexSky')
|
| 661 |
+
sky.sky_type = 'NISHITA'
|
| 662 |
+
sky.sun_rotation = math.pi / 2 + 0.2 + math.pi / 2 # sets toward west-north-west
|
| 663 |
+
sky.air_density = 1.2
|
| 664 |
+
sky.dust_density = 2.5
|
| 665 |
+
nt.links.new(sky.outputs['Color'], bg.inputs['Color'])
|
| 666 |
+
# twilight layer: deep blue dome with a warm afterglow band, faded in after sunset
|
| 667 |
+
tw = nt.nodes.new('ShaderNodeBackground')
|
| 668 |
+
grad_tc = nt.nodes.new('ShaderNodeTexCoord')
|
| 669 |
+
sep = nt.nodes.new('ShaderNodeSeparateXYZ')
|
| 670 |
+
nt.links.new(grad_tc.outputs['Generated'], sep.inputs['Vector'])
|
| 671 |
+
ramp = nt.nodes.new('ShaderNodeValToRGB')
|
| 672 |
+
ramp.color_ramp.elements[0].position = 0.5
|
| 673 |
+
ramp.color_ramp.elements[0].color = (0.35, 0.16, 0.1, 1)
|
| 674 |
+
ramp.color_ramp.elements[1].position = 0.62
|
| 675 |
+
ramp.color_ramp.elements[1].color = (0.02, 0.035, 0.1, 1)
|
| 676 |
+
nt.links.new(sep.outputs['Z'], ramp.inputs['Fac'])
|
| 677 |
+
nt.links.new(ramp.outputs['Color'], tw.inputs['Color'])
|
| 678 |
+
add = nt.nodes.new('ShaderNodeAddShader')
|
| 679 |
+
nt.links.new(bg.outputs['Background'], add.inputs[0])
|
| 680 |
+
nt.links.new(tw.outputs['Background'], add.inputs[1])
|
| 681 |
+
nt.links.new(add.outputs['Shader'], nt.nodes.get('World Output').inputs['Surface'])
|
| 682 |
+
sun_data = bpy.data.lights.new('太阳', 'SUN')
|
| 683 |
+
sun_data.angle = math.radians(1.2)
|
| 684 |
+
sun = bpy.data.objects.new('太阳', sun_data)
|
| 685 |
+
scene.collection.objects.link(sun)
|
| 686 |
+
moon_data = bpy.data.lights.new('月光', 'SUN')
|
| 687 |
+
moon_data.color = (0.6, 0.7, 1.0)
|
| 688 |
+
moon_data.energy = 0.0
|
| 689 |
+
moon = bpy.data.objects.new('月光', moon_data)
|
| 690 |
+
moon.rotation_euler = (math.radians(66), 0, math.radians(-110))
|
| 691 |
+
scene.collection.objects.link(moon)
|
| 692 |
+
for t, e in SUN_KEYS:
|
| 693 |
+
f = int(t * FPS) + 1
|
| 694 |
+
er = math.radians(e)
|
| 695 |
+
# Nishita is undefined below the horizon (can return NaN = black frames): clamp it
|
| 696 |
+
# and let the twilight layer + exposure carry the blue hour instead.
|
| 697 |
+
sky.sun_elevation = max(er, math.radians(0.4))
|
| 698 |
+
sky.keyframe_insert('sun_elevation', frame=f)
|
| 699 |
+
sun.rotation_euler = (math.pi / 2 - er, 0, -(math.pi / 2 + 0.2 + math.pi / 2))
|
| 700 |
+
sun.keyframe_insert('rotation_euler', frame=f)
|
| 701 |
+
sun_data.energy = max(0.0, 4.0 * smoothstep(-1.2, 6, e))
|
| 702 |
+
sun_data.color = (1.0, lerp(0.38, 0.76, smoothstep(0, 9, e)), lerp(0.14, 0.52, smoothstep(0, 9, e)))
|
| 703 |
+
sun_data.keyframe_insert('energy', frame=f)
|
| 704 |
+
sun_data.keyframe_insert('color', frame=f)
|
| 705 |
+
bg.inputs['Strength'].default_value = lerp(0.0, 1.0, smoothstep(-5, 2, e))
|
| 706 |
+
bg.inputs['Strength'].keyframe_insert('default_value', frame=f)
|
| 707 |
+
moon_data.energy = 0.35 * smoothstep(-1.5, -8.5, e)
|
| 708 |
+
moon_data.keyframe_insert('energy', frame=f)
|
| 709 |
+
tw.inputs['Strength'].default_value = 0.6 * smoothstep(1.0, -4.0, e)
|
| 710 |
+
tw.inputs['Strength'].keyframe_insert('default_value', frame=f)
|
| 711 |
+
# camera eye adaptation, like the web version
|
| 712 |
+
scene.view_settings.exposure = lerp(0.0, 3.2, smoothstep(1, -9, e))
|
| 713 |
+
scene.view_settings.keyframe_insert('exposure', frame=f)
|
| 714 |
+
# thin evening mist: a bounded volume over the valley (a world volume would swallow the sky)
|
| 715 |
+
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 30))
|
| 716 |
+
mist = bpy.context.active_object
|
| 717 |
+
mist.name = '暮霭'
|
| 718 |
+
mist.scale = (2400, 2400, 60)
|
| 719 |
+
mm = bpy.data.materials.new('暮霭')
|
| 720 |
+
mm.use_nodes = True
|
| 721 |
+
nt2 = mm.node_tree
|
| 722 |
+
nt2.nodes.remove(nt2.nodes.get('Principled BSDF'))
|
| 723 |
+
vol = nt2.nodes.new('ShaderNodeVolumePrincipled')
|
| 724 |
+
vol.inputs['Density'].default_value = 0.0012
|
| 725 |
+
vol.inputs['Color'].default_value = (0.85, 0.8, 0.9, 1)
|
| 726 |
+
nt2.links.new(vol.outputs['Volume'], nt2.nodes.get('Material Output').inputs['Volume'])
|
| 727 |
+
mist.data.materials.append(mm)
|
| 728 |
+
mist.visible_shadow = False
|
| 729 |
+
vol.inputs['Density'].keyframe_insert('default_value', frame=1)
|
| 730 |
+
vol.inputs['Density'].default_value = 0.0026
|
| 731 |
+
vol.inputs['Density'].keyframe_insert('default_value', frame=int(80 * FPS))
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
SHOT_TIMES = [(1, 0), (2, 9), (3, 17), (4, 25), (5, 32), (6, 40), (7, 48), (8, 55), (9, 65), (10, 74)]
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def build_cameras(scene, coll):
|
| 738 |
+
"""Ten shots. Key poses (web coordinates) mirror camera.ts; markers switch the active camera."""
|
| 739 |
+
bs = river_s_at_x(40)
|
| 740 |
+
bx, bz, btx, btz, bnx, bnz = river_point(bs, 0)
|
| 741 |
+
ws = river_s_at_x(-196)
|
| 742 |
+
wx, wz, wtx, wtz, wnx, wnz = river_point(ws, river_half_width(ws) - 3)
|
| 743 |
+
tx, tz = river_point(river_s_at_x(-300), -34)[:2]
|
| 744 |
+
def rp(s_, off, y):
|
| 745 |
+
x_, z_ = river_point(s_, off)[:2]
|
| 746 |
+
return (x_, y, z_)
|
| 747 |
+
|
| 748 |
+
def qp(s_, lane, y): # point on the market quay, same as quayPoint() in layout.ts
|
| 749 |
+
return rp(s_, -(11 + lane), y)
|
| 750 |
+
|
| 751 |
+
shots = {
|
| 752 |
+
1: ((470, 132, 150), (-1100, 60, 160), (310, 70, 82), (-1000, 30, 130), 38),
|
| 753 |
+
2: (rp(bs + 150, 1.2, 2.0), rp(bs + 116, 0, 2.3), rp(bs + 62, 1.2, 2.0), rp(bs + 28, 0, 2.3), 46),
|
| 754 |
+
3: ((bx + btx * 40 - bnx * 3.2, 1.05, bz + btz * 40 - bnz * 3.2), (bx, 3.1, bz), (bx + btx * 33 + bnx * 2.6, 1.05, bz + btz * 33 + bnz * 2.6), (bx, 3.1, bz), 34),
|
| 755 |
+
4: ((wx - wnx * 15.5 + wtx * 7, 2.9, wz - wnz * 15.5 + wtz * 7), (wx, 2.2, wz), (wx - wnx * 12.2 + wtx * 3.2, 2.4, wz - wnz * 12.2 + wtz * 3.2), (wx, 2.2, wz), 40),
|
| 756 |
+
5: (qp(river_s_at_x(-175) + 3.8 + 15, -1.1, GROUND_Y + 1.75), qp(river_s_at_x(-175) + 3.8 - 6, 1.0, GROUND_Y + 1.6),
|
| 757 |
+
qp(river_s_at_x(-175) + 11.4 + 12.5, -1.1, GROUND_Y + 1.75), qp(river_s_at_x(-175) + 11.4 - 6, 1.0, GROUND_Y + 1.6), 44),
|
| 758 |
+
6: ((tx + 25, 3.5, tz + 16.5), (tx - 2, 9.7, tz - 4), (tx + 19, 4.4, tz + 12.5), (tx - 2, 11.7, tz - 4), 46),
|
| 759 |
+
7: ((110, 26, -120), (300, 140, -410), (98, 30, -126), (205, 70, -372), 21),
|
| 760 |
+
8: (qp(river_s_at_x(-150), -0.8, GROUND_Y + 2.6), qp(river_s_at_x(-150) + 26, -1.6, GROUND_Y + 2.4),
|
| 761 |
+
qp(river_s_at_x(-150) + 21, -0.8, GROUND_Y + 2.6), qp(river_s_at_x(-150) + 47, -1.6, GROUND_Y + 2.4), 48),
|
| 762 |
+
9: (rp(bs + 52, -4.6, 1.25), (bx + bnx * 1.5, 3.4, bz + bnz * 1.5), rp(bs + 45, -4.6, 1.25), (bx + bnx * 1.5, 3.4, bz + bnz * 1.5), 42),
|
| 763 |
+
10: ((95, 9, 34), (-10, 3, -4), (250, 125, 205), (-520, 60, -230), 44),
|
| 764 |
+
}
|
| 765 |
+
cams = {}
|
| 766 |
+
for i, (sid, t0) in enumerate(SHOT_TIMES):
|
| 767 |
+
t1 = SHOT_TIMES[i + 1][1] if i + 1 < len(SHOT_TIMES) else DURATION
|
| 768 |
+
p0, a0, p1, a1, fov = shots[sid]
|
| 769 |
+
cd = bpy.data.cameras.new(f'镜头{sid:02d}')
|
| 770 |
+
cd.lens_unit = 'FOV'
|
| 771 |
+
cd.angle = math.radians(fov * 16 / 9 * 0.92)
|
| 772 |
+
cd.sensor_fit = 'HORIZONTAL'
|
| 773 |
+
cam = bpy.data.objects.new(f'镜头{sid:02d}', cd)
|
| 774 |
+
coll.objects.link(cam)
|
| 775 |
+
tgt = bpy.data.objects.new(f'镜头{sid:02d}_目标', None)
|
| 776 |
+
coll.objects.link(tgt)
|
| 777 |
+
con = cam.constraints.new('TRACK_TO')
|
| 778 |
+
con.target = tgt
|
| 779 |
+
con.track_axis = 'TRACK_NEGATIVE_Z'
|
| 780 |
+
con.up_axis = 'UP_Y'
|
| 781 |
+
for (p, a), t in (((p0, a0), t0), ((p1, a1), t1)):
|
| 782 |
+
cam.location = W2B(*p)
|
| 783 |
+
tgt.location = W2B(*a)
|
| 784 |
+
cam.keyframe_insert('location', frame=int(t * FPS) + 1)
|
| 785 |
+
tgt.keyframe_insert('location', frame=int(t * FPS) + 1)
|
| 786 |
+
m = scene.timeline_markers.new(f'S{sid:02d}', frame=int(t0 * FPS) + 1)
|
| 787 |
+
m.camera = cam
|
| 788 |
+
cams[sid] = cam
|
| 789 |
+
scene.camera = cams[1]
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def setup_render(scene):
|
| 793 |
+
scene.render.fps = FPS
|
| 794 |
+
scene.frame_start = 1
|
| 795 |
+
scene.frame_end = int(DURATION * FPS)
|
| 796 |
+
scene.render.resolution_x = 1920
|
| 797 |
+
scene.render.resolution_y = 1080
|
| 798 |
+
try:
|
| 799 |
+
scene.render.engine = RENDER_ENGINE
|
| 800 |
+
except TypeError:
|
| 801 |
+
scene.render.engine = 'CYCLES'
|
| 802 |
+
if scene.render.engine == 'CYCLES':
|
| 803 |
+
scene.cycles.samples = 128
|
| 804 |
+
scene.cycles.use_denoising = True
|
| 805 |
+
scene.cycles.max_bounces = 6
|
| 806 |
+
scene.view_settings.view_transform = 'AgX' if 'AgX' in [i.identifier for i in scene.view_settings.bl_rna.properties['view_transform'].enum_items] else 'Filmic'
|
| 807 |
+
scene.view_settings.look = 'None'
|
| 808 |
+
scene.render.image_settings.file_format = 'FFMPEG'
|
| 809 |
+
scene.render.ffmpeg.format = 'MPEG4'
|
| 810 |
+
scene.render.ffmpeg.codec = 'H264'
|
| 811 |
+
scene.render.ffmpeg.constant_rate_factor = 'HIGH'
|
| 812 |
+
scene.render.filepath = '//暮归江南_blender_'
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def main():
|
| 816 |
+
clear_scene()
|
| 817 |
+
scene = bpy.context.scene
|
| 818 |
+
M = build_materials()
|
| 819 |
+
land = collection('地形与河流')
|
| 820 |
+
town = collection('村落')
|
| 821 |
+
life = collection('灯火')
|
| 822 |
+
cams = collection('镜头')
|
| 823 |
+
print('[暮归江南] 地形…')
|
| 824 |
+
build_terrain(M, land)
|
| 825 |
+
build_river(M, land)
|
| 826 |
+
print('[暮归江南] 民居…')
|
| 827 |
+
houses, lanterns = build_houses(M, town)
|
| 828 |
+
print('[暮归江南] 石桥、水车、��樟、竹林…')
|
| 829 |
+
build_bridge(M, town)
|
| 830 |
+
build_wheel(M, town)
|
| 831 |
+
build_camphor(M, town)
|
| 832 |
+
build_bamboo(M, land)
|
| 833 |
+
print('[暮归江南] 灯笼…')
|
| 834 |
+
build_lanterns(M, life, lanterns)
|
| 835 |
+
build_sky_and_sun(scene)
|
| 836 |
+
build_cameras(scene, cams)
|
| 837 |
+
setup_render(scene)
|
| 838 |
+
print(f'[暮归江南] 完成:{len(houses)} 栋民居,{len(lanterns)} 盏灯笼,{int(DURATION * FPS)} 帧。')
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
if __name__ == '__main__':
|
| 842 |
+
main()
|
src/jiangnan/copy/platforms.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Publishing copy for each platform (all original). Limits are validated in the UI.
|
| 2 |
+
export interface PlatformCopy {
|
| 3 |
+
id: string
|
| 4 |
+
name: string
|
| 5 |
+
titleMax: number
|
| 6 |
+
tagMax: number | null // null = unlimited
|
| 7 |
+
tagStyle: 'hash' | 'plain' // plain = B站 tag field (no #)
|
| 8 |
+
titles: string[]
|
| 9 |
+
desc: string
|
| 10 |
+
tags: string[]
|
| 11 |
+
note: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export const PLATFORMS: PlatformCopy[] = [
|
| 15 |
+
{
|
| 16 |
+
id: 'xhs', name: '小红书', titleMax: 20, tagMax: 10, tagStyle: 'hash',
|
| 17 |
+
titles: [
|
| 18 |
+
'日落时分,我在江南水乡等灯亮',
|
| 19 |
+
'一座会发光的江南小村庄',
|
| 20 |
+
'江南的黄昏,原来这么慢',
|
| 21 |
+
'粉墙黛瓦,一盏一盏亮起来',
|
| 22 |
+
],
|
| 23 |
+
desc: '把一整个江南的黄昏装进 86 秒:\n夕阳贴着远山,小河绕村而过,石拱桥和倒影合成一轮满月,老水车吱呀转着,集市的竹篮和腊肉都被染成金色。\n天一点点暗下来,炊烟升起,红灯笼从街头一盏接一盏亮到街尾,灯火落进河里,最后整座村子睡在星光和薄雾里。\n\n全片是原创 3D 动画,配乐、音效、旁白都是专门做的。戴上耳机看会更有感觉。',
|
| 24 |
+
tags: ['江南水乡', '治愈系风景', '日落', '古村落', '国风', '粉墙黛瓦', '小桥流水人家', '3D动画', '氛围感', '江南'],
|
| 25 |
+
note: '标题 ≤20 字,话题 ≤10 个',
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
id: 'douyin', name: '抖音', titleMax: 30, tagMax: 5, tagStyle: 'hash',
|
| 29 |
+
titles: [
|
| 30 |
+
'江南的黄昏来得很慢,看一座村庄一盏一盏亮起灯火',
|
| 31 |
+
'86秒,看完江南水乡从日落到星夜',
|
| 32 |
+
'石桥半个月亮,水里再补半个,这就是江南',
|
| 33 |
+
],
|
| 34 |
+
desc: '夕阳、小河、石拱桥、老水车、红灯笼。\n从日落到星夜,一座江南水乡慢慢亮起来。原创3D动画,建议戴耳机。',
|
| 35 |
+
tags: ['江南水乡', '治愈系', '国风动画', '日落', '古镇'],
|
| 36 |
+
note: '标题 ≤30 字,话题 ≤5 个',
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
id: 'channels', name: '微信视频号', titleMax: 16, tagMax: null, tagStyle: 'hash',
|
| 40 |
+
titles: [
|
| 41 |
+
'江南水乡,一盏一盏亮起来',
|
| 42 |
+
'暮归江南:日落到星夜',
|
| 43 |
+
'江南的黄昏,总是来得很慢',
|
| 44 |
+
],
|
| 45 |
+
desc: '一条小河把村子揽在怀里,石桥、水车、集市、古樟,夕阳落下后,灯笼一盏接一盏亮起。\n原创3D动画《暮归江南》,送给想家的人。',
|
| 46 |
+
tags: ['江南水乡', '暮归江南', '治愈', '日落', '古村落', '国风', '粉墙黛瓦', '小桥流水', '乡愁', '原创动画', '风景', '星空'],
|
| 47 |
+
note: '标题 ≤16 字,话题不限',
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
id: 'bili', name: '哔哩哔哩', titleMax: 80, tagMax: 10, tagStyle: 'plain',
|
| 51 |
+
titles: [
|
| 52 |
+
'【原创3D动画】暮归江南:日落时分的江南水乡,从夕阳到星夜的86秒|粉墙黛瓦·石拱桥·老水车·红灯笼',
|
| 53 |
+
'我用代码搭了一座江南水乡,看它从日落一直亮到星夜(原创配乐+旁白)',
|
| 54 |
+
'【治愈向】86秒的江南黄昏:石桥倒影成满月,灯笼一盏接一盏亮起',
|
| 55 |
+
],
|
| 56 |
+
desc: '《暮归江南》是一部原创 3D 动画短片:一座日落时分的江南水乡,从金色夕阳慢慢过渡到蓝色夜幕,灯笼和窗灯依次亮起,最后整座村庄在星光与暮霭里安静发光。\n\n画面:全部程序化建模与实时渲染(徽派民居、马头墙、石拱桥、水车、乌篷船、集市、古樟、梯田、竹林)\n声音:原创五声音阶配乐(古筝、竹箫、弦乐、编钟),环境音效与中文旁白\n\n00:00 山谷航拍\n00:09 贴水滑行\n00:17 石拱桥\n00:25 老水车\n00:32 石板街集市\n00:40 村口古樟\n00:48 梯田竹林\n00:55 灯笼次第亮起\n01:05 夜河灯影\n01:14 星夜全景',
|
| 57 |
+
tags: ['原创动画', '3D动画', '江南水乡', '国风', '治愈', '风景', '日落', '古村落', 'Blender', '程序化生成'],
|
| 58 |
+
note: '标题 ≤80 字,标签 ≤10 个',
|
| 59 |
+
},
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
export interface LiveCopy { id: string; name: string; titleMax: number; tagMax: number; titles: string[]; body: string; tags: string[] }
|
| 63 |
+
|
| 64 |
+
export const LIVE_COPY: LiveCopy[] = [
|
| 65 |
+
{
|
| 66 |
+
id: 'xhs', name: '小红书 · 实况图文', titleMax: 20, tagMax: 10,
|
| 67 |
+
titles: ['长按看江南水乡亮灯的瞬间', '6张实况|江南的黄昏会动', '江南水乡,日落到星夜'],
|
| 68 |
+
body: '6 张实况,长按会动:\n① 石桥夕照,拱桥和倒影合成满月\n② 老水车转着,把光阴舀回河里\n③ 古樟树下,蓝印花布随风摆\n④ 梯田和竹林,白鹭飞回去\n⑤ 第一盏灯笼亮起\n⑥ 星夜里的整座村庄\n\n原创 3D 动画截取,喜欢可以设成壁纸。',
|
| 69 |
+
tags: ['实况图', '江南水乡', '治愈系风景', '壁纸', '日落', '古村落', '国风', '粉墙黛瓦', 'livephoto', '氛围感'],
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
id: 'douyin', name: '抖音 · 图文', titleMax: 30, tagMax: 5,
|
| 73 |
+
titles: ['江南水乡6张实况,长按看灯笼一盏盏亮起', '把江南的黄昏做成了会动的照片'],
|
| 74 |
+
body: '石桥、水车��古樟、梯田、灯笼、星夜。\n6 张实况,长按会动,原创 3D 动画截取。',
|
| 75 |
+
tags: ['实况图', '江南水乡', '治愈系', '壁纸', '国风'],
|
| 76 |
+
},
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
export const chars = (s: string) => Array.from(s).length
|
| 80 |
+
|
| 81 |
+
export function formatTags(tags: string[], style: 'hash' | 'plain') {
|
| 82 |
+
return style === 'hash' ? tags.map((t) => '#' + t).join(' ') : tags.join(',')
|
| 83 |
+
}
|
src/jiangnan/engine/Film.ts
CHANGED
|
@@ -235,6 +235,7 @@ export class Film {
|
|
| 235 |
|
| 236 |
/** Render the frame at film time t with the current canvas size. */
|
| 237 |
renderFrame(t: number) {
|
|
|
|
| 238 |
const st = this.update(t)
|
| 239 |
const pose = cameraAt(t, this.width / this.height)
|
| 240 |
applyPose(this.camera, pose)
|
|
|
|
| 235 |
|
| 236 |
/** Render the frame at film time t with the current canvas size. */
|
| 237 |
renderFrame(t: number) {
|
| 238 |
+
if (!this.sky || !this.water || !this.life) return
|
| 239 |
const st = this.update(t)
|
| 240 |
const pose = cameraAt(t, this.width / this.height)
|
| 241 |
applyPose(this.camera, pose)
|
src/jiangnan/engine/camera.ts
CHANGED
|
@@ -83,7 +83,7 @@ const shots: Record<number, ShotFn> = {
|
|
| 83 |
6: (u, _t, p) => {
|
| 84 |
const e = easeSine(u)
|
| 85 |
const tp = V(TREE_POS.x, GROUND_Y, TREE_POS.z)
|
| 86 |
-
const pos = tp.clone().add(mix(V(
|
| 87 |
const target = tp.clone().add(V(-2, mix(V(0, 8, 0), V(0, 10, 0), e).y, -4))
|
| 88 |
if (p) { pos.add(V(6, 0.5, 5)); target.y += 3 }
|
| 89 |
return { pos, target, fov: 46 }
|
|
@@ -121,8 +121,8 @@ const shots: Record<number, ShotFn> = {
|
|
| 121 |
// 10 · crane up and pull back over the glowing village under the first stars
|
| 122 |
10: (u, _t, p) => {
|
| 123 |
const e = easeInOut(clamp(u * 1.05))
|
| 124 |
-
const pos = mix(V(95, 9, 34), V(
|
| 125 |
-
const target = mix(V(-10, 3, -4), V(-
|
| 126 |
if (p) { pos.add(V(20, 25, 20)); target.y -= 8 }
|
| 127 |
return { pos, target, fov: lerp(44, 40, e) }
|
| 128 |
},
|
|
|
|
| 83 |
6: (u, _t, p) => {
|
| 84 |
const e = easeSine(u)
|
| 85 |
const tp = V(TREE_POS.x, GROUND_Y, TREE_POS.z)
|
| 86 |
+
const pos = tp.clone().add(mix(V(25, 1.8, 16.5), V(19, 2.7, 12.5), e))
|
| 87 |
const target = tp.clone().add(V(-2, mix(V(0, 8, 0), V(0, 10, 0), e).y, -4))
|
| 88 |
if (p) { pos.add(V(6, 0.5, 5)); target.y += 3 }
|
| 89 |
return { pos, target, fov: 46 }
|
|
|
|
| 121 |
// 10 · crane up and pull back over the glowing village under the first stars
|
| 122 |
10: (u, _t, p) => {
|
| 123 |
const e = easeInOut(clamp(u * 1.05))
|
| 124 |
+
const pos = mix(V(95, 9, 34), V(250, 125, 205), e)
|
| 125 |
+
const target = mix(V(-10, 3, -4), V(-520, 60, -230), e)
|
| 126 |
if (p) { pos.add(V(20, 25, 20)); target.y -= 8 }
|
| 127 |
return { pos, target, fov: lerp(44, 40, e) }
|
| 128 |
},
|
src/jiangnan/engine/layout.ts
CHANGED
|
@@ -241,7 +241,7 @@ export interface Landmarks {
|
|
| 241 |
|
| 242 |
export const BRIDGE_S = riverSAtX(40)
|
| 243 |
export const WHEEL_S = riverSAtX(-196)
|
| 244 |
-
export const TREE_POS = (() => { const p = riverPoint(riverSAtX(-
|
| 245 |
export const MARKET_S0 = riverSAtX(-130)
|
| 246 |
export const MARKET_S1 = riverSAtX(30)
|
| 247 |
export const PAVILION_POS = { x: 205, z: -372 }
|
|
@@ -249,7 +249,7 @@ export const PAVILION_POS = { x: 205, z: -372 }
|
|
| 249 |
function blocked(s: number, side: number, half: number) {
|
| 250 |
if (Math.abs(s - BRIDGE_S) < half + 8) return true
|
| 251 |
if (side > 0 && Math.abs(s - WHEEL_S) < half + 10) return true
|
| 252 |
-
if (side < 0 && s < riverSAtX(-
|
| 253 |
return false
|
| 254 |
}
|
| 255 |
|
|
|
|
| 241 |
|
| 242 |
export const BRIDGE_S = riverSAtX(40)
|
| 243 |
export const WHEEL_S = riverSAtX(-196)
|
| 244 |
+
export const TREE_POS = (() => { const p = riverPoint(riverSAtX(-300), -34); return { x: p.x, z: p.z } })()
|
| 245 |
export const MARKET_S0 = riverSAtX(-130)
|
| 246 |
export const MARKET_S1 = riverSAtX(30)
|
| 247 |
export const PAVILION_POS = { x: 205, z: -372 }
|
|
|
|
| 249 |
function blocked(s: number, side: number, half: number) {
|
| 250 |
if (Math.abs(s - BRIDGE_S) < half + 8) return true
|
| 251 |
if (side > 0 && Math.abs(s - WHEEL_S) < half + 10) return true
|
| 252 |
+
if (side < 0 && s < riverSAtX(-264) + half) return true // entrance plaza with the camphor tree
|
| 253 |
return false
|
| 254 |
}
|
| 255 |
|
src/jiangnan/engine/props.ts
CHANGED
|
@@ -375,15 +375,15 @@ export function createProps(): PropsOut {
|
|
| 375 |
const cloths: THREE.Mesh[] = []
|
| 376 |
{
|
| 377 |
const tp = TREE_POS
|
| 378 |
-
const base = V3(tp.x +
|
| 379 |
-
const rot = 0.
|
| 380 |
const m = trs(base.x, base.y, base.z, rot)
|
| 381 |
wood.setMatrix(m)
|
| 382 |
for (const z of [0, 2.4]) {
|
| 383 |
-
for (const x of [-
|
| 384 |
// bamboo crossbar
|
| 385 |
}
|
| 386 |
-
const bar = new THREE.CylinderGeometry(0.07, 0.07,
|
| 387 |
bar.rotateZ(Math.PI / 2)
|
| 388 |
const barMat = std({ color: new THREE.Color(0.45, 0.42, 0.22), roughness: 0.55 }, { key: 'bar' })
|
| 389 |
for (const z of [0, 2.4]) {
|
|
@@ -395,9 +395,9 @@ export function createProps(): PropsOut {
|
|
| 395 |
}
|
| 396 |
const clothGeo = new THREE.PlaneGeometry(1.15, 4.9, 3, 18)
|
| 397 |
clothGeo.translate(0, -2.45, 0)
|
| 398 |
-
for (let k = 0; k <
|
| 399 |
const row = k % 2
|
| 400 |
-
const x = -
|
| 401 |
const mat = patch(new THREE.MeshStandardMaterial({ map: T.cloth[k % 3], roughness: 0.92, side: THREE.DoubleSide }), {
|
| 402 |
key: 'cloth',
|
| 403 |
vertex: `
|
|
|
|
| 375 |
const cloths: THREE.Mesh[] = []
|
| 376 |
{
|
| 377 |
const tp = TREE_POS
|
| 378 |
+
const base = V3(tp.x + 6, GROUND_Y, tp.z + 13.5)
|
| 379 |
+
const rot = 0.99
|
| 380 |
const m = trs(base.x, base.y, base.z, rot)
|
| 381 |
wood.setMatrix(m)
|
| 382 |
for (const z of [0, 2.4]) {
|
| 383 |
+
for (const x of [-4.4, 0, 4.4]) wood.box(x, 2.9, z, 0.12, 5.8, 0.12)
|
| 384 |
// bamboo crossbar
|
| 385 |
}
|
| 386 |
+
const bar = new THREE.CylinderGeometry(0.07, 0.07, 9.4, 8)
|
| 387 |
bar.rotateZ(Math.PI / 2)
|
| 388 |
const barMat = std({ color: new THREE.Color(0.45, 0.42, 0.22), roughness: 0.55 }, { key: 'bar' })
|
| 389 |
for (const z of [0, 2.4]) {
|
|
|
|
| 395 |
}
|
| 396 |
const clothGeo = new THREE.PlaneGeometry(1.15, 4.9, 3, 18)
|
| 397 |
clothGeo.translate(0, -2.45, 0)
|
| 398 |
+
for (let k = 0; k < 10; k++) {
|
| 399 |
const row = k % 2
|
| 400 |
+
const x = -3.9 + Math.floor(k / 2) * 1.75 + row * 0.6
|
| 401 |
const mat = patch(new THREE.MeshStandardMaterial({ map: T.cloth[k % 3], roughness: 0.92, side: THREE.DoubleSide }), {
|
| 402 |
key: 'cloth',
|
| 403 |
vertex: `
|
src/jiangnan/engine/sky.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function skyState(t: number): SkyState {
|
|
| 45 |
sun: mixRGB(a.sun, b.sun, f), sunI: lerp(a.sunI, b.sunI, f),
|
| 46 |
cloudLit: mixRGB(a.cloudLit, b.cloudLit, f), cloudDark: mixRGB(a.cloudDark, b.cloudDark, f),
|
| 47 |
// camera "eye adaptation": open up as the light fades
|
| 48 |
-
exposure: lerp(0.85,
|
| 49 |
}
|
| 50 |
}
|
| 51 |
|
|
|
|
| 45 |
sun: mixRGB(a.sun, b.sun, f), sunI: lerp(a.sunI, b.sunI, f),
|
| 46 |
cloudLit: mixRGB(a.cloudLit, b.cloudLit, f), cloudDark: mixRGB(a.cloudDark, b.cloudDark, f),
|
| 47 |
// camera "eye adaptation": open up as the light fades
|
| 48 |
+
exposure: lerp(0.85, 1.55, smoothstep(1, -9, elev)),
|
| 49 |
}
|
| 50 |
}
|
| 51 |
|
src/jiangnan/engine/timeline.ts
CHANGED
|
@@ -61,16 +61,16 @@ export const WINDOW_SPAN = 12
|
|
| 61 |
export interface Cue { id: string; t: number; text: string; dur: number }
|
| 62 |
// dur is the measured length of the generated narration audio (seconds)
|
| 63 |
export const NARRATION: Cue[] = [
|
| 64 |
-
{ id: 'n01', t: 1.6, text: '江南的黄昏,总是来得很慢。', dur: 3.
|
| 65 |
-
{ id: 'n02', t: 9.8, text: '一条小河,把整座村子轻轻揽在怀里。', dur:
|
| 66 |
-
{ id: 'n03', t: 17.8, text: '石桥弯成半个月亮,水里,再补上另外半个。', dur: 4.
|
| 67 |
-
{ id: 'n04', t: 25.6, text: '老水车吱呀吱呀地转,把一天的光阴,一勺一勺舀回河里。', dur: 5.
|
| 68 |
-
{ id: 'n05', t: 33.0, text: '集市还没散。竹篮、陶罐、檐下的腊肉,都染上了夕阳的颜色。', dur:
|
| 69 |
-
{ id: 'n06', t: 40.8, text: '村口的老樟树站了几百年,看着人们挑着担子出去,又挑着担子回来。', dur:
|
| 70 |
-
{ id: 'n07', t: 48.6, text: '远山的梯田一层层暗下去,白鹭,也该回竹林了。', dur:
|
| 71 |
-
{ id: 'n08', t: 56.0, text: '炊烟升起来的时候,第一盏灯笼亮了。一盏,接着一盏。', dur:
|
| 72 |
-
{ id: 'n09', t: 66.0, text: '灯火落进河里,整条河,都暖了。', dur:
|
| 73 |
-
{ id: 'n10', t: 75.0, text: '星星出来了。水乡睡在一层薄薄的雾里
|
| 74 |
]
|
| 75 |
|
| 76 |
export function cueAt(t: number) {
|
|
|
|
| 61 |
export interface Cue { id: string; t: number; text: string; dur: number }
|
| 62 |
// dur is the measured length of the generated narration audio (seconds)
|
| 63 |
export const NARRATION: Cue[] = [
|
| 64 |
+
{ id: 'n01', t: 1.6, text: '江南的黄昏,总是来得很慢。', dur: 3.31 },
|
| 65 |
+
{ id: 'n02', t: 9.8, text: '一条小河,把整座村子轻轻揽在怀里。', dur: 4.61 },
|
| 66 |
+
{ id: 'n03', t: 17.8, text: '石桥弯成半个月亮,水里,再补上另外半个。', dur: 4.92 },
|
| 67 |
+
{ id: 'n04', t: 25.6, text: '老水车吱呀吱呀地转,把一天的光阴,一勺一勺舀回河里。', dur: 5.95 },
|
| 68 |
+
{ id: 'n05', t: 33.0, text: '集市还没散。竹篮、陶罐、檐下的腊肉,都染上了夕阳的颜色。', dur: 7.2 },
|
| 69 |
+
{ id: 'n06', t: 40.8, text: '村口的老樟树站了几百年,看着人们挑着担子出去,又挑着担子回来。', dur: 7.08 },
|
| 70 |
+
{ id: 'n07', t: 48.6, text: '远山的梯田一层层暗下去,白鹭,也该回竹林了。', dur: 5.4 },
|
| 71 |
+
{ id: 'n08', t: 56.0, text: '炊烟升起来的时候,第一盏灯笼亮了。一盏,接着一盏。', dur: 6.22 },
|
| 72 |
+
{ id: 'n09', t: 66.0, text: '灯火落进河里,整条河,都暖了。', dur: 4.1 },
|
| 73 |
+
{ id: 'n10', t: 75.0, text: '星星出来了。水乡睡在一层薄薄的雾里。这里,是家的方向。', dur: 7.13 },
|
| 74 |
]
|
| 75 |
|
| 76 |
export function cueAt(t: number) {
|
src/jiangnan/export/covers.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Covers: the 3D scene is re-rendered for each aspect ratio (no stretching),
|
| 2 |
+
// then typeset with calligraphy titles.
|
| 3 |
+
import type { Film } from '../engine/Film'
|
| 4 |
+
import { drawSeal, FONT_BODY, FONT_TITLE } from '../player/overlay'
|
| 5 |
+
|
| 6 |
+
export type Ratio = '16:9' | '4:3' | '3:4'
|
| 7 |
+
export const RATIOS: { id: Ratio; w: number; h: number; label: string }[] = [
|
| 8 |
+
{ id: '16:9', w: 1920, h: 1080, label: '16:9 横版(B站 / 视频号 / 抖音横屏)' },
|
| 9 |
+
{ id: '4:3', w: 1440, h: 1080, label: '4:3 横版(B站 / 西瓜)' },
|
| 10 |
+
{ id: '3:4', w: 1080, h: 1440, label: '3:4 竖版(小红书 / 抖音 / 视频号)' },
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
export interface CoverDesign { key: string; name: string; t: number; layout: 'right-vertical' | 'top-center' | 'bottom-left' | 'left-vertical'; line: string }
|
| 14 |
+
export const COVER_DESIGNS: CoverDesign[] = [
|
| 15 |
+
{ key: 'bridge', name: '石桥夕照', t: 21.2, layout: 'right-vertical', line: '石桥半个月亮 水里补上另一半' },
|
| 16 |
+
{ key: 'lantern', name: '灯火长街', t: 63.4, layout: 'top-center', line: '灯笼一盏接一盏亮起' },
|
| 17 |
+
{ key: 'night', name: '星夜水乡', t: 84.2, layout: 'bottom-left', line: '从日落到星夜的 86 秒' },
|
| 18 |
+
{ key: 'tree', name: '古樟斜阳', t: 45.5, layout: 'left-vertical', line: '挑着担子出去 又挑着担子回来' },
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
function vertical(g: CanvasRenderingContext2D, text: string, x: number, y: number, size: number, gap = 1.08) {
|
| 22 |
+
Array.from(text).forEach((c, i) => g.fillText(c, x, y + i * size * gap))
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function renderCover(film: Film, d: CoverDesign, ratio: Ratio): HTMLCanvasElement {
|
| 26 |
+
const R = RATIOS.find((r) => r.id === ratio)!
|
| 27 |
+
const W = R.w, H = R.h
|
| 28 |
+
const prevW = film.width, prevH = film.height
|
| 29 |
+
film.setSize(W, H)
|
| 30 |
+
film.renderFrame(d.t)
|
| 31 |
+
const c = document.createElement('canvas')
|
| 32 |
+
c.width = W
|
| 33 |
+
c.height = H
|
| 34 |
+
const g = c.getContext('2d')!
|
| 35 |
+
g.drawImage(film.renderer.domElement, 0, 0, W, H)
|
| 36 |
+
film.setSize(prevW, prevH)
|
| 37 |
+
const u = Math.min(W, H)
|
| 38 |
+
const portrait = H > W
|
| 39 |
+
g.textAlign = 'center'
|
| 40 |
+
g.textBaseline = 'middle'
|
| 41 |
+
const shadow = (b: number) => { g.shadowColor = 'rgba(20,8,0,0.6)'; g.shadowBlur = b }
|
| 42 |
+
// legibility scrims
|
| 43 |
+
const scrim = (x0: number, y0: number, x1: number, y1: number, a: number) => {
|
| 44 |
+
const grd = g.createLinearGradient(x0, y0, x1, y1)
|
| 45 |
+
grd.addColorStop(0, `rgba(8,6,10,${a})`)
|
| 46 |
+
grd.addColorStop(1, 'rgba(8,6,10,0)')
|
| 47 |
+
g.fillStyle = grd
|
| 48 |
+
g.fillRect(0, 0, W, H)
|
| 49 |
+
}
|
| 50 |
+
if (d.layout === 'right-vertical') {
|
| 51 |
+
scrim(W, 0, W * 0.55, 0, 0.45)
|
| 52 |
+
shadow(u * 0.03)
|
| 53 |
+
g.fillStyle = '#fff4e2'
|
| 54 |
+
const size = u * (portrait ? 0.15 : 0.16)
|
| 55 |
+
const x = W - u * (portrait ? 0.14 : 0.15)
|
| 56 |
+
g.font = `${size}px ${FONT_TITLE}, serif`
|
| 57 |
+
vertical(g, '暮归江南', x, H * 0.17, size)
|
| 58 |
+
g.font = `${u * 0.036}px ${FONT_BODY}, serif`
|
| 59 |
+
g.fillStyle = 'rgba(255,238,215,0.92)'
|
| 60 |
+
const parts = d.line.split(' ')
|
| 61 |
+
parts.forEach((p, i) => vertical(g, p, x - size * 0.95 - i * u * 0.05, H * 0.2 + i * u * 0.06, u * 0.036, 1.25))
|
| 62 |
+
g.shadowBlur = 0
|
| 63 |
+
drawSeal(g, x - u * 0.04, H * 0.17 + size * 4.35, u * 0.08, '江南')
|
| 64 |
+
} else if (d.layout === 'top-center') {
|
| 65 |
+
scrim(0, 0, 0, H * 0.5, 0.55)
|
| 66 |
+
shadow(u * 0.035)
|
| 67 |
+
g.fillStyle = '#fff3df'
|
| 68 |
+
g.font = `${u * (portrait ? 0.17 : 0.15)}px ${FONT_TITLE}, serif`
|
| 69 |
+
g.fillText('暮归江南', W / 2, H * (portrait ? 0.15 : 0.2))
|
| 70 |
+
g.font = `${u * 0.045}px ${FONT_BODY}, serif`
|
| 71 |
+
g.fillStyle = 'rgba(255,230,200,0.95)'
|
| 72 |
+
g.fillText(d.line, W / 2, H * (portrait ? 0.15 : 0.2) + u * 0.13)
|
| 73 |
+
g.shadowBlur = 0
|
| 74 |
+
drawSeal(g, W / 2 + u * (portrait ? 0.36 : 0.32), H * (portrait ? 0.15 : 0.2) - u * 0.13, u * 0.07, '灯火')
|
| 75 |
+
} else if (d.layout === 'bottom-left') {
|
| 76 |
+
scrim(0, H, 0, H * 0.45, 0.65)
|
| 77 |
+
shadow(u * 0.03)
|
| 78 |
+
const x0 = W * 0.07
|
| 79 |
+
g.textAlign = 'left'
|
| 80 |
+
g.fillStyle = '#fff3df'
|
| 81 |
+
g.font = `${u * 0.16}px ${FONT_TITLE}, serif`
|
| 82 |
+
g.fillText('暮归江南', x0, H - u * 0.3)
|
| 83 |
+
g.font = `${u * 0.042}px ${FONT_BODY}, serif`
|
| 84 |
+
g.fillStyle = 'rgba(235,225,255,0.92)'
|
| 85 |
+
g.fillText(d.line, x0 + u * 0.01, H - u * 0.16)
|
| 86 |
+
g.shadowBlur = 0
|
| 87 |
+
drawSeal(g, x0 + u * 0.66, H - u * 0.39, u * 0.07, '星夜')
|
| 88 |
+
} else {
|
| 89 |
+
scrim(0, 0, W * 0.5, 0, 0.5)
|
| 90 |
+
shadow(u * 0.03)
|
| 91 |
+
g.fillStyle = '#fff4e2'
|
| 92 |
+
const size = u * 0.15
|
| 93 |
+
const x = u * 0.15
|
| 94 |
+
g.font = `${size}px ${FONT_TITLE}, serif`
|
| 95 |
+
vertical(g, '暮归江南', x, H * 0.17, size)
|
| 96 |
+
g.font = `${u * 0.036}px ${FONT_BODY}, serif`
|
| 97 |
+
g.fillStyle = 'rgba(255,238,215,0.92)'
|
| 98 |
+
d.line.split(' ').forEach((p, i) => vertical(g, p, x + size * 0.95 + i * u * 0.05, H * 0.2 + i * u * 0.06, u * 0.036, 1.25))
|
| 99 |
+
g.shadowBlur = 0
|
| 100 |
+
drawSeal(g, x - u * 0.04, H * 0.17 + size * 4.35, u * 0.08, '江南')
|
| 101 |
+
}
|
| 102 |
+
// small badge
|
| 103 |
+
g.shadowBlur = 0
|
| 104 |
+
g.textAlign = 'left'
|
| 105 |
+
g.font = `${u * 0.026}px ${FONT_BODY}, serif`
|
| 106 |
+
const badge = '原创3D动画 · 86秒'
|
| 107 |
+
const bw = g.measureText(badge).width + u * 0.05
|
| 108 |
+
const bx = d.layout === 'bottom-left' || d.layout === 'right-vertical' ? u * 0.05 : W - bw - u * 0.05
|
| 109 |
+
const by = d.layout === 'bottom-left' ? u * 0.05 : H - u * 0.1
|
| 110 |
+
g.fillStyle = 'rgba(10,8,12,0.45)'
|
| 111 |
+
g.beginPath()
|
| 112 |
+
g.roundRect(bx, by, bw, u * 0.05, u * 0.025)
|
| 113 |
+
g.fill()
|
| 114 |
+
g.fillStyle = 'rgba(255,236,210,0.95)'
|
| 115 |
+
g.textBaseline = 'middle'
|
| 116 |
+
g.fillText(badge, bx + u * 0.025, by + u * 0.026)
|
| 117 |
+
return c
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
export function canvasToBlob(c: HTMLCanvasElement, type = 'image/jpeg', q = 0.92) {
|
| 121 |
+
return new Promise<Blob>((res, rej) => c.toBlob((b) => (b ? res(b) : rej(new Error('导出失败'))), type, q))
|
| 122 |
+
}
|
src/jiangnan/export/livegen.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// LiveCanvas-style Apple Live Photos generated entirely in the browser:
|
| 2 |
+
// 3:4 (1080×1440), 30 fps, 3 s, silent H.264; the cover JPEG is the exact
|
| 3 |
+
// frame at coverTime; pairing metadata written by livephoto.ts.
|
| 4 |
+
import { strToU8, zipSync, Zippable } from 'fflate'
|
| 5 |
+
import { LIVE_COPY } from '../copy/platforms'
|
| 6 |
+
import type { Film } from '../engine/Film'
|
| 7 |
+
import { drawSeal, FONT_BODY, FONT_TITLE } from '../player/overlay'
|
| 8 |
+
import { inspectLiveJpeg, PVT_PLIST, sha256, uuid4, VideoSample, writeLiveJpeg, writeLiveMov } from './livephoto'
|
| 9 |
+
|
| 10 |
+
export interface LiveClip { id: string; name: string; t0: number; cover: number; caption: string }
|
| 11 |
+
export const LIVE_CLIPS: LiveClip[] = [
|
| 12 |
+
{ id: '01', name: '石桥夕照', t0: 19.6, cover: 1.5, caption: '石桥半月' },
|
| 13 |
+
{ id: '02', name: '老水车', t0: 27.0, cover: 1.5, caption: '水车舀光阴' },
|
| 14 |
+
{ id: '03', name: '古樟布影', t0: 42.6, cover: 1.5, caption: '古樟斜阳' },
|
| 15 |
+
{ id: '04', name: '梯田归鹭', t0: 50.0, cover: 1.5, caption: '梯田归鹭' },
|
| 16 |
+
{ id: '05', name: '灯笼亮起', t0: 57.4, cover: 2.0, caption: '第一盏灯' },
|
| 17 |
+
{ id: '06', name: '星夜水乡', t0: 80.6, cover: 1.5, caption: '星夜水乡' },
|
| 18 |
+
]
|
| 19 |
+
let W = 1080, H = 1440
|
| 20 |
+
const FPS = 30
|
| 21 |
+
let FRAMES = 90
|
| 22 |
+
|
| 23 |
+
function caption(g: CanvasRenderingContext2D, text: string) {
|
| 24 |
+
const u = W
|
| 25 |
+
g.save()
|
| 26 |
+
g.textAlign = 'center'
|
| 27 |
+
g.textBaseline = 'middle'
|
| 28 |
+
g.shadowColor = 'rgba(10,5,0,0.65)'
|
| 29 |
+
g.shadowBlur = u * 0.025
|
| 30 |
+
g.fillStyle = '#fff4e2'
|
| 31 |
+
const size = u * 0.075
|
| 32 |
+
g.font = `${size}px ${FONT_TITLE}, serif`
|
| 33 |
+
const x = W - u * 0.09
|
| 34 |
+
Array.from(text).forEach((c, i) => g.fillText(c, x, H * 0.08 + size * 0.6 + i * size * 1.06))
|
| 35 |
+
g.shadowBlur = 0
|
| 36 |
+
drawSeal(g, x - u * 0.03, H * 0.08 + size * (Array.from(text).length + 0.3) * 1.06, u * 0.06, '暮归')
|
| 37 |
+
g.font = `${u * 0.026}px ${FONT_BODY}, serif`
|
| 38 |
+
g.fillStyle = 'rgba(255,236,210,0.85)'
|
| 39 |
+
g.textAlign = 'left'
|
| 40 |
+
g.fillText('暮归江南 · 原创3D动画', u * 0.05, H - u * 0.06)
|
| 41 |
+
g.restore()
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async function pickCodec() {
|
| 45 |
+
for (const codec of ['avc1.640028', 'avc1.4D0028', 'avc1.42E028', 'avc1.640032']) {
|
| 46 |
+
const cfg: VideoEncoderConfig = { codec, width: W, height: H, bitrate: 10e6, framerate: FPS, avc: { format: 'avc' }, latencyMode: 'quality' }
|
| 47 |
+
try { if ((await VideoEncoder.isConfigSupported(cfg)).supported) return cfg } catch { /* next */ }
|
| 48 |
+
}
|
| 49 |
+
throw new Error('当前浏览器不支持 H.264 编码')
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export interface LiveResult { zip: Blob; previews: { name: string; url: string }[]; report: string }
|
| 53 |
+
|
| 54 |
+
export async function generateLivePhotos(film: Film, onProgress: (p: number, label: string) => void, signal: { cancelled: boolean }, test?: { w: number; h: number; frames: number; clips: number }): Promise<LiveResult> {
|
| 55 |
+
W = test?.w ?? 1080
|
| 56 |
+
H = test?.h ?? 1440
|
| 57 |
+
FRAMES = test?.frames ?? 90
|
| 58 |
+
const clips = LIVE_CLIPS.slice(0, test?.clips ?? LIVE_CLIPS.length)
|
| 59 |
+
const cfg = await pickCodec()
|
| 60 |
+
const files: Zippable = {}
|
| 61 |
+
const previews: LiveResult['previews'] = []
|
| 62 |
+
const manifest: unknown[] = []
|
| 63 |
+
const root = '暮归江南_实况图'
|
| 64 |
+
const comp = document.createElement('canvas')
|
| 65 |
+
comp.width = W
|
| 66 |
+
comp.height = H
|
| 67 |
+
const g = comp.getContext('2d')!
|
| 68 |
+
const prevW = film.width, prevH = film.height
|
| 69 |
+
film.setSize(W, H)
|
| 70 |
+
try {
|
| 71 |
+
for (let ci = 0; ci < clips.length; ci++) {
|
| 72 |
+
const clip = clips[ci]
|
| 73 |
+
const samples: VideoSample[] = []
|
| 74 |
+
let avcC: Uint8Array | null = null
|
| 75 |
+
let err: unknown = null
|
| 76 |
+
let lastTs = -1
|
| 77 |
+
const enc = new VideoEncoder({
|
| 78 |
+
output: (chunk, meta) => {
|
| 79 |
+
if (meta?.decoderConfig?.description && !avcC) {
|
| 80 |
+
const d = meta.decoderConfig.description
|
| 81 |
+
avcC = d instanceof ArrayBuffer ? new Uint8Array(d.slice(0)) : new Uint8Array((d as ArrayBufferView).buffer.slice((d as ArrayBufferView).byteOffset, (d as ArrayBufferView).byteOffset + (d as ArrayBufferView).byteLength))
|
| 82 |
+
}
|
| 83 |
+
if (chunk.timestamp <= lastTs) err = new Error('编码器输出了乱序帧(B 帧),无法封装实况图')
|
| 84 |
+
lastTs = chunk.timestamp
|
| 85 |
+
const data = new Uint8Array(chunk.byteLength)
|
| 86 |
+
chunk.copyTo(data)
|
| 87 |
+
samples.push({ data, key: chunk.type === 'key' })
|
| 88 |
+
},
|
| 89 |
+
error: (e) => { err = e },
|
| 90 |
+
})
|
| 91 |
+
enc.configure(cfg)
|
| 92 |
+
const coverIdx = Math.min(FRAMES - 1, Math.round(clip.cover * FPS))
|
| 93 |
+
let coverJpeg: Uint8Array | null = null
|
| 94 |
+
for (let i = 0; i < FRAMES; i++) {
|
| 95 |
+
if (signal.cancelled) throw new Error('已取消')
|
| 96 |
+
if (err) throw err
|
| 97 |
+
const t = clip.t0 + i / FPS
|
| 98 |
+
film.renderFrame(t)
|
| 99 |
+
g.drawImage(film.renderer.domElement, 0, 0, W, H)
|
| 100 |
+
caption(g, clip.caption)
|
| 101 |
+
if (i === coverIdx) {
|
| 102 |
+
const blob = await new Promise<Blob>((res, rej) => comp.toBlob((b) => (b ? res(b) : rej(new Error('封面导出失败'))), 'image/jpeg', 0.95))
|
| 103 |
+
coverJpeg = new Uint8Array(await blob.arrayBuffer())
|
| 104 |
+
previews.push({ name: `${clip.id} ${clip.name}`, url: URL.createObjectURL(blob) })
|
| 105 |
+
}
|
| 106 |
+
const frame = new VideoFrame(comp, { timestamp: Math.round((i * 1e6) / FPS), duration: Math.round(1e6 / FPS) })
|
| 107 |
+
enc.encode(frame, { keyFrame: i === 0 })
|
| 108 |
+
frame.close()
|
| 109 |
+
while (enc.encodeQueueSize > 3) await new Promise((r) => setTimeout(r, 2))
|
| 110 |
+
if (i % 6 === 0) {
|
| 111 |
+
onProgress((ci + i / FRAMES) / clips.length, `正在渲染第 ${ci + 1}/${clips.length} 张:${clip.name}`)
|
| 112 |
+
await new Promise((r) => setTimeout(r, 0))
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
await enc.flush()
|
| 116 |
+
enc.close()
|
| 117 |
+
if (err) throw err
|
| 118 |
+
if (!avcC || !coverJpeg || samples.length !== FRAMES) throw new Error('视频编码不完整')
|
| 119 |
+
const id = uuid4()
|
| 120 |
+
const mov = writeLiveMov({ samples, avcC, width: W, height: H, fps: FPS, coverTime: coverIdx / FPS, id })
|
| 121 |
+
const jpg = writeLiveJpeg(coverJpeg, id, W, H)
|
| 122 |
+
if (inspectLiveJpeg(jpg) !== id) throw new Error('JPEG 标识读回失败')
|
| 123 |
+
const base = `${clip.id}_${clip.name}`
|
| 124 |
+
files[`${root}/1_jpg+mov(Mac 直接导入照片)/${base}.jpg`] = [jpg, { level: 0 }]
|
| 125 |
+
files[`${root}/1_jpg+mov(Mac 直接导入照片)/${base}.mov`] = [mov, { level: 0 }]
|
| 126 |
+
files[`${root}/2_pvt实况资源包/${base}.pvt/${base}.jpg`] = [jpg, { level: 0 }]
|
| 127 |
+
files[`${root}/2_pvt实况资源包/${base}.pvt/${base}.mov`] = [mov, { level: 0 }]
|
| 128 |
+
files[`${root}/2_pvt实况资源包/${base}.pvt/metadata.plist`] = strToU8(PVT_PLIST)
|
| 129 |
+
manifest.push({
|
| 130 |
+
name: base, identifier: id, photo: `${base}.jpg`, video: `${base}.mov`, coverTime: coverIdx / FPS, duration: FRAMES / FPS, fps: FPS,
|
| 131 |
+
width: W, height: H, backend: 'browser-webcodecs(LiveCanvas portable-python 配对器的 TypeScript 移植)', metadataVerified: true,
|
| 132 |
+
localPHLivePhotoLoad: 'not_available', iPhonePlayback: 'not_tested', photosImport: 'not_requested',
|
| 133 |
+
sha256: { photo: await sha256(jpg), video: await sha256(mov) },
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
} finally {
|
| 137 |
+
film.setSize(prevW, prevH)
|
| 138 |
+
}
|
| 139 |
+
const copy = LIVE_COPY.map((c) => `【${c.name}】\n标题(任选其一,≤${c.titleMax} 字):\n${c.titles.map((t) => '· ' + t).join('\n')}\n\n正文:\n${c.body}\n\n话题(${c.tags.length}/${c.tagMax}):\n${c.tags.map((t) => '#' + t).join(' ')}\n`).join('\n————————————\n\n')
|
| 140 |
+
const readme = `暮归江南 · 实况图(Apple Live Photo)\n\n共 ${LIVE_CLIPS.length} 组,每组 3 秒、30 fps、1080×1440(3:4),无声 H.264。\n\n使用方法(Mac):\n1. 打开「1_jpg+mov(Mac 直接导入照片)」文件夹,全选同名的 .jpg 和 .mov,拖进「照片」App(或 文件 → 导入),会自动合成为实况照片;\n2. 通过 iCloud 照片同步到 iPhone 后,在小红书 / 抖音发布图文时选择这些实况照片即可(发布时保持“实况”开启)。\n\n「2_pvt实况资源包」是 LiveCanvas 规范的 .pvt 目录包,可在 Mac 上作为单个实况照片打开或导入。\n\n技术说明:JPEG 的 Exif MakerNote(Apple tag 17)与 MOV 的 com.apple.quicktime.content.identifier 使用同一个 UUID;MOV 内含 mebx 定时元数据轨(com.apple.quicktime.still-image-time),指向封面那一帧。生成器在浏览器本地运行,已用 LiveCanvas skill 原版 Python 校验器(verify_pair / package_live_photo)验证过同一套写入逻辑。\n导入「照片」和手机播放属于接收端操作,本包未在你的设备上测试(manifest 中记为 not_tested)。\n`
|
| 141 |
+
files[`${root}/使用说明.txt`] = strToU8(readme)
|
| 142 |
+
files[`${root}/图文发布文案.txt`] = strToU8(copy)
|
| 143 |
+
files[`${root}/manifest.json`] = strToU8(JSON.stringify(manifest, null, 2))
|
| 144 |
+
onProgress(0.99, '正在打包 ZIP')
|
| 145 |
+
await new Promise((r) => setTimeout(r, 0))
|
| 146 |
+
const zipped = zipSync(files)
|
| 147 |
+
onProgress(1, '完成')
|
| 148 |
+
return { zip: new Blob([zipped as Uint8Array<ArrayBuffer>], { type: 'application/zip' }), previews, report: `${LIVE_CLIPS.length} 组实况图已生成` }
|
| 149 |
+
}
|
src/jiangnan/export/livephoto.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Apple Live Photo pairing in the browser.
|
| 2 |
+
// Byte-for-byte port of the LiveCanvas skill's portable pairing
|
| 3 |
+
// (scripts/portable_live_photo/apple.py, Apache-2.0, © 2026 杨振, modified by LiveCanvas):
|
| 4 |
+
// • JPEG: ExifIFD MakerNote (UNDEFINED) with Apple tag 17 = content identifier
|
| 5 |
+
// • MOV : moov/meta keys `com.apple.quicktime.content.identifier`
|
| 6 |
+
// + `mebx` timed-metadata track with `com.apple.quicktime.still-image-time`
|
| 7 |
+
// (signed int8 0) positioned by an edit list at the cover time.
|
| 8 |
+
// Here we write the QuickTime file directly (one H.264 video track, one chunk).
|
| 9 |
+
|
| 10 |
+
const enc = new TextEncoder()
|
| 11 |
+
export const CONTENT_KEY = 'com.apple.quicktime.content.identifier'
|
| 12 |
+
export const STILL_TIME_KEY = 'com.apple.quicktime.still-image-time'
|
| 13 |
+
const TIMED_SAMPLE = new Uint8Array([0, 0, 0, 9, 0, 0, 0, 1, 0])
|
| 14 |
+
export const MOVIE_TIMESCALE = 60000
|
| 15 |
+
|
| 16 |
+
// ---------------------------------------------------------------- byte helpers
|
| 17 |
+
class W {
|
| 18 |
+
parts: Uint8Array[] = []
|
| 19 |
+
len = 0
|
| 20 |
+
u8(v: number) { return this.bytes(new Uint8Array([v & 255])) }
|
| 21 |
+
u16(v: number) { return this.bytes(new Uint8Array([(v >> 8) & 255, v & 255])) }
|
| 22 |
+
i16(v: number) { return this.u16(v < 0 ? v + 65536 : v) }
|
| 23 |
+
u32(v: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v >>> 0); return this.bytes(b) }
|
| 24 |
+
i32(v: number) { const b = new Uint8Array(4); new DataView(b.buffer).setInt32(0, v); return this.bytes(b) }
|
| 25 |
+
u64(v: number) { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v)); return this.bytes(b) }
|
| 26 |
+
str(s: string) { return this.bytes(enc.encode(s)) }
|
| 27 |
+
zeros(n: number) { return this.bytes(new Uint8Array(n)) }
|
| 28 |
+
bytes(b: Uint8Array) { this.parts.push(b); this.len += b.length; return this }
|
| 29 |
+
out() { const o = new Uint8Array(this.len); let p = 0; for (const b of this.parts) { o.set(b, p); p += b.length } return o }
|
| 30 |
+
}
|
| 31 |
+
const cat = (...bs: Uint8Array[]) => { const w = new W(); bs.forEach((b) => w.bytes(b)); return w.out() }
|
| 32 |
+
function atom(type: string, ...payload: Uint8Array[]) {
|
| 33 |
+
const body = cat(...payload)
|
| 34 |
+
return new W().u32(body.length + 8).str(type).bytes(body).out()
|
| 35 |
+
}
|
| 36 |
+
const fullbox = (version = 0, flags = 0) => new W().u8(version).u8(flags >> 16).u8(flags >> 8).u8(flags).out()
|
| 37 |
+
const MATRIX = () => { const w = new W(); [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000].forEach((v) => w.u32(v)); return w.out() }
|
| 38 |
+
const pascal = (s: string) => { const b = enc.encode(s); return cat(new Uint8Array([b.length]), b) }
|
| 39 |
+
|
| 40 |
+
export function canonicalUUID(u: string) {
|
| 41 |
+
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(u)) throw new Error('Apple 资产标识必须是标准 UUID')
|
| 42 |
+
return u.toUpperCase()
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ---------------------------------------------------------------- JPEG
|
| 46 |
+
export function appleMakerNote(id: string) {
|
| 47 |
+
const ident = enc.encode(canonicalUUID(id))
|
| 48 |
+
return new W().str('Apple iOS').u8(0).u8(0).u8(1).str('MM')
|
| 49 |
+
.u16(1)
|
| 50 |
+
.u16(0x11).u16(2).u32(ident.length + 1).u32(32)
|
| 51 |
+
.u32(0)
|
| 52 |
+
.bytes(ident).u8(0).out()
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/** Build a big-endian Exif APP1 payload (TIFF) carrying the Apple MakerNote. */
|
| 56 |
+
function exifTiff(id: string, width: number, height: number) {
|
| 57 |
+
const note = appleMakerNote(id)
|
| 58 |
+
const make = enc.encode('Apple\0'), model = enc.encode('LiveCanvas\0')
|
| 59 |
+
// layout: header(8) | IFD0 | ExifIFD | data
|
| 60 |
+
const ifd0Count = 4, exifCount = 6
|
| 61 |
+
const ifd0Off = 8
|
| 62 |
+
const ifd0Size = 2 + ifd0Count * 12 + 4
|
| 63 |
+
const exifOff = ifd0Off + ifd0Size
|
| 64 |
+
const exifSize = 2 + exifCount * 12 + 4
|
| 65 |
+
let dataOff = exifOff + exifSize
|
| 66 |
+
const makeOff = dataOff; dataOff += make.length
|
| 67 |
+
const modelOff = dataOff; dataOff += model.length
|
| 68 |
+
if (dataOff % 2) dataOff++
|
| 69 |
+
const noteOff = dataOff; dataOff += note.length
|
| 70 |
+
const w = new W()
|
| 71 |
+
w.str('MM').u16(42).u32(8)
|
| 72 |
+
// IFD0 (tags ascending)
|
| 73 |
+
w.u16(ifd0Count)
|
| 74 |
+
w.u16(0x010f).u16(2).u32(make.length).u32(makeOff)
|
| 75 |
+
w.u16(0x0110).u16(2).u32(model.length).u32(modelOff)
|
| 76 |
+
w.u16(0x0112).u16(3).u32(1).u16(1).u16(0) // orientation = 1
|
| 77 |
+
w.u16(0x8769).u16(4).u32(1).u32(exifOff)
|
| 78 |
+
w.u32(0)
|
| 79 |
+
// Exif IFD
|
| 80 |
+
w.u16(exifCount)
|
| 81 |
+
w.u16(0x9000).u16(7).u32(4).str('0232')
|
| 82 |
+
w.u16(0x927c).u16(7).u32(note.length).u32(noteOff) // MakerNote as UNDEFINED (type 7)
|
| 83 |
+
w.u16(0xa000).u16(7).u32(4).str('0100')
|
| 84 |
+
w.u16(0xa001).u16(3).u32(1).u16(1).u16(0)
|
| 85 |
+
w.u16(0xa002).u16(4).u32(1).u32(width)
|
| 86 |
+
w.u16(0xa003).u16(4).u32(1).u32(height)
|
| 87 |
+
w.u32(0)
|
| 88 |
+
w.bytes(make).bytes(model)
|
| 89 |
+
if (w.len % 2) w.u8(0)
|
| 90 |
+
w.bytes(note)
|
| 91 |
+
return w.out()
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/** Insert an Exif APP1 segment (with the Apple identifier) into a baseline JPEG. */
|
| 95 |
+
export function writeLiveJpeg(jpeg: Uint8Array, id: string, width: number, height: number) {
|
| 96 |
+
if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8) throw new Error('不是 JPEG')
|
| 97 |
+
const tiff = exifTiff(id, width, height)
|
| 98 |
+
const payload = cat(enc.encode('Exif\0\0'), tiff)
|
| 99 |
+
const app1 = new W().u8(0xff).u8(0xe1).u16(payload.length + 2).bytes(payload).out()
|
| 100 |
+
// skip SOI and any existing APP0 (JFIF); drop existing APP1 segments
|
| 101 |
+
let p = 2
|
| 102 |
+
const keep: Uint8Array[] = []
|
| 103 |
+
while (p < jpeg.length && jpeg[p] === 0xff && jpeg[p + 1] >= 0xe0 && jpeg[p + 1] <= 0xef) {
|
| 104 |
+
const len = (jpeg[p + 2] << 8) | jpeg[p + 3]
|
| 105 |
+
if (jpeg[p + 1] === 0xe0) keep.push(jpeg.subarray(p, p + 2 + len))
|
| 106 |
+
p += 2 + len
|
| 107 |
+
}
|
| 108 |
+
return cat(jpeg.subarray(0, 2), ...keep, app1, jpeg.subarray(p))
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/** Read back the content identifier from our JPEG (mirrors inspect_live_jpeg). */
|
| 112 |
+
export function inspectLiveJpeg(jpeg: Uint8Array): string {
|
| 113 |
+
let p = 2
|
| 114 |
+
while (p < jpeg.length && jpeg[p] === 0xff) {
|
| 115 |
+
const m = jpeg[p + 1], len = (jpeg[p + 2] << 8) | jpeg[p + 3]
|
| 116 |
+
if (m === 0xe1) {
|
| 117 |
+
const seg = jpeg.subarray(p + 4, p + 2 + len)
|
| 118 |
+
const s = new TextDecoder('latin1').decode(seg)
|
| 119 |
+
const i = s.indexOf('Apple iOS\0\0\x01MM')
|
| 120 |
+
if (i >= 0) {
|
| 121 |
+
const note = seg.subarray(i)
|
| 122 |
+
const dv = new DataView(note.buffer, note.byteOffset, note.byteLength)
|
| 123 |
+
if (dv.getUint16(16) !== 0x11 || dv.getUint16(18) !== 2) throw new Error('缺少 tag 17')
|
| 124 |
+
const count = dv.getUint32(20), off = dv.getUint32(24)
|
| 125 |
+
return canonicalUUID(new TextDecoder().decode(note.subarray(off, off + count)).replace(/\0+$/, ''))
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
if (m === 0xda) break
|
| 129 |
+
p += 2 + len
|
| 130 |
+
}
|
| 131 |
+
throw new Error('JPEG 缺少 Apple MakerNote')
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// ---------------------------------------------------------------- MOV
|
| 135 |
+
export interface VideoSample { data: Uint8Array; key: boolean }
|
| 136 |
+
|
| 137 |
+
export interface LiveMovInput {
|
| 138 |
+
samples: VideoSample[]
|
| 139 |
+
avcC: Uint8Array
|
| 140 |
+
width: number
|
| 141 |
+
height: number
|
| 142 |
+
fps: number
|
| 143 |
+
coverTime: number
|
| 144 |
+
id: string
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function hdlr(type: string, subtype: string, manuf: string, name: string, flags = 0) {
|
| 148 |
+
return atom('hdlr', fullbox(), enc.encode(type), enc.encode(subtype), manuf ? enc.encode(manuf) : new Uint8Array(4), new W().u32(flags).u32(0).out(), pascal(name))
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function contentIdentifierMeta(id: string) {
|
| 152 |
+
const ident = enc.encode(canonicalUUID(id))
|
| 153 |
+
const h = atom('hdlr', fullbox(), new Uint8Array(4), enc.encode('mdta'), new Uint8Array(12), new Uint8Array(2))
|
| 154 |
+
const keyEntry = cat(new W().u32(8 + CONTENT_KEY.length).str('mdta').out(), enc.encode(CONTENT_KEY))
|
| 155 |
+
const keys = atom('keys', fullbox(), new W().u32(1).out(), keyEntry)
|
| 156 |
+
const data = atom('data', new W().u32(1).u32(0).out(), ident)
|
| 157 |
+
const item = atom('\0\0\0\x01', data)
|
| 158 |
+
return atom('meta', h, keys, atom('ilst', item))
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function timedMetadataTrack(trackId: number, videoTrackId: number, timescale: number, coverTicks: number, sampleOffset: number, sampleTicks: number) {
|
| 162 |
+
const dur = coverTicks + sampleTicks
|
| 163 |
+
const tkhd = atom('tkhd', fullbox(0, 0x0f), new W().u32(0).u32(0).u32(trackId).u32(0).u32(dur).zeros(8).i16(0).i16(0).i16(0).i16(0).out(), MATRIX(), new W().u32(0).u32(0).out())
|
| 164 |
+
const edits = new W()
|
| 165 |
+
let n = 0
|
| 166 |
+
if (coverTicks) { edits.u32(coverTicks).i32(-1).u16(1).u16(0); n++ }
|
| 167 |
+
edits.u32(sampleTicks).i32(0).u16(1).u16(0); n++
|
| 168 |
+
const elst = atom('elst', fullbox(), new W().u32(n).out(), edits.out())
|
| 169 |
+
const edts = atom('edts', elst)
|
| 170 |
+
const tref = atom('tref', atom('cdsc', new W().u32(videoTrackId).out()))
|
| 171 |
+
const mdhd = atom('mdhd', fullbox(), new W().u32(0).u32(0).u32(timescale).u32(sampleTicks).u16(0x55c4).u16(0).out())
|
| 172 |
+
const h = atom('hdlr', fullbox(), enc.encode('mhlrmetaappl'), new W().u32(1).u32(0).out(), pascal('Core Media Metadata'))
|
| 173 |
+
const gmin = atom('gmin', fullbox(), new W().u16(0x40).u16(0x8000).u16(0x8000).u16(0x8000).u16(0).u16(0).out())
|
| 174 |
+
const gmhd = atom('gmhd', gmin)
|
| 175 |
+
const dh = atom('hdlr', fullbox(), enc.encode('dhlralisappl'), new W().u32(0).u32(0).out(), pascal('Core Media Data Handler'))
|
| 176 |
+
const dinf = atom('dinf', atom('dref', fullbox(), new W().u32(1).out(), atom('alis', fullbox(0, 1))))
|
| 177 |
+
const keyd = atom('keyd', enc.encode('mdta'), enc.encode(STILL_TIME_KEY))
|
| 178 |
+
const dtyp = atom('dtyp', fullbox(), new W().u32(0x41).out())
|
| 179 |
+
const keyRecord = cat(new W().u32(8 + keyd.length + dtyp.length).u32(1).out(), keyd, dtyp)
|
| 180 |
+
const keys = atom('keys', keyRecord)
|
| 181 |
+
const mebx = atom('mebx', new Uint8Array(6), new W().u16(1).out(), keys)
|
| 182 |
+
const stsd = atom('stsd', fullbox(), new W().u32(1).out(), mebx)
|
| 183 |
+
const stts = atom('stts', fullbox(), new W().u32(1).u32(1).u32(sampleTicks).out())
|
| 184 |
+
const stsc = atom('stsc', fullbox(), new W().u32(1).u32(1).u32(1).u32(1).out())
|
| 185 |
+
const stsz = atom('stsz', fullbox(), new W().u32(0).u32(1).u32(TIMED_SAMPLE.length).out())
|
| 186 |
+
const stco = atom('stco', fullbox(), new W().u32(1).u32(sampleOffset).out())
|
| 187 |
+
const stbl = atom('stbl', stsd, stts, stsc, stsz, stco)
|
| 188 |
+
const minf = atom('minf', gmhd, dh, dinf, stbl)
|
| 189 |
+
const mdia = atom('mdia', mdhd, h, minf)
|
| 190 |
+
return atom('trak', tkhd, edts, tref, mdia)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/** QuickTime movie: ftyp | mdat (video samples + timed sample) | moov. */
|
| 194 |
+
export function writeLiveMov(inp: LiveMovInput) {
|
| 195 |
+
const { samples, avcC, width, height, fps, id } = inp
|
| 196 |
+
const ts = MOVIE_TIMESCALE
|
| 197 |
+
const delta = Math.round(ts / fps)
|
| 198 |
+
const n = samples.length
|
| 199 |
+
const duration = n * delta
|
| 200 |
+
const ftyp = atom('ftyp', enc.encode('qt '), new W().u32(0x200).out(), enc.encode('qt '))
|
| 201 |
+
const mediaLen = samples.reduce((a, s) => a + s.data.length, 0) + TIMED_SAMPLE.length
|
| 202 |
+
const mdatHeader = new W().u32(mediaLen + 8).str('mdat').out()
|
| 203 |
+
const firstOffset = ftyp.length + mdatHeader.length
|
| 204 |
+
const timedOffset = firstOffset + mediaLen - TIMED_SAMPLE.length
|
| 205 |
+
// ---- video track
|
| 206 |
+
const VID = 1, META = 2
|
| 207 |
+
const tkhd = atom('tkhd', fullbox(0, 0x03), new W().u32(0).u32(0).u32(VID).u32(0).u32(duration).zeros(8).i16(0).i16(0).i16(0).i16(0).out(), MATRIX(), new W().u32(width * 65536).u32(height * 65536).out())
|
| 208 |
+
const mdhd = atom('mdhd', fullbox(), new W().u32(0).u32(0).u32(ts).u32(duration).u16(0x55c4).u16(0).out())
|
| 209 |
+
const vh = hdlr('mhlr', 'vide', 'appl', 'Core Media Video')
|
| 210 |
+
const vmhd = atom('vmhd', fullbox(0, 1), new W().u16(0).u16(0).u16(0).u16(0).out())
|
| 211 |
+
const dh = hdlr('dhlr', 'alis', 'appl', 'Core Media Data Handler')
|
| 212 |
+
const dinf = atom('dinf', atom('dref', fullbox(), new W().u32(1).out(), atom('alis', fullbox(0, 1))))
|
| 213 |
+
const compressor = new Uint8Array(32)
|
| 214 |
+
const cname = enc.encode('H.264')
|
| 215 |
+
compressor[0] = cname.length
|
| 216 |
+
compressor.set(cname, 1)
|
| 217 |
+
const colr = atom('colr', enc.encode('nclx'), new W().u16(1).u16(1).u16(1).out())
|
| 218 |
+
const pasp = atom('pasp', new W().u32(1).u32(1).out())
|
| 219 |
+
const avc1 = atom('avc1', new Uint8Array(6), new W().u16(1).u16(0).u16(0).u32(0).u32(0).u32(0x200).u16(width).u16(height).u32(0x480000).u32(0x480000).u32(0).u16(1).out(), compressor, new W().u16(0x18).i16(-1).out(), atom('avcC', avcC), colr, pasp)
|
| 220 |
+
const stsd = atom('stsd', fullbox(), new W().u32(1).out(), avc1)
|
| 221 |
+
const stts = atom('stts', fullbox(), new W().u32(1).u32(n).u32(delta).out())
|
| 222 |
+
const keys = samples.map((s, i) => (s.key ? i + 1 : 0)).filter(Boolean)
|
| 223 |
+
const stssW = new W().u32(keys.length)
|
| 224 |
+
keys.forEach((k) => stssW.u32(k))
|
| 225 |
+
const stss = atom('stss', fullbox(), stssW.out())
|
| 226 |
+
const stsc = atom('stsc', fullbox(), new W().u32(1).u32(1).u32(n).u32(1).out())
|
| 227 |
+
const szW = new W().u32(0).u32(n)
|
| 228 |
+
samples.forEach((s) => szW.u32(s.data.length))
|
| 229 |
+
const stsz = atom('stsz', fullbox(), szW.out())
|
| 230 |
+
const stco = atom('stco', fullbox(), new W().u32(1).u32(firstOffset).out())
|
| 231 |
+
const stbl = atom('stbl', stsd, stts, stss, stsc, stsz, stco)
|
| 232 |
+
const minf = atom('minf', vmhd, dh, dinf, stbl)
|
| 233 |
+
const mdia = atom('mdia', mdhd, vh, minf)
|
| 234 |
+
const vtrak = atom('trak', tkhd, mdia)
|
| 235 |
+
// ---- cover time (same clamping as the Python pairing tool)
|
| 236 |
+
let coverTicks = Math.round(inp.coverTime * ts)
|
| 237 |
+
coverTicks = Math.min(Math.max(0, coverTicks), Math.max(0, duration - 1))
|
| 238 |
+
const mtrak = timedMetadataTrack(META, VID, ts, coverTicks, timedOffset, Math.max(1, Math.round(ts / fps)))
|
| 239 |
+
const mvhd = atom('mvhd', fullbox(), new W().u32(0).u32(0).u32(ts).u32(duration).u32(0x10000).u16(0x100).zeros(10).out(), MATRIX(), new W().zeros(24).u32(META + 1).out())
|
| 240 |
+
const moov = atom('moov', mvhd, vtrak, contentIdentifierMeta(id), mtrak)
|
| 241 |
+
return cat(ftyp, mdatHeader, ...samples.map((s) => s.data), TIMED_SAMPLE, moov)
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
export const PVT_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
| 245 |
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
| 246 |
+
<plist version="1.0">
|
| 247 |
+
<dict>
|
| 248 |
+
<key>PFVideoComplementMetadataVersionKey</key>
|
| 249 |
+
<string>1</string>
|
| 250 |
+
</dict>
|
| 251 |
+
</plist>
|
| 252 |
+
`
|
| 253 |
+
|
| 254 |
+
export function uuid4(rand: () => number = Math.random) {
|
| 255 |
+
const b = new Uint8Array(16)
|
| 256 |
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b)
|
| 257 |
+
else for (let i = 0; i < 16; i++) b[i] = Math.floor(rand() * 256)
|
| 258 |
+
b[6] = (b[6] & 0x0f) | 0x40
|
| 259 |
+
b[8] = (b[8] & 0x3f) | 0x80
|
| 260 |
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
|
| 261 |
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`.toUpperCase()
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
export async function sha256(data: Uint8Array) {
|
| 265 |
+
const d = await crypto.subtle.digest('SHA-256', data as Uint8Array<ArrayBuffer>)
|
| 266 |
+
return Array.from(new Uint8Array(d), (x) => x.toString(16).padStart(2, '0')).join('')
|
| 267 |
+
}
|
src/jiangnan/export/mp4.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// In-browser MP4 export: deterministic frame-by-frame rendering → WebCodecs
|
| 2 |
+
// H.264 (+ AAC / Opus audio) → mp4-muxer. Nothing leaves the user's device.
|
| 3 |
+
import { ArrayBufferTarget, Muxer } from 'mp4-muxer'
|
| 4 |
+
import type { Film } from '../engine/Film'
|
| 5 |
+
import { DURATION } from '../engine/timeline'
|
| 6 |
+
import { drawOverlay, OverlayOpts } from '../player/overlay'
|
| 7 |
+
|
| 8 |
+
export interface ExportOptions {
|
| 9 |
+
width: number
|
| 10 |
+
height: number
|
| 11 |
+
fps: 24 | 30 | 60
|
| 12 |
+
audio: AudioBuffer | null
|
| 13 |
+
overlay: OverlayOpts
|
| 14 |
+
onProgress: (p: number, info: { frame: number; total: number; eta: number; stage: string }) => void
|
| 15 |
+
signal: { cancelled: boolean }
|
| 16 |
+
duration?: number
|
| 17 |
+
start?: number
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function exportSupport() {
|
| 21 |
+
return {
|
| 22 |
+
video: typeof window !== 'undefined' && 'VideoEncoder' in window,
|
| 23 |
+
audio: typeof window !== 'undefined' && 'AudioEncoder' in window,
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function pickVideoCodec(w: number, h: number, fps: number, bitrate: number) {
|
| 28 |
+
const candidates = ['avc1.640034', 'avc1.640033', 'avc1.64002A', 'avc1.640028', 'avc1.4D4033', 'avc1.4D0028', 'avc1.42E033', 'avc1.42E028', 'avc1.42001f']
|
| 29 |
+
for (const codec of candidates) {
|
| 30 |
+
for (const hw of ['prefer-hardware', 'no-preference'] as const) {
|
| 31 |
+
const cfg: VideoEncoderConfig = { codec, width: w, height: h, bitrate, framerate: fps, hardwareAcceleration: hw, avc: { format: 'avc' }, latencyMode: 'quality' }
|
| 32 |
+
try {
|
| 33 |
+
const s = await VideoEncoder.isConfigSupported(cfg)
|
| 34 |
+
if (s.supported) return cfg
|
| 35 |
+
} catch { /* try next */ }
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
throw new Error('当前浏览器不支持 H.264 编码,请使用最新版 Chrome / Edge / Safari')
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function pickAudioCodec() {
|
| 42 |
+
if (!('AudioEncoder' in window)) return null
|
| 43 |
+
for (const [codec, mux] of [['mp4a.40.2', 'aac'], ['opus', 'opus']] as const) {
|
| 44 |
+
const cfg: AudioEncoderConfig = { codec, sampleRate: 48000, numberOfChannels: 2, bitrate: 192000 }
|
| 45 |
+
try {
|
| 46 |
+
const s = await AudioEncoder.isConfigSupported(cfg)
|
| 47 |
+
if (s.supported) return { cfg, mux }
|
| 48 |
+
} catch { /* next */ }
|
| 49 |
+
}
|
| 50 |
+
return null
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function suggestedBitrate(w: number, h: number, fps: number) {
|
| 54 |
+
const px = w * h
|
| 55 |
+
const base = px >= 1920 * 1080 * 0.9 ? 14e6 : 7e6
|
| 56 |
+
return Math.round(base * (fps >= 50 ? 1.5 : fps <= 24 ? 0.85 : 1))
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function exportMp4(film: Film, o: ExportOptions): Promise<{ blob: Blob; audioCodec: string | null }> {
|
| 60 |
+
const { width: W, height: H, fps } = o
|
| 61 |
+
const start = o.start ?? 0
|
| 62 |
+
const dur = o.duration ?? DURATION
|
| 63 |
+
const total = Math.round(dur * fps)
|
| 64 |
+
const bitrate = suggestedBitrate(W, H, fps)
|
| 65 |
+
const vcfg = await pickVideoCodec(W, H, fps, bitrate)
|
| 66 |
+
const acodec = o.audio ? await pickAudioCodec() : null
|
| 67 |
+
const target = new ArrayBufferTarget()
|
| 68 |
+
const muxer = new Muxer({
|
| 69 |
+
target,
|
| 70 |
+
video: { codec: 'avc', width: W, height: H, frameRate: fps },
|
| 71 |
+
...(acodec ? { audio: { codec: acodec.mux, sampleRate: 48000, numberOfChannels: 2 } } : {}),
|
| 72 |
+
fastStart: 'in-memory',
|
| 73 |
+
firstTimestampBehavior: 'offset',
|
| 74 |
+
})
|
| 75 |
+
let encErr: unknown = null
|
| 76 |
+
|
| 77 |
+
// ---- audio first (fast)
|
| 78 |
+
if (acodec && o.audio) {
|
| 79 |
+
o.onProgress(0, { frame: 0, total, eta: 0, stage: '编码音轨' })
|
| 80 |
+
const ae = new AudioEncoder({ output: (c, m) => muxer.addAudioChunk(c, m), error: (e) => { encErr = e } })
|
| 81 |
+
ae.configure(acodec.cfg)
|
| 82 |
+
const buf = o.audio
|
| 83 |
+
const L = buf.getChannelData(0), R = buf.getChannelData(1)
|
| 84 |
+
const s0 = Math.floor(start * 48000)
|
| 85 |
+
const len = Math.min(L.length - s0, Math.round(dur * 48000))
|
| 86 |
+
const block = 4800
|
| 87 |
+
for (let i = 0; i < len; i += block) {
|
| 88 |
+
const n = Math.min(block, len - i)
|
| 89 |
+
const data = new Float32Array(n * 2)
|
| 90 |
+
data.set(L.subarray(s0 + i, s0 + i + n), 0)
|
| 91 |
+
data.set(R.subarray(s0 + i, s0 + i + n), n)
|
| 92 |
+
const ad = new AudioData({ format: 'f32-planar', sampleRate: 48000, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round((i / 48000) * 1e6), data })
|
| 93 |
+
ae.encode(ad)
|
| 94 |
+
ad.close()
|
| 95 |
+
if (ae.encodeQueueSize > 20) await new Promise((r) => setTimeout(r, 0))
|
| 96 |
+
}
|
| 97 |
+
await ae.flush()
|
| 98 |
+
ae.close()
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// ---- video
|
| 102 |
+
const ve = new VideoEncoder({ output: (c, m) => muxer.addVideoChunk(c, m), error: (e) => { encErr = e } })
|
| 103 |
+
ve.configure(vcfg)
|
| 104 |
+
const comp = document.createElement('canvas')
|
| 105 |
+
comp.width = W
|
| 106 |
+
comp.height = H
|
| 107 |
+
const g = comp.getContext('2d')!
|
| 108 |
+
const prevW = film.width, prevH = film.height
|
| 109 |
+
film.setSize(W, H)
|
| 110 |
+
const t0 = performance.now()
|
| 111 |
+
try {
|
| 112 |
+
for (let i = 0; i < total; i++) {
|
| 113 |
+
if (o.signal.cancelled) throw new Error('已取消')
|
| 114 |
+
if (encErr) throw encErr
|
| 115 |
+
const t = start + i / fps
|
| 116 |
+
film.renderFrame(t)
|
| 117 |
+
g.drawImage(film.renderer.domElement, 0, 0, W, H)
|
| 118 |
+
drawOverlay(g, t, W, H, o.overlay)
|
| 119 |
+
const frame = new VideoFrame(comp, { timestamp: Math.round((i * 1e6) / fps), duration: Math.round(1e6 / fps) })
|
| 120 |
+
ve.encode(frame, { keyFrame: i % (fps * 2) === 0 })
|
| 121 |
+
frame.close()
|
| 122 |
+
while (ve.encodeQueueSize > 3) await new Promise((r) => setTimeout(r, 2))
|
| 123 |
+
if (i % 3 === 0) {
|
| 124 |
+
const el = (performance.now() - t0) / 1000
|
| 125 |
+
o.onProgress((i + 1) / total, { frame: i + 1, total, eta: (el / (i + 1)) * (total - i - 1), stage: '渲染画面' })
|
| 126 |
+
await new Promise((r) => setTimeout(r, 0))
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
o.onProgress(1, { frame: total, total, eta: 0, stage: '封装 MP4' })
|
| 130 |
+
await ve.flush()
|
| 131 |
+
ve.close()
|
| 132 |
+
muxer.finalize()
|
| 133 |
+
} finally {
|
| 134 |
+
film.setSize(prevW, prevH)
|
| 135 |
+
if (ve.state !== 'closed') try { ve.close() } catch { /* ignore */ }
|
| 136 |
+
}
|
| 137 |
+
return { blob: new Blob([target.buffer], { type: 'video/mp4' }), audioCodec: acodec?.mux ?? null }
|
| 138 |
+
}
|
src/jiangnan/jiangnan.css
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.jn {
|
| 2 |
+
--ink: #0b0d14;
|
| 3 |
+
--ink2: #12151f;
|
| 4 |
+
--ink3: #1a1e2b;
|
| 5 |
+
--line: rgba(255, 236, 210, 0.1);
|
| 6 |
+
--line2: rgba(255, 236, 210, 0.18);
|
| 7 |
+
--paper: #f3eadb;
|
| 8 |
+
--muted: rgba(243, 234, 219, 0.62);
|
| 9 |
+
--dim: rgba(243, 234, 219, 0.42);
|
| 10 |
+
--amber: #e8a65a;
|
| 11 |
+
--red: #c8372d;
|
| 12 |
+
--ok: #7fc59a;
|
| 13 |
+
--bad: #ef7b6e;
|
| 14 |
+
min-height: 100vh;
|
| 15 |
+
background: radial-gradient(1200px 600px at 50% -10%, #241a1a 0%, var(--ink) 60%);
|
| 16 |
+
color: var(--paper);
|
| 17 |
+
font-family: 'JNBody', 'PingFang SC', 'Noto Serif SC', 'Songti SC', serif;
|
| 18 |
+
-webkit-font-smoothing: antialiased;
|
| 19 |
+
}
|
| 20 |
+
.jn * { box-sizing: border-box; }
|
| 21 |
+
.jn-wrap { max-width: 1180px; margin: 0 auto; padding: 28px 20px 80px; }
|
| 22 |
+
.jn-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; }
|
| 23 |
+
.jn-title { font-family: 'JNTitle', serif; font-size: 52px; line-height: 1; letter-spacing: 6px; margin: 0; color: #fff3e2; }
|
| 24 |
+
.jn-sub { color: var(--muted); font-size: 15px; margin-top: 10px; letter-spacing: 1px; }
|
| 25 |
+
.jn-seal { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--red); color: #fff1e6; font-family: 'JNTitle', serif; font-size: 14px; line-height: 1.05; border-radius: 4px; writing-mode: vertical-rl; margin-left: 12px; vertical-align: 10px; }
|
| 26 |
+
.jn-links { color: var(--dim); font-size: 13px; text-align: right; }
|
| 27 |
+
.jn-links a { color: var(--amber); text-decoration: none; }
|
| 28 |
+
|
| 29 |
+
/* player */
|
| 30 |
+
.jn-player { position: relative; margin: 0 auto; background: #000; border-radius: 14px; overflow: hidden; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--line); }
|
| 31 |
+
.jn-player.landscape { width: 100%; aspect-ratio: 16 / 9; }
|
| 32 |
+
.jn-player.portrait { height: min(78vh, 900px); aspect-ratio: 9 / 16; max-width: 100%; }
|
| 33 |
+
.jn-player canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
| 34 |
+
.jn-player:fullscreen { border-radius: 0; width: 100vw; height: 100vh; aspect-ratio: auto; display: flex; align-items: center; justify-content: center; background: #000; }
|
| 35 |
+
.jn-player:fullscreen canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
| 36 |
+
.jn-big-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: transparent; border: 0; cursor: pointer; }
|
| 37 |
+
.jn-big-play span { width: 84px; height: 84px; border-radius: 50%; background: rgba(12, 10, 14, 0.5); border: 1px solid rgba(255, 236, 210, 0.45); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(6px); transition: transform 0.2s; }
|
| 38 |
+
.jn-big-play:hover span { transform: scale(1.06); }
|
| 39 |
+
.jn-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: radial-gradient(circle at 50% 40%, #1d1716, #07080c); z-index: 5; text-align: center; padding: 20px; }
|
| 40 |
+
.jn-loading .t { font-family: 'JNTitle', serif; font-size: 40px; letter-spacing: 6px; }
|
| 41 |
+
.jn-bar { width: min(360px, 80%); height: 3px; background: rgba(255, 255, 255, 0.08); border-radius: 3px; overflow: hidden; }
|
| 42 |
+
.jn-bar i { display: block; height: 100%; background: linear-gradient(90deg, #c8372d, #e8a65a); transition: width 0.25s; }
|
| 43 |
+
.jn-loading small { color: var(--muted); font-size: 13px; }
|
| 44 |
+
|
| 45 |
+
.jn-controls { display: flex; align-items: center; gap: 10px; padding: 12px 4px 0; flex-wrap: wrap; }
|
| 46 |
+
.jn-btn { appearance: none; border: 1px solid var(--line2); background: var(--ink3); color: var(--paper); padding: 8px 14px; border-radius: 999px; font: inherit; font-size: 14px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; transition: background 0.15s, border-color 0.15s; white-space: nowrap; }
|
| 47 |
+
.jn-btn:hover { border-color: rgba(232, 166, 90, 0.6); }
|
| 48 |
+
.jn-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
| 49 |
+
.jn-btn.primary { background: linear-gradient(135deg, #c8372d, #b1522a); border-color: transparent; color: #fff4ea; }
|
| 50 |
+
.jn-btn.ghost { background: transparent; }
|
| 51 |
+
.jn-btn.on { border-color: var(--amber); color: #ffd9a8; }
|
| 52 |
+
.jn-time { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; min-width: 92px; }
|
| 53 |
+
.jn-scrub { flex: 1 1 260px; position: relative; height: 34px; display: flex; align-items: center; }
|
| 54 |
+
.jn-scrub input { width: 100%; accent-color: var(--amber); }
|
| 55 |
+
.jn-shots { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
| 56 |
+
.jn-chip { font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); background: transparent; cursor: pointer; font-family: inherit; }
|
| 57 |
+
.jn-chip.active { border-color: var(--amber); color: #ffd9a8; background: rgba(232, 166, 90, 0.08); }
|
| 58 |
+
|
| 59 |
+
/* sections */
|
| 60 |
+
.jn-sec { margin-top: 44px; border-top: 1px solid var(--line); padding-top: 26px; }
|
| 61 |
+
.jn-sec h2 { font-family: 'JNTitle', serif; font-weight: normal; font-size: 30px; letter-spacing: 3px; margin: 0 0 6px; color: #fff1dc; }
|
| 62 |
+
.jn-sec .lead { color: var(--muted); font-size: 14px; margin: 0 0 18px; line-height: 1.7; }
|
| 63 |
+
.jn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
| 64 |
+
.jn-card { background: var(--ink2); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
|
| 65 |
+
.jn-opt { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 14px; }
|
| 66 |
+
.jn-label { font-size: 13px; color: var(--dim); margin-bottom: 4px; letter-spacing: 1px; }
|
| 67 |
+
.jn-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
| 68 |
+
.jn-progress { margin-top: 14px; }
|
| 69 |
+
.jn-progress .jn-bar { width: 100%; height: 6px; }
|
| 70 |
+
.jn-progress small { display: block; margin-top: 8px; color: var(--muted); font-size: 13px; }
|
| 71 |
+
.jn-note { font-size: 12.5px; color: var(--dim); line-height: 1.7; margin-top: 10px; }
|
| 72 |
+
.jn-err { color: var(--bad); font-size: 13px; margin-top: 10px; }
|
| 73 |
+
.jn-result video { width: 100%; max-height: 420px; background: #000; border-radius: 10px; margin-top: 12px; }
|
| 74 |
+
|
| 75 |
+
/* covers */
|
| 76 |
+
.jn-covers { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 16px; }
|
| 77 |
+
.jn-cover { position: relative; border-radius: 10px; overflow: hidden; border: 2px solid transparent; background: #000; cursor: pointer; }
|
| 78 |
+
.jn-cover.sel { border-color: var(--amber); }
|
| 79 |
+
.jn-cover img { width: 100%; display: block; }
|
| 80 |
+
.jn-cover .meta { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; font-size: 12.5px; background: var(--ink2); color: var(--muted); gap: 8px; }
|
| 81 |
+
.jn-cover .chk { position: absolute; top: 8px; left: 8px; width: 24px; height: 24px; border-radius: 6px; background: rgba(0, 0, 0, 0.55); border: 1px solid rgba(255, 255, 255, 0.5); display: flex; align-items: center; justify-content: center; font-size: 15px; color: #ffd9a8; }
|
| 82 |
+
.jn-cover a { color: var(--amber); text-decoration: none; }
|
| 83 |
+
|
| 84 |
+
/* copy */
|
| 85 |
+
.jn-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
|
| 86 |
+
.jn-titles { display: flex; flex-direction: column; gap: 8px; }
|
| 87 |
+
.jn-tline { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--ink2); cursor: pointer; }
|
| 88 |
+
.jn-tline.sel { border-color: rgba(232, 166, 90, 0.7); }
|
| 89 |
+
.jn-tline .txt { flex: 1; font-size: 15px; line-height: 1.5; }
|
| 90 |
+
.jn-count { font-size: 12px; padding: 2px 8px; border-radius: 999px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
| 91 |
+
.jn-count.ok { color: var(--ok); background: rgba(127, 197, 154, 0.1); }
|
| 92 |
+
.jn-count.bad { color: var(--bad); background: rgba(239, 123, 110, 0.1); }
|
| 93 |
+
.jn-box { white-space: pre-wrap; background: var(--ink2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; font-size: 14px; line-height: 1.75; color: #efe5d4; }
|
| 94 |
+
.jn-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
| 95 |
+
.jn-tag { font-size: 13px; padding: 4px 10px; border-radius: 999px; background: rgba(200, 55, 45, 0.12); color: #ffc8b8; border: 1px solid rgba(200, 55, 45, 0.3); }
|
| 96 |
+
.jn-copyrow { display: flex; justify-content: space-between; align-items: center; margin: 18px 0 8px; gap: 10px; }
|
| 97 |
+
.jn-copyrow .jn-label { margin: 0; }
|
| 98 |
+
.jn-toast { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); background: rgba(20, 16, 18, 0.95); border: 1px solid var(--line2); color: var(--paper); padding: 10px 18px; border-radius: 999px; font-size: 14px; z-index: 100; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); }
|
| 99 |
+
.jn-live { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 14px; }
|
| 100 |
+
.jn-live figure { margin: 0; }
|
| 101 |
+
.jn-live img { width: 100%; border-radius: 8px; display: block; }
|
| 102 |
+
.jn-live figcaption { font-size: 12.5px; color: var(--muted); margin-top: 6px; }
|
| 103 |
+
.jn-foot { margin-top: 56px; color: var(--dim); font-size: 12.5px; line-height: 1.8; border-top: 1px solid var(--line); padding-top: 18px; }
|
| 104 |
+
.jn-foot code { color: var(--muted); }
|
| 105 |
+
@media (max-width: 640px) {
|
| 106 |
+
.jn-wrap { padding: 16px 12px 60px; }
|
| 107 |
+
.jn-title { font-size: 40px; }
|
| 108 |
+
.jn-player.portrait { height: auto; width: 100%; }
|
| 109 |
+
.jn-sec h2 { font-size: 26px; }
|
| 110 |
+
}
|
src/jiangnan/player/overlay.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Typography drawn on top of the 3D frame (title card, subtitles, end card).
|
| 2 |
+
// The same function is used for the live preview, MP4 export, covers and live photos.
|
| 3 |
+
import titleFont from '../assets/fonts/title.woff2?url'
|
| 4 |
+
import bodyFont from '../assets/fonts/wenkai.woff2?url'
|
| 5 |
+
import { cueAt, DURATION, endAlpha, SUBTITLE, TITLE, titleAlpha } from '../engine/timeline'
|
| 6 |
+
|
| 7 |
+
export const FONT_TITLE = 'JNTitle'
|
| 8 |
+
export const FONT_BODY = 'JNBody'
|
| 9 |
+
let fontsReady: Promise<void> | null = null
|
| 10 |
+
|
| 11 |
+
export function loadFonts() {
|
| 12 |
+
if (fontsReady) return fontsReady
|
| 13 |
+
fontsReady = (async () => {
|
| 14 |
+
const faces = [new FontFace(FONT_TITLE, `url(${titleFont})`), new FontFace(FONT_BODY, `url(${bodyFont})`)]
|
| 15 |
+
await Promise.all(faces.map(async (f) => { await f.load(); document.fonts.add(f) }))
|
| 16 |
+
})().catch(() => undefined)
|
| 17 |
+
return fontsReady
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const clamp = (v: number) => Math.max(0, Math.min(1, v))
|
| 21 |
+
|
| 22 |
+
/** Red square seal (印章) with two characters. */
|
| 23 |
+
export function drawSeal(g: CanvasRenderingContext2D, x: number, y: number, size: number, text = '江南', alpha = 1) {
|
| 24 |
+
g.save()
|
| 25 |
+
g.globalAlpha *= alpha
|
| 26 |
+
g.fillStyle = '#b3261e'
|
| 27 |
+
const r = size * 0.08
|
| 28 |
+
g.beginPath()
|
| 29 |
+
g.roundRect(x, y, size, size, r)
|
| 30 |
+
g.fill()
|
| 31 |
+
g.strokeStyle = 'rgba(255,235,220,0.9)'
|
| 32 |
+
g.lineWidth = size * 0.04
|
| 33 |
+
g.strokeRect(x + size * 0.1, y + size * 0.1, size * 0.8, size * 0.8)
|
| 34 |
+
g.fillStyle = '#fff1e6'
|
| 35 |
+
g.font = `${size * 0.36}px ${FONT_TITLE}, serif`
|
| 36 |
+
g.textAlign = 'center'
|
| 37 |
+
g.textBaseline = 'middle'
|
| 38 |
+
const chars = Array.from(text)
|
| 39 |
+
if (chars.length === 2) {
|
| 40 |
+
g.fillText(chars[0], x + size / 2, y + size * 0.32)
|
| 41 |
+
g.fillText(chars[1], x + size / 2, y + size * 0.7)
|
| 42 |
+
} else g.fillText(text, x + size / 2, y + size / 2)
|
| 43 |
+
g.restore()
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function spaced(g: CanvasRenderingContext2D, text: string, cx: number, y: number, spacing: number) {
|
| 47 |
+
const chars = Array.from(text)
|
| 48 |
+
const widths = chars.map((c) => g.measureText(c).width)
|
| 49 |
+
const total = widths.reduce((a, b) => a + b, 0) + spacing * (chars.length - 1)
|
| 50 |
+
let x = cx - total / 2
|
| 51 |
+
chars.forEach((c, i) => {
|
| 52 |
+
g.fillText(c, x + widths[i] / 2, y)
|
| 53 |
+
x += widths[i] + spacing
|
| 54 |
+
})
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export interface OverlayOpts { subtitles: boolean; titles: boolean }
|
| 58 |
+
|
| 59 |
+
export function drawOverlay(g: CanvasRenderingContext2D, t: number, W: number, H: number, opts: OverlayOpts = { subtitles: true, titles: true }) {
|
| 60 |
+
const portrait = H > W
|
| 61 |
+
const unit = Math.min(W, H)
|
| 62 |
+
g.save()
|
| 63 |
+
g.textAlign = 'center'
|
| 64 |
+
g.textBaseline = 'middle'
|
| 65 |
+
// ---- opening title
|
| 66 |
+
const ta = opts.titles ? titleAlpha(t) : 0
|
| 67 |
+
if (ta > 0.001) {
|
| 68 |
+
const rise = (1 - ta) * unit * 0.015
|
| 69 |
+
const cy = portrait ? H * 0.36 : H * 0.42
|
| 70 |
+
g.globalAlpha = ta
|
| 71 |
+
g.shadowColor = 'rgba(40,16,0,0.55)'
|
| 72 |
+
g.shadowBlur = unit * 0.04
|
| 73 |
+
g.fillStyle = '#fff6e8'
|
| 74 |
+
g.font = `${unit * (portrait ? 0.2 : 0.17)}px ${FONT_TITLE}, serif`
|
| 75 |
+
spaced(g, TITLE, W / 2, cy + rise, unit * 0.025)
|
| 76 |
+
g.shadowBlur = unit * 0.02
|
| 77 |
+
g.font = `${unit * 0.038}px ${FONT_BODY}, serif`
|
| 78 |
+
g.fillStyle = 'rgba(255,240,220,0.92)'
|
| 79 |
+
spaced(g, SUBTITLE, W / 2, cy + unit * (portrait ? 0.17 : 0.15) + rise, unit * 0.012)
|
| 80 |
+
g.shadowBlur = 0
|
| 81 |
+
drawSeal(g, W / 2 + unit * (portrait ? 0.3 : 0.27), cy - unit * 0.1 + rise, unit * 0.06, '江南', ta)
|
| 82 |
+
}
|
| 83 |
+
// ---- end card
|
| 84 |
+
const ea = opts.titles ? endAlpha(t) : 0
|
| 85 |
+
if (ea > 0.001) {
|
| 86 |
+
g.globalAlpha = ea
|
| 87 |
+
g.shadowColor = 'rgba(0,0,0,0.6)'
|
| 88 |
+
g.shadowBlur = unit * 0.03
|
| 89 |
+
g.fillStyle = '#fff3e0'
|
| 90 |
+
const cy = portrait ? H * 0.3 : H * 0.36
|
| 91 |
+
g.font = `${unit * 0.11}px ${FONT_TITLE}, serif`
|
| 92 |
+
spaced(g, TITLE, W / 2, cy, unit * 0.018)
|
| 93 |
+
g.font = `${unit * 0.03}px ${FONT_BODY}, serif`
|
| 94 |
+
g.fillStyle = 'rgba(255,236,210,0.85)'
|
| 95 |
+
spaced(g, '原创 3D 动画 · 程序化建模 · 实时渲染', W / 2, cy + unit * 0.11, unit * 0.006)
|
| 96 |
+
spaced(g, '配乐 · 音效 · 旁白 原创', W / 2, cy + unit * 0.16, unit * 0.006)
|
| 97 |
+
g.shadowBlur = 0
|
| 98 |
+
drawSeal(g, W / 2 - unit * 0.03, cy + unit * 0.21, unit * 0.06, '暮归', ea)
|
| 99 |
+
}
|
| 100 |
+
// ---- subtitles
|
| 101 |
+
const c = opts.subtitles ? cueAt(t) : null
|
| 102 |
+
if (c) {
|
| 103 |
+
const a = clamp((t - c.t + 0.15) / 0.3) * clamp((c.t + c.dur + 0.5 - t) / 0.35)
|
| 104 |
+
if (a > 0.001) {
|
| 105 |
+
const size = portrait ? W * 0.046 : H * 0.043
|
| 106 |
+
g.globalAlpha = a
|
| 107 |
+
g.font = `${size}px ${FONT_BODY}, serif`
|
| 108 |
+
const maxW = W * (portrait ? 0.84 : 0.8)
|
| 109 |
+
const lines = wrap(g, c.text, maxW)
|
| 110 |
+
const y0 = portrait ? H * 0.74 : H * 0.88
|
| 111 |
+
lines.forEach((ln, i) => {
|
| 112 |
+
const y = y0 + (i - (lines.length - 1)) * size * 1.35
|
| 113 |
+
g.shadowColor = 'rgba(0,0,0,0.85)'
|
| 114 |
+
g.shadowBlur = size * 0.35
|
| 115 |
+
g.lineWidth = size * 0.08
|
| 116 |
+
g.strokeStyle = 'rgba(0,0,0,0.35)'
|
| 117 |
+
g.strokeText(ln, W / 2, y)
|
| 118 |
+
g.fillStyle = '#fbf3e4'
|
| 119 |
+
g.fillText(ln, W / 2, y)
|
| 120 |
+
})
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
g.restore()
|
| 124 |
+
void DURATION
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/** Wrap Chinese text at punctuation where possible. */
|
| 128 |
+
export function wrap(g: CanvasRenderingContext2D, text: string, maxW: number): string[] {
|
| 129 |
+
if (g.measureText(text).width <= maxW) return [text]
|
| 130 |
+
const chars = Array.from(text)
|
| 131 |
+
// try to split at the punctuation closest to the middle
|
| 132 |
+
let best = -1, bd = 1e9
|
| 133 |
+
chars.forEach((ch, i) => {
|
| 134 |
+
if (',。、;:'.includes(ch) && i > 2 && i < chars.length - 2) {
|
| 135 |
+
const d = Math.abs(i - chars.length / 2)
|
| 136 |
+
if (d < bd) { bd = d; best = i }
|
| 137 |
+
}
|
| 138 |
+
})
|
| 139 |
+
const mid = best >= 0 ? best + 1 : Math.ceil(chars.length / 2)
|
| 140 |
+
const a = chars.slice(0, mid).join(''), b = chars.slice(mid).join('')
|
| 141 |
+
return [...wrap(g, a, maxW), ...wrap(g, b, maxW)]
|
| 142 |
+
}
|
tools/jiangnan/analyze.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Blind-QA for the film: renders stills + category-ID maps in headless Chromium,
|
| 2 |
+
# then prints ASCII composition maps, luminance maps and colour statistics.
|
| 3 |
+
import base64, io, os, sys, time
|
| 4 |
+
from playwright.sync_api import sync_playwright
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
BASE = 'http://localhost:5173/preview/production/0f8da72d-f0f5-4c30-b791-36769aa8c157/'
|
| 8 |
+
CATS = {
|
| 9 |
+
'S': 0x6080ff, 'M': 0x304060, 'T': 0x806040, 'F': 0xc0c040, 'W': 0x00c0ff, 'H': 0xffffff, 'R': 0x202020, 'D': 0x8b4513,
|
| 10 |
+
'K': 0x909090, 'Y': 0xffff00, 'V': 0x00a000, 'L': 0xff0000, 'P': 0xff00ff, 'C': 0x0000a0, 'O': 0x00ffa0, 'X': 0x404040,
|
| 11 |
+
}
|
| 12 |
+
CRGB = {k: ((v >> 16) & 255, (v >> 8) & 255, v & 255) for k, v in CATS.items()}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def classify(px):
|
| 16 |
+
best, bd = '.', 1e9
|
| 17 |
+
for k, c in CRGB.items():
|
| 18 |
+
d = (px[0] - c[0]) ** 2 + (px[1] - c[1]) ** 2 + (px[2] - c[2]) ** 2
|
| 19 |
+
if d < bd:
|
| 20 |
+
bd, best = d, k
|
| 21 |
+
return best if bd < 3000 else '?'
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def ascii_ids(img, cols=64):
|
| 25 |
+
w, h = img.size
|
| 26 |
+
rows = round(cols * h / w * 0.5)
|
| 27 |
+
small = img.resize((cols * 3, rows * 3), Image.NEAREST).convert('RGB')
|
| 28 |
+
out = []
|
| 29 |
+
for r in range(rows):
|
| 30 |
+
line = ''
|
| 31 |
+
for c in range(cols):
|
| 32 |
+
cnt = {}
|
| 33 |
+
for dy in range(3):
|
| 34 |
+
for dx in range(3):
|
| 35 |
+
k = classify(small.getpixel((c * 3 + dx, r * 3 + dy)))
|
| 36 |
+
cnt[k] = cnt.get(k, 0) + 1
|
| 37 |
+
line += max(cnt, key=cnt.get)
|
| 38 |
+
out.append(line)
|
| 39 |
+
return '\n'.join(out)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def ascii_lum(img, cols=64):
|
| 43 |
+
w, h = img.size
|
| 44 |
+
rows = round(cols * h / w * 0.5)
|
| 45 |
+
g = img.convert('L').resize((cols, rows), Image.BOX)
|
| 46 |
+
chars = ' .:-=+*#%@'
|
| 47 |
+
return '\n'.join(''.join(chars[min(9, g.getpixel((c, r)) * 10 // 256)] for c in range(cols)) for r in range(rows))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def stats(img):
|
| 51 |
+
im = img.convert('RGB')
|
| 52 |
+
w, h = im.size
|
| 53 |
+
res = []
|
| 54 |
+
for name, y0, y1 in [('top', 0, h // 3), ('mid', h // 3, 2 * h // 3), ('bot', 2 * h // 3, h)]:
|
| 55 |
+
crop = im.crop((0, y0, w, y1)).resize((32, 16), Image.BOX)
|
| 56 |
+
px = list(crop.getdata())
|
| 57 |
+
m = [sum(p[i] for p in px) / len(px) for i in range(3)]
|
| 58 |
+
res.append(f"{name} rgb=({m[0]:.0f},{m[1]:.0f},{m[2]:.0f})")
|
| 59 |
+
L = list(im.convert('L').resize((240, 135)).getdata())
|
| 60 |
+
clip = sum(1 for v in L if v > 248) / len(L)
|
| 61 |
+
crush = sum(1 for v in L if v < 6) / len(L)
|
| 62 |
+
mean = sum(L) / len(L)
|
| 63 |
+
return ' | '.join(res) + f" | meanL={mean:.0f} clip={clip*100:.1f}% crush={crush*100:.1f}%"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main():
|
| 67 |
+
times = [float(x) for x in sys.argv[1].split(',')]
|
| 68 |
+
w = int(sys.argv[2]) if len(sys.argv) > 2 else 640
|
| 69 |
+
h = int(sys.argv[3]) if len(sys.argv) > 3 else 360
|
| 70 |
+
mode = sys.argv[4] if len(sys.argv) > 4 else 'both'
|
| 71 |
+
out = '/tmp/shots'
|
| 72 |
+
os.makedirs(out, exist_ok=True)
|
| 73 |
+
with sync_playwright() as p:
|
| 74 |
+
b = p.chromium.launch(args=['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-dev-shm-usage'])
|
| 75 |
+
pg = b.new_page(viewport={'width': w, 'height': h})
|
| 76 |
+
logs = []
|
| 77 |
+
pg.on('console', lambda m: logs.append(m.type + ': ' + m.text) if m.type in ('error', 'warning') else None)
|
| 78 |
+
pg.on('pageerror', lambda e: logs.append('PAGEERROR: ' + str(e)))
|
| 79 |
+
t0 = time.time()
|
| 80 |
+
pg.goto(f'{BASE}?w={w}&h={h}&t={times[0]}', timeout=180000)
|
| 81 |
+
pg.wait_for_function("document.title === 'ready' || document.title === 'error'", timeout=900000)
|
| 82 |
+
print('init', round(time.time() - t0, 1), 's', pg.title())
|
| 83 |
+
for t in times:
|
| 84 |
+
print(f'\n===== t={t} =====')
|
| 85 |
+
if mode in ('both', 'final'):
|
| 86 |
+
pg.evaluate(f"window.__film.renderFrame({t})")
|
| 87 |
+
data = pg.evaluate("document.querySelector('canvas').toDataURL('image/jpeg', 0.9)")
|
| 88 |
+
img = Image.open(io.BytesIO(base64.b64decode(data.split(',')[1])))
|
| 89 |
+
img.save(f'{out}/t{t:05.1f}.jpg')
|
| 90 |
+
print(stats(img))
|
| 91 |
+
print(ascii_lum(img))
|
| 92 |
+
if mode in ('both', 'ids'):
|
| 93 |
+
pg.evaluate(f"window.__ids({t})")
|
| 94 |
+
data = pg.evaluate("document.querySelector('canvas').toDataURL('image/png')")
|
| 95 |
+
img = Image.open(io.BytesIO(base64.b64decode(data.split(',')[1])))
|
| 96 |
+
img.save(f'{out}/ids{t:05.1f}.png')
|
| 97 |
+
print(ascii_ids(img))
|
| 98 |
+
for l in [x for x in logs if 'useProgram' not in x][-20:]:
|
| 99 |
+
print(l)
|
| 100 |
+
b.close()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == '__main__':
|
| 104 |
+
main()
|
tools/jiangnan/subset_fonts.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Subset the OFL Chinese fonts to the characters actually used by the film/UI.
|
| 2 |
+
import glob, re, subprocess, sys
|
| 3 |
+
text = ''
|
| 4 |
+
for f in glob.glob('/workspaces/frontend/src/jiangnan/**/*.ts*', recursive=True):
|
| 5 |
+
text += open(f, encoding='utf-8').read()
|
| 6 |
+
chars = sorted(set(c for c in text if ord(c) > 0x2E80))
|
| 7 |
+
extra = '0123456789::.,,。、!?!?·—…()()《》「」“”‘’ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-+/%#&@ '
|
| 8 |
+
open('/opt/work/chars.txt', 'w', encoding='utf-8').write(''.join(chars) + extra)
|
| 9 |
+
print(len(chars), 'CJK chars')
|
| 10 |
+
for src, out in [('/tmp/fonts/MaShanZheng-Regular.ttf', 'title.woff2'), ('/tmp/fonts/LXGWWenKai-Regular.ttf', 'wenkai.woff2')]:
|
| 11 |
+
subprocess.run([sys.executable, '-m', 'fontTools.subset', src, '--text-file=/opt/work/chars.txt', '--flavor=woff2',
|
| 12 |
+
f'--output-file=/workspaces/frontend/src/jiangnan/assets/fonts/{out}', '--layout-features=*'], check=True)
|
tools/jiangnan/tts.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generate narration lines with a neural Chinese voice (edge-tts) and print durations.
|
| 2 |
+
import asyncio, re, subprocess, sys, json
|
| 3 |
+
import edge_tts, imageio_ffmpeg
|
| 4 |
+
LINES = json.loads(open(sys.argv[1], encoding='utf-8').read())
|
| 5 |
+
OUT = sys.argv[2]
|
| 6 |
+
VOICE = sys.argv[3] if len(sys.argv) > 3 else 'zh-CN-YunyangNeural'
|
| 7 |
+
FF = imageio_ffmpeg.get_ffmpeg_exe()
|
| 8 |
+
async def main():
|
| 9 |
+
res = {}
|
| 10 |
+
for lid, text in LINES.items():
|
| 11 |
+
c = edge_tts.Communicate(text, VOICE, rate='-12%', pitch='-3Hz')
|
| 12 |
+
await c.save(f'{OUT}/{lid}.mp3')
|
| 13 |
+
info = subprocess.run([FF, '-i', f'{OUT}/{lid}.mp3'], capture_output=True, text=True).stderr
|
| 14 |
+
m = re.search(r'Duration: (\d+):(\d+):([\d.]+)', info)
|
| 15 |
+
res[lid] = round(int(m.group(2)) * 60 + float(m.group(3)), 2)
|
| 16 |
+
print(json.dumps(res))
|
| 17 |
+
asyncio.run(main())
|
tools/jiangnan/verify_live_photo.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys, json, hashlib
|
| 2 |
+
sys.path.insert(0, '/tmp/lc/scripts')
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from pair_live_photo import verify_pair, digest
|
| 5 |
+
from portable_live_photo.apple import inspect_live_jpeg, inspect_live_mov
|
| 6 |
+
d = Path(sys.argv[1]); cover = float(sys.argv[2]); fps = float(sys.argv[3])
|
| 7 |
+
ident = inspect_live_jpeg(d / 'live.jpg')
|
| 8 |
+
info = inspect_live_mov(d / 'live.mov')
|
| 9 |
+
print('jpeg id', ident); print('mov', info)
|
| 10 |
+
manifest = {'identifier': ident, 'photo': 'live.jpg', 'video': 'live.mov', 'coverTime': cover, 'fps': fps,
|
| 11 |
+
'backend': 'portable-python', 'metadataVerified': True, 'localPHLivePhotoLoad': 'not_available',
|
| 12 |
+
'sha256': {'photo': digest(d / 'live.jpg'), 'video': digest(d / 'live.mov')}}
|
| 13 |
+
r = verify_pair(d, manifest)
|
| 14 |
+
print('verify_pair OK, still time', r.still_time)
|
| 15 |
+
(d / 'manifest.json').write_text(json.dumps(manifest, indent=2))
|