Ethscriptions commited on
Commit
4b7c857
·
verified ·
1 Parent(s): 2b0432b

暮归江南 · 步骤4-9:生命感、声音、播放器与MP4导出、封面文案、实况图、Blender脚本

Browse files
.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 { Film, renderIds } from './engine/Film'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- // Temporary test harness (replaced by the full player UI)
5
  export default function JiangnanApp() {
6
- const ref = useRef<HTMLCanvasElement>(null)
7
- const [status, setStatus] = useState('加载中…')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  useEffect(() => {
9
- const canvas = ref.current!
10
- const film = new Film(canvas, 'high')
11
  const q = new URLSearchParams(location.search)
12
- const w = Number(q.get('w') || 960), h = Number(q.get('h') || 540)
13
- film.width = w
14
- film.height = h
15
- ;(window as unknown as { __film: Film }).__film = film
16
- ;(window as unknown as { __ids: (t: number) => void }).__ids = (t: number) => renderIds(film, t)
17
- film.init((p, l) => setStatus(`${Math.round(p * 100)}% ${l}`)).then(() => {
18
- film.setSize(w, h)
19
- film.renderFrame(Number(q.get('t') || 5))
20
- setStatus('ready')
21
- document.title = 'ready'
22
- }).catch((e) => { setStatus('error: ' + e.message); document.title = 'error'; console.error(e) })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  return (
25
- <div style={{ background: '#000', minHeight: '100vh' }}>
26
- <canvas ref={ref} style={{ width: '100%', display: 'block' }} />
27
- <div id="status" style={{ color: '#fff', position: 'fixed', top: 4, left: 4, fontSize: 12 }}>{status}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(34, 2.2, 24), V(26, 3.6, 17), 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,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(240, 150, 210), e)
125
- const target = mix(V(-10, 3, -4), V(-60, 0, -20), e)
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(-262), -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,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(-238) + half) return true // entrance plaza with the camphor tree
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 + 4, GROUND_Y, tp.z - 15)
379
- const rot = 0.35
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 [-6, 0, 6]) 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, 13, 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,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 < 14; k++) {
399
  const row = k % 2
400
- const x = -5.6 + 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: `
 
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, 2.1, smoothstep(1, -9, elev)),
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.0 },
65
- { id: 'n02', t: 9.8, text: '一条小河,把整座村子轻轻揽在怀里。', dur: 3.6 },
66
- { id: 'n03', t: 17.8, text: '石桥弯成半个月亮,水里,再补上另外半个。', dur: 4.6 },
67
- { id: 'n04', t: 25.6, text: '老水车吱呀吱呀地转,把一天的光阴,一勺一勺舀回河里。', dur: 5.6 },
68
- { id: 'n05', t: 33.0, text: '集市还没散。竹篮、陶罐、檐下的腊肉,都染上了夕阳的颜色。', dur: 6.0 },
69
- { id: 'n06', t: 40.8, text: '村口的老樟树站了几百年,看着人们挑着担子出去,又挑着担子回来。', dur: 6.2 },
70
- { id: 'n07', t: 48.6, text: '远山的梯田一层层暗下去,白鹭,也该回竹林了。', dur: 4.8 },
71
- { id: 'n08', t: 56.0, text: '炊烟升起来的时候,第一盏灯笼亮了。一盏,接着一盏。', dur: 5.6 },
72
- { id: 'n09', t: 66.0, text: '灯火落进河里,整条河,都暖了。', dur: 3.6 },
73
- { id: 'n10', t: 75.0, text: '星星出来了。水乡睡在一层薄薄的雾里——这里,是家的方向。', dur: 6.6 },
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))