dsh-creation-mode 0.0.0-stage → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (369) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +26 -2
  3. package/client.js +1581 -0
  4. package/cordis.patch.yml +106 -0
  5. package/data.js +386 -0
  6. package/flow.js +263 -0
  7. package/icon.svg +13 -0
  8. package/index.js +344 -0
  9. package/locale/en.json +6 -0
  10. package/locale/zh.json +6 -0
  11. package/package.json +53 -4
  12. package/references/audio.md +167 -0
  13. package/references/camera.md +168 -0
  14. package/references/color.md +272 -0
  15. package/references/copy.md +121 -0
  16. package/references/data-viz.md +189 -0
  17. package/references/effects.md +236 -0
  18. package/references/motion.md +322 -0
  19. package/references/particles.md +166 -0
  20. package/references/sequencing.md +143 -0
  21. package/references/text-animation.md +213 -0
  22. package/references/timbre.md +182 -0
  23. package/references/transitions.md +231 -0
  24. package/references/typography.md +276 -0
  25. package/stills/A1.jpg +0 -0
  26. package/stills/A2.jpg +0 -0
  27. package/stills/A3.jpg +0 -0
  28. package/stills/A4.jpg +0 -0
  29. package/stills/A5.jpg +0 -0
  30. package/stills/B1.jpg +0 -0
  31. package/stills/B2.jpg +0 -0
  32. package/stills/B3.jpg +0 -0
  33. package/stills/B4.jpg +0 -0
  34. package/stills/B5.jpg +0 -0
  35. package/stills/C1.jpg +0 -0
  36. package/stills/C2.jpg +0 -0
  37. package/stills/C3.jpg +0 -0
  38. package/stills/C4.jpg +0 -0
  39. package/stills/D1.jpg +0 -0
  40. package/stills/D2.jpg +0 -0
  41. package/stills/D3.jpg +0 -0
  42. package/stills/D4.jpg +0 -0
  43. package/stills/E1.jpg +0 -0
  44. package/stills/E2.jpg +0 -0
  45. package/stills/E3.jpg +0 -0
  46. package/stills/F1.jpg +0 -0
  47. package/stills/F2.jpg +0 -0
  48. package/stills/F3.jpg +0 -0
  49. package/stills/G1.jpg +0 -0
  50. package/stills/G2.jpg +0 -0
  51. package/stills/H1.jpg +0 -0
  52. package/stills/H10.jpg +0 -0
  53. package/stills/H11.jpg +0 -0
  54. package/stills/H12.jpg +0 -0
  55. package/stills/H2.jpg +0 -0
  56. package/stills/H3.jpg +0 -0
  57. package/stills/H4.jpg +0 -0
  58. package/stills/H5.jpg +0 -0
  59. package/stills/H6.jpg +0 -0
  60. package/stills/H7.jpg +0 -0
  61. package/stills/H8.jpg +0 -0
  62. package/stills/H9.jpg +0 -0
  63. package/stills/K1.jpg +0 -0
  64. package/stills/K10.jpg +0 -0
  65. package/stills/K11.jpg +0 -0
  66. package/stills/K12.jpg +0 -0
  67. package/stills/K13.jpg +0 -0
  68. package/stills/K14.jpg +0 -0
  69. package/stills/K15.jpg +0 -0
  70. package/stills/K16.jpg +0 -0
  71. package/stills/K17.jpg +0 -0
  72. package/stills/K18.jpg +0 -0
  73. package/stills/K19.jpg +0 -0
  74. package/stills/K2.jpg +0 -0
  75. package/stills/K20.jpg +0 -0
  76. package/stills/K21.jpg +0 -0
  77. package/stills/K22.jpg +0 -0
  78. package/stills/K23.jpg +0 -0
  79. package/stills/K24.jpg +0 -0
  80. package/stills/K25.jpg +0 -0
  81. package/stills/K26.jpg +0 -0
  82. package/stills/K27.jpg +0 -0
  83. package/stills/K28.jpg +0 -0
  84. package/stills/K29.jpg +0 -0
  85. package/stills/K3.jpg +0 -0
  86. package/stills/K30.jpg +0 -0
  87. package/stills/K31.jpg +0 -0
  88. package/stills/K32.jpg +0 -0
  89. package/stills/K33.jpg +0 -0
  90. package/stills/K34.jpg +0 -0
  91. package/stills/K35.jpg +0 -0
  92. package/stills/K36.jpg +0 -0
  93. package/stills/K37.jpg +0 -0
  94. package/stills/K38.jpg +0 -0
  95. package/stills/K4.jpg +0 -0
  96. package/stills/K5.jpg +0 -0
  97. package/stills/K6.jpg +0 -0
  98. package/stills/K7.jpg +0 -0
  99. package/stills/K8.jpg +0 -0
  100. package/stills/K9.jpg +0 -0
  101. package/stills/L1.jpg +0 -0
  102. package/stills/L10.jpg +0 -0
  103. package/stills/L11.jpg +0 -0
  104. package/stills/L12.jpg +0 -0
  105. package/stills/L13.jpg +0 -0
  106. package/stills/L14.jpg +0 -0
  107. package/stills/L15.jpg +0 -0
  108. package/stills/L16.jpg +0 -0
  109. package/stills/L17.jpg +0 -0
  110. package/stills/L18.jpg +0 -0
  111. package/stills/L19.jpg +0 -0
  112. package/stills/L2.jpg +0 -0
  113. package/stills/L20.jpg +0 -0
  114. package/stills/L21.jpg +0 -0
  115. package/stills/L22.jpg +0 -0
  116. package/stills/L23.jpg +0 -0
  117. package/stills/L24.jpg +0 -0
  118. package/stills/L25.jpg +0 -0
  119. package/stills/L26.jpg +0 -0
  120. package/stills/L27.jpg +0 -0
  121. package/stills/L28.jpg +0 -0
  122. package/stills/L29.jpg +0 -0
  123. package/stills/L3.jpg +0 -0
  124. package/stills/L30.jpg +0 -0
  125. package/stills/L31.jpg +0 -0
  126. package/stills/L32.jpg +0 -0
  127. package/stills/L33.jpg +0 -0
  128. package/stills/L34.jpg +0 -0
  129. package/stills/L35.jpg +0 -0
  130. package/stills/L36.jpg +0 -0
  131. package/stills/L37.jpg +0 -0
  132. package/stills/L38.jpg +0 -0
  133. package/stills/L39.jpg +0 -0
  134. package/stills/L4.jpg +0 -0
  135. package/stills/L40.jpg +0 -0
  136. package/stills/L41.jpg +0 -0
  137. package/stills/L42.jpg +0 -0
  138. package/stills/L43.jpg +0 -0
  139. package/stills/L44.jpg +0 -0
  140. package/stills/L45.jpg +0 -0
  141. package/stills/L46.jpg +0 -0
  142. package/stills/L47.jpg +0 -0
  143. package/stills/L48.jpg +0 -0
  144. package/stills/L49.jpg +0 -0
  145. package/stills/L5.jpg +0 -0
  146. package/stills/L50.jpg +0 -0
  147. package/stills/L51.jpg +0 -0
  148. package/stills/L52.jpg +0 -0
  149. package/stills/L53.jpg +0 -0
  150. package/stills/L54.jpg +0 -0
  151. package/stills/L55.jpg +0 -0
  152. package/stills/L56.jpg +0 -0
  153. package/stills/L57.jpg +0 -0
  154. package/stills/L58.jpg +0 -0
  155. package/stills/L59.jpg +0 -0
  156. package/stills/L6.jpg +0 -0
  157. package/stills/L7.jpg +0 -0
  158. package/stills/L8.jpg +0 -0
  159. package/stills/L9.jpg +0 -0
  160. package/stills/M1.jpg +0 -0
  161. package/stills/M10.jpg +0 -0
  162. package/stills/M11.jpg +0 -0
  163. package/stills/M12.jpg +0 -0
  164. package/stills/M13.jpg +0 -0
  165. package/stills/M14.jpg +0 -0
  166. package/stills/M15.jpg +0 -0
  167. package/stills/M16.jpg +0 -0
  168. package/stills/M17.jpg +0 -0
  169. package/stills/M18.jpg +0 -0
  170. package/stills/M19.jpg +0 -0
  171. package/stills/M2.jpg +0 -0
  172. package/stills/M20.jpg +0 -0
  173. package/stills/M21.jpg +0 -0
  174. package/stills/M22.jpg +0 -0
  175. package/stills/M23.jpg +0 -0
  176. package/stills/M24.jpg +0 -0
  177. package/stills/M25.jpg +0 -0
  178. package/stills/M26.jpg +0 -0
  179. package/stills/M27.jpg +0 -0
  180. package/stills/M28.jpg +0 -0
  181. package/stills/M29.jpg +0 -0
  182. package/stills/M3.jpg +0 -0
  183. package/stills/M30.jpg +0 -0
  184. package/stills/M31.jpg +0 -0
  185. package/stills/M32.jpg +0 -0
  186. package/stills/M33.jpg +0 -0
  187. package/stills/M34.jpg +0 -0
  188. package/stills/M35.jpg +0 -0
  189. package/stills/M36.jpg +0 -0
  190. package/stills/M37.jpg +0 -0
  191. package/stills/M38.jpg +0 -0
  192. package/stills/M39.jpg +0 -0
  193. package/stills/M4.jpg +0 -0
  194. package/stills/M40.jpg +0 -0
  195. package/stills/M5.jpg +0 -0
  196. package/stills/M6.jpg +0 -0
  197. package/stills/M7.jpg +0 -0
  198. package/stills/M8.jpg +0 -0
  199. package/stills/M9.jpg +0 -0
  200. package/stills/O1.jpg +0 -0
  201. package/stills/O10.jpg +0 -0
  202. package/stills/O11.jpg +0 -0
  203. package/stills/O12.jpg +0 -0
  204. package/stills/O13.jpg +0 -0
  205. package/stills/O14.jpg +0 -0
  206. package/stills/O15.jpg +0 -0
  207. package/stills/O16.jpg +0 -0
  208. package/stills/O17.jpg +0 -0
  209. package/stills/O18.jpg +0 -0
  210. package/stills/O19.jpg +0 -0
  211. package/stills/O2.jpg +0 -0
  212. package/stills/O20.jpg +0 -0
  213. package/stills/O21.jpg +0 -0
  214. package/stills/O22.jpg +0 -0
  215. package/stills/O23.jpg +0 -0
  216. package/stills/O24.jpg +0 -0
  217. package/stills/O25.jpg +0 -0
  218. package/stills/O26.jpg +0 -0
  219. package/stills/O27.jpg +0 -0
  220. package/stills/O28.jpg +0 -0
  221. package/stills/O29.jpg +0 -0
  222. package/stills/O3.jpg +0 -0
  223. package/stills/O30.jpg +0 -0
  224. package/stills/O31.jpg +0 -0
  225. package/stills/O32.jpg +0 -0
  226. package/stills/O33.jpg +0 -0
  227. package/stills/O34.jpg +0 -0
  228. package/stills/O35.jpg +0 -0
  229. package/stills/O36.jpg +0 -0
  230. package/stills/O37.jpg +0 -0
  231. package/stills/O38.jpg +0 -0
  232. package/stills/O39.jpg +0 -0
  233. package/stills/O4.jpg +0 -0
  234. package/stills/O40.jpg +0 -0
  235. package/stills/O41.jpg +0 -0
  236. package/stills/O42.jpg +0 -0
  237. package/stills/O43.jpg +0 -0
  238. package/stills/O44.jpg +0 -0
  239. package/stills/O45.jpg +0 -0
  240. package/stills/O46.jpg +0 -0
  241. package/stills/O47.jpg +0 -0
  242. package/stills/O48.jpg +0 -0
  243. package/stills/O49.jpg +0 -0
  244. package/stills/O5.jpg +0 -0
  245. package/stills/O50.jpg +0 -0
  246. package/stills/O51.jpg +0 -0
  247. package/stills/O52.jpg +0 -0
  248. package/stills/O53.jpg +0 -0
  249. package/stills/O54.jpg +0 -0
  250. package/stills/O55.jpg +0 -0
  251. package/stills/O56.jpg +0 -0
  252. package/stills/O57.jpg +0 -0
  253. package/stills/O58.jpg +0 -0
  254. package/stills/O59.jpg +0 -0
  255. package/stills/O6.jpg +0 -0
  256. package/stills/O60.jpg +0 -0
  257. package/stills/O61.jpg +0 -0
  258. package/stills/O62.jpg +0 -0
  259. package/stills/O63.jpg +0 -0
  260. package/stills/O64.jpg +0 -0
  261. package/stills/O65.jpg +0 -0
  262. package/stills/O66.jpg +0 -0
  263. package/stills/O67.jpg +0 -0
  264. package/stills/O68.jpg +0 -0
  265. package/stills/O69.jpg +0 -0
  266. package/stills/O7.jpg +0 -0
  267. package/stills/O70.jpg +0 -0
  268. package/stills/O71.jpg +0 -0
  269. package/stills/O72.jpg +0 -0
  270. package/stills/O73.jpg +0 -0
  271. package/stills/O74.jpg +0 -0
  272. package/stills/O75.jpg +0 -0
  273. package/stills/O76.jpg +0 -0
  274. package/stills/O77.jpg +0 -0
  275. package/stills/O78.jpg +0 -0
  276. package/stills/O79.jpg +0 -0
  277. package/stills/O8.jpg +0 -0
  278. package/stills/O80.jpg +0 -0
  279. package/stills/O81.jpg +0 -0
  280. package/stills/O82.jpg +0 -0
  281. package/stills/O83.jpg +0 -0
  282. package/stills/O84.jpg +0 -0
  283. package/stills/O85.jpg +0 -0
  284. package/stills/O86.jpg +0 -0
  285. package/stills/O87.jpg +0 -0
  286. package/stills/O88.jpg +0 -0
  287. package/stills/O89.jpg +0 -0
  288. package/stills/O9.jpg +0 -0
  289. package/stills/O90.jpg +0 -0
  290. package/stills/O91.jpg +0 -0
  291. package/stills/O92.jpg +0 -0
  292. package/stills/O93.jpg +0 -0
  293. package/stills/O94.jpg +0 -0
  294. package/stills/O95.jpg +0 -0
  295. package/stills/O96.jpg +0 -0
  296. package/stills/O97.jpg +0 -0
  297. package/stills/O98.jpg +0 -0
  298. package/stills/R1.jpg +0 -0
  299. package/stills/R2.jpg +0 -0
  300. package/stills/R3.jpg +0 -0
  301. package/stills/R4.jpg +0 -0
  302. package/stills/R5.jpg +0 -0
  303. package/stills/R6.jpg +0 -0
  304. package/stills/R7.jpg +0 -0
  305. package/stills/V1.jpg +0 -0
  306. package/stills/V10.jpg +0 -0
  307. package/stills/V11.jpg +0 -0
  308. package/stills/V12.jpg +0 -0
  309. package/stills/V13.jpg +0 -0
  310. package/stills/V14.jpg +0 -0
  311. package/stills/V15.jpg +0 -0
  312. package/stills/V2.jpg +0 -0
  313. package/stills/V3.jpg +0 -0
  314. package/stills/V4.jpg +0 -0
  315. package/stills/V5.jpg +0 -0
  316. package/stills/V6.jpg +0 -0
  317. package/stills/V7.jpg +0 -0
  318. package/stills/V8.jpg +0 -0
  319. package/stills/V9.jpg +0 -0
  320. package/stills/X1.jpg +0 -0
  321. package/stills/X10.jpg +0 -0
  322. package/stills/X11.jpg +0 -0
  323. package/stills/X12.jpg +0 -0
  324. package/stills/X13.jpg +0 -0
  325. package/stills/X14.jpg +0 -0
  326. package/stills/X15.jpg +0 -0
  327. package/stills/X16.jpg +0 -0
  328. package/stills/X17.jpg +0 -0
  329. package/stills/X18.jpg +0 -0
  330. package/stills/X19.jpg +0 -0
  331. package/stills/X2.jpg +0 -0
  332. package/stills/X20.jpg +0 -0
  333. package/stills/X21.jpg +0 -0
  334. package/stills/X22.jpg +0 -0
  335. package/stills/X23.jpg +0 -0
  336. package/stills/X24.jpg +0 -0
  337. package/stills/X25.jpg +0 -0
  338. package/stills/X26.jpg +0 -0
  339. package/stills/X27.jpg +0 -0
  340. package/stills/X28.jpg +0 -0
  341. package/stills/X29.jpg +0 -0
  342. package/stills/X3.jpg +0 -0
  343. package/stills/X30.jpg +0 -0
  344. package/stills/X31.jpg +0 -0
  345. package/stills/X4.jpg +0 -0
  346. package/stills/X5.jpg +0 -0
  347. package/stills/X6.jpg +0 -0
  348. package/stills/X7.jpg +0 -0
  349. package/stills/X8.jpg +0 -0
  350. package/stills/X9.jpg +0 -0
  351. package/stills/Z1.jpg +0 -0
  352. package/stills/Z10.jpg +0 -0
  353. package/stills/Z11.jpg +0 -0
  354. package/stills/Z12.jpg +0 -0
  355. package/stills/Z13.jpg +0 -0
  356. package/stills/Z14.jpg +0 -0
  357. package/stills/Z15.jpg +0 -0
  358. package/stills/Z2.jpg +0 -0
  359. package/stills/Z3.jpg +0 -0
  360. package/stills/Z4.jpg +0 -0
  361. package/stills/Z5.jpg +0 -0
  362. package/stills/Z6.jpg +0 -0
  363. package/stills/Z7.jpg +0 -0
  364. package/stills/Z8.jpg +0 -0
  365. package/stills/Z9.jpg +0 -0
  366. package/stills/approved.json +198 -0
  367. package/stills/index.json +1 -0
  368. package/styles.json +5090 -0
  369. package/tools.js +982 -0
package/client.js ADDED
@@ -0,0 +1,1581 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-creation-mode',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+ const { useState, useEffect, useMemo, useRef, useCallback } = React;
7
+
8
+ const API = '/code-animation-api';
9
+ /** Conversation event kind that carries one Turn's creation artifacts. */
10
+ const KIND = 'code-animation';
11
+
12
+ const CSS = `
13
+ .cas-tail{margin:8px 0 2px;display:flex;flex-direction:column;gap:8px}
14
+ /* Inline Tool view: the card sits inside the Tool row, which is already indented. */
15
+ .cas-inline{margin:6px 0 2px}
16
+ .cas-card{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;
17
+ background:var(--dsw-alias-bg-layer-1);font:13px/1.5 system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
18
+ color:var(--dsw-alias-label-primary)}
19
+ .cas-card-head{display:flex;align-items:center;gap:8px;padding:9px 11px;border-bottom:1px solid var(--dsw-alias-border-l1)}
20
+ .cas-dot{width:7px;height:7px;border-radius:2px;background:linear-gradient(135deg,#ff8a3d,#e0457b);flex:none}
21
+ .cas-card-title{font-weight:600;font-size:12.5px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
22
+ .cas-card-meta{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
23
+ .cas-grow{flex:1}
24
+ .cas-btn{height:27px;padding:0 11px;border-radius:7px;font:600 11.5px/1 inherit;cursor:pointer;white-space:nowrap;
25
+ border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
26
+ .cas-btn:hover{border-color:var(--dsw-alias-border-l2)}
27
+ .cas-btn:disabled{opacity:.5;cursor:not-allowed}
28
+ .cas-btn-primary{background:var(--dsw-alias-label-primary);border-color:transparent;color:var(--dsw-alias-bg-base)}
29
+ .cas-btn-primary:hover{filter:brightness(.92)}
30
+
31
+ /* stills gallery */
32
+ .cas-stills{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px;padding:10px}
33
+ .cas-still{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;
34
+ background:var(--dsw-alias-bg-layer-2);cursor:zoom-in;padding:0;text-align:left;font:inherit;color:inherit}
35
+ .cas-still img{width:100%;display:block;aspect-ratio:16/9;object-fit:cover}
36
+ .cas-still-cap{padding:6px 8px}
37
+ .cas-still-id{font-size:10px;font-weight:600;color:var(--dsw-alias-label-secondary);letter-spacing:.4px}
38
+ .cas-still-name{margin-left:6px;font-size:12.5px}
39
+ .cas-still-desc{display:block;margin-top:2px;font-size:11px;color:var(--dsw-alias-label-secondary);
40
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
41
+ .cas-nostill{padding:8px 10px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
42
+
43
+ /* storyboard */
44
+ .cas-sb-track{display:flex;height:26px;gap:2px;padding:8px 10px 0}
45
+ .cas-sb-seg{display:flex;align-items:center;justify-content:center;border-radius:4px;min-width:14px;flex-basis:0;
46
+ color:#0b0c10;font-size:10px;font-weight:700}
47
+ .cas-sb-list{max-height:280px;overflow:auto;padding:8px 10px 10px;display:flex;flex-direction:column;gap:6px}
48
+ .cas-sb-shot{display:grid;grid-template-columns:20px 78px minmax(0,1fr);gap:8px;align-items:start}
49
+ .cas-sb-idx{width:20px;height:20px;border-radius:5px;display:flex;align-items:center;justify-content:center;
50
+ color:#0b0c10;font-size:10.5px;font-weight:700;margin-top:1px}
51
+ .cas-sb-t{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;
52
+ color:var(--dsw-alias-label-secondary);padding-top:2px}
53
+ .cas-sb-body{min-width:0}
54
+ .cas-sb-scene{display:block;font-size:12.5px}
55
+ .cas-sb-text,.cas-sb-cam{display:block;margin-top:2px;font-size:11px;color:var(--dsw-alias-label-secondary)}
56
+ .cas-sb-foot{padding:8px 11px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11.5px;
57
+ color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}
58
+
59
+ /* animation player */
60
+ /* Each shape derives its width from the ratio and the height cap, then lets
61
+ aspect-ratio compute the height. Sizing by max-height alone (or by height alone)
62
+ left the box at the card's full width, and the canvas inside — which fills its
63
+ frame at 100%/100% — was stretched to that wrong shape. */
64
+ .cas-stage{position:relative;background:#05070b;display:flex;align-items:center;justify-content:center;margin:0 auto}
65
+ .cas-stage[data-aspect="16:9"]{aspect-ratio:16/9;width:min(100%,calc(560px * 16 / 9))}
66
+ .cas-stage[data-aspect="9:16"]{aspect-ratio:9/16;width:min(100%,calc(520px * 9 / 16))}
67
+ .cas-stage[data-aspect="1:1"]{aspect-ratio:1/1;width:min(100%,520px)}
68
+ .cas-frame{display:block;width:100%;height:100%;border:0;background:#05070b}
69
+ .cas-bar{display:flex;align-items:center;gap:9px;padding:8px 11px;border-top:1px solid var(--dsw-alias-border-l1)}
70
+ /* two-track timeline: picture marks on top, real waveform below */
71
+ .cas-tracks{border-top:1px solid var(--dsw-alias-border-l1);background:#080a0e;user-select:none}
72
+ .cas-track{position:relative;height:22px;cursor:crosshair}
73
+ .cas-track-video{background:#10141c;border-bottom:1px solid #171c26}
74
+ .cas-track-audio{height:58px}
75
+ .cas-track-label{position:absolute;left:6px;top:50%;transform:translateY(-50%);font-size:10px;
76
+ color:#5a6478;pointer-events:none;letter-spacing:.5px}
77
+ .cas-track-note{position:absolute;left:40px;top:50%;transform:translateY(-50%);font-size:10px;
78
+ color:#3d4757;pointer-events:none}
79
+ .cas-cut{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:#3fd7c0;
80
+ box-shadow:0 0 6px #3fd7c088}
81
+ .cas-wave{display:block;width:100%;height:58px}
82
+ .cas-play-btn{width:28px;height:28px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1);
83
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);display:inline-flex;
84
+ align-items:center;justify-content:center;cursor:pointer;font-size:12px;padding:0;flex-shrink:0;user-select:none}
85
+ .cas-play-btn:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-border-l2)}
86
+ .cas-play-btn:active{transform:scale(0.96)}
87
+ .cas-scrub{flex:1;accent-color:var(--dsw-alias-label-secondary);min-width:0}
88
+ .cas-t{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;
89
+ color:var(--dsw-alias-label-secondary);white-space:nowrap}
90
+ .cas-warn{padding:7px 11px;font-size:11.5px;color:var(--dsw-alias-state-warn-primary);
91
+ border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}
92
+ .cas-note{padding:8px 11px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
93
+ .cas-score{display:flex;align-items:center;gap:9px;padding:8px 11px;border-top:1px solid var(--dsw-alias-border-l1);
94
+ flex-wrap:wrap}
95
+ .cas-score-label{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary);white-space:nowrap}
96
+ .cas-select{height:27px;border-radius:7px;border:1px solid var(--dsw-alias-border-l1);
97
+ background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:11.5px;
98
+ padding:0 6px;outline:none;max-width:190px}
99
+ .cas-select:focus{border-color:var(--dsw-alias-border-l2)}
100
+ .cas-score-hint{font-size:11px;color:var(--dsw-alias-state-idle-primary);white-space:nowrap}
101
+ .cas-score-prompt{font-size:11.5px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;
102
+ white-space:nowrap;max-width:340px;font-weight:500}
103
+ .cas-path{padding:7px 11px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;
104
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-state-idle-primary);
105
+ word-break:break-all;background:var(--dsw-alias-bg-layer-2)}
106
+ .cas-path-ok{color:var(--dsw-alias-state-success-primary)}
107
+ .cas-lightbox{position:fixed;inset:0;z-index:9100;background:rgba(4,5,8,.88);display:flex;align-items:center;
108
+ justify-content:center;padding:32px;cursor:zoom-out}
109
+ .cas-lightbox img{max-width:100%;max-height:100%;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.6)}
110
+
111
+ /* 样式库:会话里的第三个页签(对话 / 轨迹 / 样式库) */
112
+ .cas-lib-view{display:flex;flex-direction:column;gap:10px;padding:16px;min-height:0}
113
+ .cas-lib-head{display:flex;align-items:center;gap:10px}
114
+ .cas-lib-title{font-weight:600;font-size:14px}
115
+ .cas-lib-count{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
116
+ .cas-lib-search{flex:1;height:30px;padding:0 10px;border-radius:8px;outline:none;min-width:0;
117
+ border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);
118
+ color:var(--dsw-alias-label-primary);font:13px/1 inherit}
119
+ .cas-lib-search:focus{border-color:var(--dsw-alias-border-l2)}
120
+ .cas-lib-chips{display:flex;flex-wrap:wrap;gap:6px}
121
+ .cas-chip{height:24px;padding:0 9px;border-radius:999px;cursor:pointer;
122
+ border:1px solid var(--dsw-alias-border-l1);background:transparent;
123
+ color:var(--dsw-alias-label-secondary);font:11.5px/1 inherit}
124
+ .cas-chip:hover{border-color:var(--dsw-alias-border-l2)}
125
+ .cas-chip[data-on="1"]{background:var(--dsw-alias-label-primary);border-color:transparent;
126
+ color:var(--dsw-alias-bg-base)}
127
+ .cas-lib-body{overflow:auto;min-height:240px;max-height:calc(100vh - 260px)}
128
+ .cas-lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}
129
+ .cas-lib-card{border:1px solid var(--dsw-alias-border-l1);border-radius:9px;overflow:hidden;
130
+ background:var(--dsw-alias-bg-layer-1);padding:0;text-align:left;cursor:zoom-in;
131
+ color:inherit;font:inherit}
132
+ .cas-lib-card:hover{border-color:var(--dsw-alias-border-l2)}
133
+ .cas-lib-card img{width:100%;display:block;aspect-ratio:16/9;object-fit:cover;
134
+ background:var(--dsw-alias-bg-layer-2)}
135
+ .cas-lib-cap{padding:7px 9px}
136
+ .cas-lib-id{font-size:10px;font-weight:600;letter-spacing:.5px;color:var(--dsw-alias-label-secondary)}
137
+ .cas-lib-name{margin-left:6px;font-size:12.5px}
138
+ .cas-lib-desc{display:block;margin-top:3px;font-size:11px;color:var(--dsw-alias-label-secondary);
139
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
140
+ .cas-lib-empty{padding:48px 0;text-align:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
141
+ `;
142
+
143
+ const PALETTE = ['#ff8a3d', '#e0457b', '#7b5bff', '#3fd7c0', '#4d6bfe', '#ffc04d'];
144
+
145
+ /** Parse one tool call's raw argument text; malformed input degrades to {}. */
146
+ function parseArgs(raw) {
147
+ if (typeof raw !== 'string' || raw === '') return {};
148
+ try {
149
+ const parsed = JSON.parse(raw);
150
+ return parsed && typeof parsed === 'object' ? parsed : {};
151
+ } catch {
152
+ return {};
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 把用户自己选的音频文件解码成 AudioBuffer,并铺满整段片长。
158
+ *
159
+ * 这是唯一一处「不是模型写的配乐」还能进片子的入口,而且必须由用户主动选文件;
160
+ * 插件自己不会生成任何兜底配乐。
161
+ */
162
+ async function audioFileToBuffer(file, duration, sampleRate = 44100) {
163
+ const bytes = await file.arrayBuffer();
164
+ const ctx = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate);
165
+ const decoded = await ctx.decodeAudioData(bytes);
166
+ const targetLength = Math.ceil(duration * decoded.sampleRate);
167
+ const out = ctx.createBuffer(2, targetLength, decoded.sampleRate);
168
+
169
+ for (let ch = 0; ch < 2; ch += 1) {
170
+ const from = decoded.getChannelData(Math.min(ch, decoded.numberOfChannels - 1));
171
+ const to = out.getChannelData(ch);
172
+ const take = Math.min(targetLength, from.length);
173
+ to.set(from.subarray(0, take));
174
+ // 素材比片子短就首尾相接地铺满,不做拉伸,免得变速变调。
175
+ if (take < targetLength && from.length > 0) {
176
+ let offset = take;
177
+ while (offset < targetLength) {
178
+ const chunk = Math.min(from.length, targetLength - offset);
179
+ to.set(from.subarray(0, chunk), offset);
180
+ offset += chunk;
181
+ }
182
+ }
183
+ }
184
+
185
+ const fadeLen = Math.min(Math.floor(out.sampleRate * 0.4), targetLength);
186
+ for (let ch = 0; ch < 2; ch += 1) {
187
+ const data = out.getChannelData(ch);
188
+ for (let i = 0; i < fadeLen; i += 1) data[targetLength - 1 - i] *= i / fadeLen;
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * Container preference, best first.
195
+ *
196
+ * Chromium records genuine H.264/AAC MP4 through MediaRecorder, so MP4 is the
197
+ * default; the WebM entries are the fallback for browsers without it.
198
+ */
199
+ const CONTAINERS = [
200
+ { id: 'mp4', label: 'MP4', ext: 'mp4', mimes: ['video/mp4;codecs=avc1.42E01E,mp4a.40.2', 'video/mp4;codecs=avc1.4D401F,mp4a.40.2', 'video/mp4'] },
201
+ { id: 'webm', label: 'WebM', ext: 'webm', mimes: ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'] },
202
+ ];
203
+
204
+ /** Which container this browser can actually record, MP4 first. */
205
+ function pickContainer(preferred) {
206
+ const order = preferred === 'webm' ? ['webm', 'mp4'] : ['mp4', 'webm'];
207
+ for (const id of order) {
208
+ const container = CONTAINERS.find((c) => c.id === id);
209
+ const mime = container.mimes.find((m) => (
210
+ typeof MediaRecorder !== 'undefined'
211
+ && MediaRecorder.isTypeSupported
212
+ && MediaRecorder.isTypeSupported(m)
213
+ ));
214
+ if (mime) return { ...container, mime };
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function download(blob, name) {
220
+ const url = URL.createObjectURL(blob);
221
+ const a = document.createElement('a');
222
+ a.href = url;
223
+ a.download = name;
224
+ document.body.appendChild(a);
225
+ a.click();
226
+ a.remove();
227
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
228
+ }
229
+
230
+ /**
231
+ * Shape the arrangement around each beat, at one of two strengths.
232
+ *
233
+ * There are two different jobs here, and treating them the same makes one of them
234
+ * wrong:
235
+ *
236
+ * ordinary beat (power < 0.72)
237
+ * The rhythm should keep running; the beat just needs to be clearly audible on
238
+ * top of it. So the arrangement is not interrupted — it gets a small dip right
239
+ * under the transient, which clears space without breaking the groove. Cutting
240
+ * the bed here would read as a mistake, because a normal accent is supposed to
241
+ * land *inside* the音乐, not punctuate it.
242
+ *
243
+ * climax beat (power >= 0.72)
244
+ * This is the one the audience is meant to feel, so the arrangement stops: a
245
+ * hard cut to near-silence ~90-130ms before, the hit lands in the hole, then the
246
+ * bed returns slightly hot. Silence to full level is the largest contrast there
247
+ * is, and it is reserved for peaks so it does not become fatiguing.
248
+ *
249
+ * Buses handed to the score:
250
+ * `bed` - the arrangement; this is what gets shaped
251
+ * `dest` - the hits; never shaped, so they are what the gap exposes
252
+ *
253
+ * @returns `{ total, climax }` counts after shaping.
254
+ */
255
+ const CLIMAX_POWER = 0.72;
256
+
257
+ function shapeBeats(bedGain, beats, duration) {
258
+ const marks = (Array.isArray(beats) ? beats : [])
259
+ .map((b) => ({
260
+ time: Number(typeof b === 'number' ? b : b && b.time),
261
+ power: Math.max(0.1, Math.min(1, Number(b && b.power) || 0.8)),
262
+ }))
263
+ .filter((b) => Number.isFinite(b.time) && b.time > 0.06 && b.time < duration - 0.02)
264
+ .sort((a, b) => a.time - b.time);
265
+
266
+ if (!marks.length) return { total: 0, climax: 0 };
267
+
268
+ const g = bedGain.gain;
269
+ g.setValueAtTime(1, 0);
270
+
271
+ let climax = 0;
272
+ for (const mark of marks) {
273
+ const t = mark.time;
274
+
275
+ if (mark.power >= CLIMAX_POWER) {
276
+ // Climax: let the music fall away, then land the hit in the quiet.
277
+ //
278
+ // A hard cut would read as a dropout — the ear hears a broken tape, not a
279
+ // build. So the arrangement fades out over a longer lead (~260ms at full
280
+ // power) and reaches near-silence only right at the beat. What the listener
281
+ // perceives is the music stepping back to make room, then the hit.
282
+ climax++;
283
+ const fade = 0.20 + mark.power * 0.12;
284
+ const gapStart = Math.max(0, t - fade);
285
+ const restoreAt = Math.min(duration, t + 0.045);
286
+
287
+ g.setValueAtTime(1, gapStart);
288
+ // Exponential-shaped fall: quick early, slow into the floor, so it feels like
289
+ // a release rather than a switch.
290
+ g.linearRampToValueAtTime(0.62, gapStart + fade * 0.42);
291
+ g.linearRampToValueAtTime(0.22, gapStart + fade * 0.74);
292
+ g.linearRampToValueAtTime(0.05, t - 0.006);
293
+ g.setValueAtTime(0.05, restoreAt);
294
+ // Return gently and slightly hot, so the beat reads as an arrival.
295
+ g.linearRampToValueAtTime(1.05, Math.min(duration, restoreAt + 0.14));
296
+ g.linearRampToValueAtTime(1, Math.min(duration, restoreAt + 0.32));
297
+ } else {
298
+ // Ordinary: keep the groove, just clear a little room under the hit.
299
+ // Depth scales with power so a 0.3 beat barely moves and a 0.7 one is firm.
300
+ // The dip is a rounded shape, not a step: stepping a gain in a few ms clicks.
301
+ const dip = 0.46 - mark.power * 0.30; // 0.37 at 0.3, 0.25 at 0.7
302
+ const from = Math.max(0, t - 0.075);
303
+ const hold = Math.min(duration, t + 0.028);
304
+
305
+ g.setValueAtTime(1, from);
306
+ g.linearRampToValueAtTime(0.78, t - 0.028);
307
+ g.linearRampToValueAtTime(dip, t - 0.004);
308
+ g.setValueAtTime(dip, hold);
309
+ g.linearRampToValueAtTime(1.02, Math.min(duration, hold + 0.07));
310
+ g.linearRampToValueAtTime(1, Math.min(duration, hold + 0.18));
311
+ }
312
+ }
313
+
314
+ return { total: marks.length, climax };
315
+ }
316
+
317
+ /**
318
+ * Render a score the model wrote for this clip.
319
+ *
320
+ * The code runs against an OfflineAudioContext with the same helpers the built-in
321
+ * engine uses, so a model-written score can do anything the engine can — it just is
322
+ * not limited to the engine's instrument set.
323
+ *
324
+ * Two buses are handed in: `bed` is where the arrangement should go, and `dest` is
325
+ * the raw output. Anything connected to `bed` is ducked around each beat; anything
326
+ * connected to `dest` is not, which is where the hits belong.
327
+ *
328
+ * @param url - `/code-animation-api/score/<id>`.
329
+ * @param options - `{ duration, cuts, cues, beats }`.
330
+ * @returns the rendered AudioBuffer, or null when the code is missing or throws.
331
+ */
332
+ async function renderModelScore(url, options) {
333
+ const source = await fetch(url).then((r) => (r.ok ? r.text() : '')).catch(() => '');
334
+ if (!source) return null;
335
+
336
+ const duration = Math.max(0.5, Number(options.duration) || 8);
337
+ const sampleRate = 44100;
338
+ const ctx = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate);
339
+
340
+ // Raw output: anything the model connects here is heard as-is, so hits belong here.
341
+ const out = ctx.createGain();
342
+ out.gain.value = 1;
343
+ out.connect(ctx.destination);
344
+
345
+ // The bed bus: the arrangement goes here and is shaped around each beat — a small
346
+ // dip under ordinary accents, a full stop before a climax. Hits belong on `dest`
347
+ // so they are what the gap exposes.
348
+ const bed = ctx.createGain();
349
+ bed.gain.value = 1;
350
+ bed.connect(ctx.destination);
351
+ const shaped = shapeBeats(bed, options.beats, duration);
352
+
353
+ const rand = (() => {
354
+ let a = 20261103;
355
+ return () => {
356
+ a = (a + 0x6D2B79F5) | 0;
357
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
358
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
359
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
360
+ };
361
+ })();
362
+ const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);
363
+
364
+ try {
365
+ // `beats` carries the exact moments the model exported from its own animation
366
+ // code, so the score can land on the picture without guessing.
367
+ // eslint-disable-next-line no-new-func
368
+ const fn = new Function(
369
+ 'ctx', 'dest', 'bed', 'duration', 'cuts', 'cues', 'beats', 'rand', 'hz', source,
370
+ );
371
+ fn(ctx, out, bed, duration, options.cuts || [], options.cues || [], options.beats || [], rand, hz);
372
+ const buffer = await ctx.startRendering();
373
+ buffer.__shapedBeats = shaped.total;
374
+ buffer.__climaxBeats = shaped.climax;
375
+ let peak = 0;
376
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
377
+ const data = buffer.getChannelData(ch);
378
+ for (let i = 0; i < data.length; i += 7) peak = Math.max(peak, Math.abs(data[i]));
379
+ }
380
+ return peak > 1e-4 ? buffer : null;
381
+ } catch (error) {
382
+ console.warn('model score failed, falling back to the built-in engine:', error);
383
+ return null;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Drive a detached document frame frame by frame and record it through MediaRecorder.
389
+ * @param opts.scoreBuffer - 模型写的配乐,已经离线渲染好的 AudioBuffer。
390
+ * @param opts.musicBuffer - 用户自己选的音频文件,优先级高于 scoreBuffer。
391
+ * 两者都不给就是无声导出;这里不会凭空合成配乐。
392
+ * @param opts.cuts - cut times in seconds; the score is fitted and accented to them.
393
+ */
394
+ /**
395
+ * Find the canvas an export should record.
396
+ *
397
+ * The frame is loaded asynchronously and a page may create its canvas from script,
398
+ * so this waits for the document and retries instead of failing on a first miss.
399
+ * When several canvases exist the largest is used: decorative or helper canvases
400
+ * are typically small.
401
+ */
402
+ async function resolveCanvas(iframe, timeoutMs = 2000) {
403
+ const started = performance.now();
404
+ let doc;
405
+ let seen = '';
406
+
407
+ while (performance.now() - started < timeoutMs) {
408
+ doc = iframe && iframe.contentDocument;
409
+ if (doc && doc.readyState !== 'loading') {
410
+ const canvases = Array.from(doc.querySelectorAll('canvas'));
411
+ if (canvases.length) {
412
+ canvases.sort((a, b) => (b.width * b.height) - (a.width * a.height));
413
+ return canvases[0];
414
+ }
415
+ // Remember what the page does contain, for an actionable failure message.
416
+ const counts = ['div', 'svg', 'video', 'img']
417
+ .map((tag) => [tag, doc.querySelectorAll(tag).length])
418
+ .filter(([, n]) => n > 0)
419
+ .map(([tag, n]) => `${n} 个 <${tag}>`);
420
+ seen = counts.join('、');
421
+ }
422
+ await new Promise((r) => setTimeout(r, 80));
423
+ }
424
+
425
+ const win = iframe && iframe.contentWindow;
426
+ const hasRenderAt = win && typeof win.renderAt === 'function';
427
+ throw new Error([
428
+ '画面里没有可录制的 <canvas>',
429
+ seen ? `(页面里找到 ${seen})` : '',
430
+ hasRenderAt ? '' : ',且没有 window.renderAt',
431
+ '。这个动画是 DOM/CSS 绘制的,无法逐帧录制;需要让 Agent 改为渲染到 <canvas> 的实现。',
432
+ ].filter(Boolean).join(''));
433
+ }
434
+
435
+ async function recordAnimation(iframe, opts) {
436
+ const win = iframe && iframe.contentWindow;
437
+ if (!win) throw new Error('预览还没准备好');
438
+ const canvas = await resolveCanvas(iframe);
439
+ if (typeof canvas.captureStream !== 'function') throw new Error('当前浏览器不支持 canvas.captureStream');
440
+ const container = pickContainer(opts.container);
441
+ if (!container) throw new Error('当前浏览器不支持 MediaRecorder 录制');
442
+ const mime = container.mime;
443
+
444
+ const stream = canvas.captureStream(0);
445
+ const videoTrack = stream.getVideoTracks()[0];
446
+ const tracks = [videoTrack];
447
+
448
+ // Audio is mixed into the same recording session so the two tracks share one clock.
449
+ // There is no engine behind this: the buffer must be the model's score (or a file
450
+ // the user picked). Missing it is an error, not a reason to synthesize something.
451
+ let audioCtx;
452
+ let source;
453
+ let audioBuffer;
454
+ let analyser;
455
+ const wantsAudio = opts.scoreBuffer || opts.musicBuffer;
456
+ if (wantsAudio) {
457
+ audioBuffer = opts.musicBuffer || opts.scoreBuffer;
458
+ audioCtx = new AudioContext();
459
+ const destination = audioCtx.createMediaStreamDestination();
460
+ source = audioCtx.createBufferSource();
461
+ source.buffer = audioBuffer;
462
+ source.connect(destination);
463
+ // Tap the same signal so the export can prove audio actually flowed.
464
+ analyser = audioCtx.createAnalyser();
465
+ analyser.fftSize = 2048;
466
+ source.connect(analyser);
467
+ tracks.push(...destination.stream.getAudioTracks());
468
+
469
+ // Bring the context up BEFORE the recorder exists. Doing this after start()
470
+ // leaves the recorder capturing silence while the picture already runs, which
471
+ // truncates the score (measured: a 3.000s clip came out as 2.640s) and shifts
472
+ // every later beat.
473
+ if (audioCtx.state === 'suspended') {
474
+ await Promise.race([
475
+ audioCtx.resume().catch(() => {}),
476
+ new Promise((r) => setTimeout(r, 800)),
477
+ ]);
478
+ }
479
+ if (audioCtx.state !== 'running' && opts.onStage) {
480
+ opts.onStage('音频未能启动,本段可能无声');
481
+ }
482
+ }
483
+
484
+ const recorder = new MediaRecorder(new MediaStream(tracks), { mimeType: mime, videoBitsPerSecond: 16000000 });
485
+ const parts = [];
486
+ recorder.ondataavailable = (e) => { if (e.data && e.data.size) parts.push(e.data); };
487
+ const finished = new Promise((resolve) => {
488
+ // Resolve with the container that was actually negotiated, not a hardcoded one.
489
+ recorder.onstop = () => resolve(new Blob(parts, { type: mime }));
490
+ });
491
+ recorder.start();
492
+
493
+ const fps = Math.max(1, Number(opts.fps) || 30);
494
+ const total = Math.max(1, Math.round(opts.duration * fps));
495
+ const hasRenderAt = typeof win.renderAt === 'function';
496
+ const t0 = performance.now();
497
+ let audioPeak = 0;
498
+
499
+ // Start the score with no await in between: the recorder and the first frame must
500
+ // share one origin, or the whole track lands late.
501
+ if (source) source.start(0);
502
+
503
+ try {
504
+ for (let i = 0; i < total; i++) {
505
+ if (opts.signal && opts.signal.aborted) break;
506
+ if (hasRenderAt) await win.renderAt(i / fps);
507
+ videoTrack.requestFrame();
508
+ // Sample the score periodically; a peak of zero means the browser never
509
+ // started audio, which would otherwise export as silent video.
510
+ if (analyser && i % 5 === 0) {
511
+ const probe = new Float32Array(analyser.fftSize);
512
+ analyser.getFloatTimeDomainData(probe);
513
+ for (let k = 0; k < probe.length; k++) {
514
+ const v = Math.abs(probe[k]);
515
+ if (v > audioPeak) audioPeak = v;
516
+ }
517
+ }
518
+ if (opts.onProgress) opts.onProgress((i + 1) / total);
519
+ const target = ((i + 1) * 1000) / fps;
520
+ const wait = target - (performance.now() - t0);
521
+ if (wait > 1) await new Promise((r) => setTimeout(r, wait));
522
+ }
523
+ } finally {
524
+ recorder.stop();
525
+ }
526
+
527
+ const blob = await finished;
528
+ try { if (source) source.stop(); } catch { /* already ended */ }
529
+ if (audioCtx) void audioCtx.close();
530
+ return { blob, container, hasAudio: tracks.length > 1, audioPeak };
531
+ }
532
+
533
+ /**
534
+ * Two stacked tracks for the finished clip.
535
+ *
536
+ * The picture track marks the storyboard cuts; the sound track draws the score's real
537
+ * waveform and marks every beat the animation code reported. Seeing where an accent
538
+ * sits against the picture is the only way to diagnose a sync problem by eye, so the
539
+ * waveform is drawn from the rendered buffer rather than a decorative placeholder.
540
+ */
541
+ function Timeline({ duration, scrub, beats, cuts, cues, score, fps, onSeek }) {
542
+ const canvasRef = useRef(null);
543
+
544
+ // Redraw whenever the buffer or the marks change.
545
+ useEffect(() => {
546
+ const canvas = canvasRef.current;
547
+ if (!canvas) return undefined;
548
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
549
+ const width = canvas.clientWidth || 600;
550
+ const height = 56;
551
+ canvas.width = Math.round(width * dpr);
552
+ canvas.height = Math.round(height * dpr);
553
+ const g = canvas.getContext('2d');
554
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
555
+ g.clearRect(0, 0, width, height);
556
+
557
+ const lane = (top, h, fill) => {
558
+ g.fillStyle = fill;
559
+ g.fillRect(0, top, width, h);
560
+ };
561
+ lane(0, height, '#0b0d12');
562
+
563
+ const xOf = (t) => (Math.max(0, Math.min(duration, t)) / duration) * width;
564
+
565
+ if (score && typeof score.getChannelData === 'function') {
566
+ // Peak envelope per pixel column: the honest shape of what will be heard.
567
+ const data = score.getChannelData(0);
568
+ const mid = height / 2;
569
+ g.strokeStyle = '#3fb6f0';
570
+ g.lineWidth = 1;
571
+ g.beginPath();
572
+ for (let x = 0; x < width; x++) {
573
+ const from = Math.floor((x / width) * data.length);
574
+ const to = Math.floor(((x + 1) / width) * data.length);
575
+ let peak = 0;
576
+ for (let i = from; i < to; i += 2) {
577
+ const v = Math.abs(data[i]);
578
+ if (v > peak) peak = v;
579
+ }
580
+ const half = Math.min(mid - 2, peak * mid * 1.6);
581
+ g.moveTo(x + 0.5, mid - half);
582
+ g.lineTo(x + 0.5, mid + half);
583
+ }
584
+ g.stroke();
585
+ } else {
586
+ g.fillStyle = '#1a1f2b';
587
+ g.fillRect(0, height / 2 - 1, width, 2);
588
+ }
589
+
590
+ // Beat markers: the moments the animation code said matter.
591
+ for (const b of beats) {
592
+ const x = xOf(b.time);
593
+ const power = Number.isFinite(b.power) ? b.power : 0.8;
594
+ g.strokeStyle = `rgba(255,138,61,${0.45 + power * 0.55})`;
595
+ g.lineWidth = 1 + power * 1.2;
596
+ g.beginPath();
597
+ g.moveTo(x, 0);
598
+ g.lineTo(x, height);
599
+ g.stroke();
600
+ g.fillStyle = `rgba(255,138,61,${0.6 + power * 0.4})`;
601
+ g.beginPath();
602
+ g.arc(x, 3, 2 + power * 1.6, 0, Math.PI * 2);
603
+ g.fill();
604
+ }
605
+
606
+ // Cue markers that were not already covered by a beat.
607
+ for (const c of cues) {
608
+ const near = beats.some((b) => Math.abs(b.time - c.time) < 0.05);
609
+ if (near) continue;
610
+ const x = xOf(c.time);
611
+ g.strokeStyle = 'rgba(255,192,77,0.55)';
612
+ g.lineWidth = 1;
613
+ g.setLineDash([2, 3]);
614
+ g.beginPath();
615
+ g.moveTo(x, 0);
616
+ g.lineTo(x, height);
617
+ g.stroke();
618
+ g.setLineDash([]);
619
+ }
620
+
621
+ // Playhead.
622
+ const px = xOf(scrub);
623
+ g.strokeStyle = '#ffffff';
624
+ g.lineWidth = 1.5;
625
+ g.beginPath();
626
+ g.moveTo(px, 0);
627
+ g.lineTo(px, height);
628
+ g.stroke();
629
+
630
+ return undefined;
631
+ }, [duration, scrub, beats, cues, score]);
632
+
633
+ /** Click or drag anywhere on the track to scrub there. */
634
+ const seek = (event) => {
635
+ const rect = event.currentTarget.getBoundingClientRect();
636
+ const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
637
+ onSeek(ratio * duration);
638
+ };
639
+
640
+ return h('div', { className: 'cas-tracks' },
641
+ // Picture track: storyboard cuts.
642
+ h('div', {
643
+ className: 'cas-track cas-track-video',
644
+ onMouseDown: seek,
645
+ title: '视频轨:竖线是分镜切点',
646
+ },
647
+ cuts.map((t) => h('span', {
648
+ key: `c${t}`,
649
+ className: 'cas-cut',
650
+ style: { left: `${(t / duration) * 100}%` },
651
+ })),
652
+ cuts.length
653
+ ? null
654
+ : h('span', { className: 'cas-track-note' }, '无分镜切点'),
655
+ h('span', { className: 'cas-track-label' }, '视频'),
656
+ ),
657
+ // Sound track: real waveform plus every reported beat.
658
+ h('div', {
659
+ className: 'cas-track cas-track-audio',
660
+ onMouseDown: seek,
661
+ title: '音频轨:波形是实际配乐,橙色竖线是卡点',
662
+ },
663
+ h('canvas', { ref: canvasRef, className: 'cas-wave' }),
664
+ h('span', { className: 'cas-track-label' }, '音频'),
665
+ ),
666
+ );
667
+ }
668
+ function StillGallery({ stills, note, missing }) {
669
+ const [lightbox, setLightbox] = useState('');
670
+ return h('div', { className: 'cas-card' },
671
+ h('div', { className: 'cas-card-head' },
672
+ h('span', { className: 'cas-dot' }),
673
+ h('span', { className: 'cas-card-title' }, '风格候选'),
674
+ h('span', { className: 'cas-card-meta' }, `${stills.length} 条参考静帧`),
675
+ ),
676
+ note ? h('div', { className: 'cas-note' }, note) : null,
677
+ h('div', { className: 'cas-stills' },
678
+ stills.map((s) => h('button', {
679
+ key: s.id,
680
+ className: 'cas-still',
681
+ onClick: () => setLightbox(`${API}/still/${s.id}`),
682
+ title: '点击放大',
683
+ },
684
+ h('img', { src: `${API}/still/${s.id}`, alt: s.name || s.id, loading: 'lazy' }),
685
+ h('span', { className: 'cas-still-cap' },
686
+ h('span', { className: 'cas-still-id' }, s.id),
687
+ h('span', { className: 'cas-still-name' }, s.name || ''),
688
+ h('span', { className: 'cas-still-desc' }, s.desc || ''),
689
+ ),
690
+ )),
691
+ ),
692
+ (missing && missing.length)
693
+ ? h('div', { className: 'cas-nostill' }, `没有参考静帧:${missing.join('、')}`)
694
+ : null,
695
+ lightbox
696
+ ? h('div', { className: 'cas-lightbox', onClick: () => setLightbox('') },
697
+ h('img', { src: lightbox, alt: '参考静帧' }))
698
+ : null,
699
+ );
700
+ }
701
+
702
+ /** The storyboard draft, as a proportional timeline plus a per-shot list. */
703
+ function StoryboardCard({ board }) {
704
+ const shots = board.shots || [];
705
+ const shots2 = shots.length ? shots : [];
706
+ return h('div', { className: 'cas-card' },
707
+ h('div', { className: 'cas-card-head' },
708
+ h('span', { className: 'cas-dot' }),
709
+ h('span', { className: 'cas-card-title' }, `分镜 · ${board.title || ''}`),
710
+ h('span', { className: 'cas-card-meta' },
711
+ [
712
+ board.styleId ? `${board.styleId} ${board.styleName || ''}`.trim() : '',
713
+ `${shots2.length} 场`,
714
+ `${board.duration}s`,
715
+ board.aspect || '',
716
+ ].filter(Boolean).join(' · ')),
717
+ ),
718
+ h('div', { className: 'cas-sb-track' },
719
+ shots2.map((shot, i) => h('span', {
720
+ key: i,
721
+ className: 'cas-sb-seg',
722
+ title: `${shot.start}–${shot.end}s|${shot.scene}`,
723
+ style: {
724
+ flexGrow: String(Math.max(0.05, Number(shot.end) - Number(shot.start))),
725
+ background: PALETTE[i % PALETTE.length],
726
+ },
727
+ }, h('b', null, String(i + 1))))),
728
+ h('div', { className: 'cas-sb-list' },
729
+ shots2.map((shot, i) => h('div', { key: i, className: 'cas-sb-shot' },
730
+ h('span', { className: 'cas-sb-idx', style: { background: PALETTE[i % PALETTE.length] } }, String(i + 1)),
731
+ h('span', { className: 'cas-sb-t' }, `${Number(shot.start).toFixed(2)}–${Number(shot.end).toFixed(2)}s`),
732
+ h('span', { className: 'cas-sb-body' },
733
+ h('span', { className: 'cas-sb-scene' }, shot.scene || ''),
734
+ shot.text ? h('span', { className: 'cas-sb-text' }, `字幕「${shot.text}」`) : null,
735
+ shot.camera ? h('span', { className: 'cas-sb-cam' }, shot.camera) : null,
736
+ ),
737
+ ))),
738
+ (board.warnings && board.warnings.length)
739
+ ? h('div', { className: 'cas-warn' }, `需要留意:${board.warnings.join(';')}`)
740
+ : null,
741
+ h('div', { className: 'cas-sb-foot' }, '这份分镜只是草稿。确认之后再让 Agent 开始写动画;要改就直接说加场、删场、调时长或改文案。'),
742
+ );
743
+ }
744
+
745
+ /**
746
+ * The finished animation: pick a score and a container, scrub any moment, export.
747
+ *
748
+ * Exports are written to the session directory through the Host so the file lands
749
+ * somewhere the user can find; the card shows that absolute path before and after
750
+ * the export, and keeps a browser download as a secondary action.
751
+ */
752
+ function AnimationCard({ anim, cwd, uiWorkspace }) {
753
+ const [scrub, setScrub] = useState(0);
754
+ const [busy, setBusy] = useState('');
755
+ const [progress, setProgress] = useState(0);
756
+ const [note, setNote] = useState('');
757
+ const [savedPath, setSavedPath] = useState('');
758
+ const [targetDir, setTargetDir] = useState('');
759
+ const [customDir, setCustomDir] = useState('');
760
+ const [aspect, setAspect] = useState('16:9');
761
+ const [playing, setPlaying] = useState(false);
762
+ // The rendered score, exposed to the timeline so it can draw the real waveform.
763
+ const [scoreBuffer, setScoreBuffer] = useState(null);
764
+ const animFrameRef = useRef(null);
765
+ const scrubRef = useRef(0);
766
+ scrubRef.current = scrub;
767
+ const [music, setMusic] = useState(anim.music || 'none');
768
+ const [custom, setCustom] = useState(null);
769
+ const [customName, setCustomName] = useState('');
770
+ const [stage, setStage] = useState('');
771
+ const [format, setFormat] = useState(null);
772
+ const previewRef = useRef(null);
773
+ const audioCtxRef = useRef(null);
774
+ const audioSourceRef = useRef(null);
775
+ const scoreRef = useRef(null);
776
+ const fileRef = useRef(null);
777
+ const frameRef = useRef(null);
778
+
779
+ // Only offer containers this browser can actually record.
780
+ const available = useMemo(
781
+ () => CONTAINERS.filter((c) => c.mimes.some((m) => (
782
+ typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)
783
+ ))),
784
+ [],
785
+ );
786
+ const defaultFormat = available.some((c) => c.id === 'mp4') ? 'mp4' : (available[0] && available[0].id) || 'webm';
787
+ const activeFormat = format || defaultFormat;
788
+
789
+ const duration = Number(anim.duration) || 8;
790
+ const fps = Number(anim.fps) || 30;
791
+ // Cut points come from the storyboard, so the score lands on the edits.
792
+ const cuts = useMemo(
793
+ () => (Array.isArray(anim.cuts) ? anim.cuts.map(Number).filter((t) => Number.isFinite(t) && t > 0) : []),
794
+ [anim.cuts],
795
+ );
796
+ // In-shot visual peaks the score must hit as well as the cuts.
797
+ const cues = useMemo(() => (Array.isArray(anim.cues) ? anim.cues : []), [anim.cues]);
798
+ // Beats are exported from the animation code, so they are the exact sync points.
799
+ const beats = useMemo(() => (Array.isArray(anim.beats) ? anim.beats : []), [anim.beats]);
800
+
801
+ useEffect(() => () => { if (previewRef.current) previewRef.current.stop(); }, []);
802
+
803
+ // Resolve the destination up front so the card can state it before exporting.
804
+ useEffect(() => {
805
+ let cancelled = false;
806
+ const query = cwd ? `?dir=${encodeURIComponent(cwd)}` : '';
807
+ fetch(`${API}/export-dir${query}`)
808
+ .then((r) => (r.ok ? r.json() : null))
809
+ .then((data) => { if (!cancelled && data && data.dir) setTargetDir(data.dir); })
810
+ .catch(() => {});
811
+ return () => { cancelled = true; };
812
+ }, [cwd]);
813
+
814
+ const applyScrub = useCallback((value) => {
815
+ setScrub(value);
816
+ const win = frameRef.current && frameRef.current.contentWindow;
817
+ if (win && typeof win.renderAt === 'function') {
818
+ try { win.renderAt(Number(value)); } catch { /* one bad frame must not break the card */ }
819
+ }
820
+ }, []);
821
+
822
+ /** Stop the auditioning source without touching the video clock. */
823
+ const stopAudio = useCallback(() => {
824
+ if (audioSourceRef.current) {
825
+ try { audioSourceRef.current.stop(); } catch { /* already ended */ }
826
+ audioSourceRef.current = null;
827
+ }
828
+ if (audioCtxRef.current) {
829
+ void audioCtxRef.current.close().catch(() => {});
830
+ audioCtxRef.current = null;
831
+ }
832
+ }, []);
833
+
834
+ const stopPlayback = useCallback(() => {
835
+ if (animFrameRef.current) {
836
+ cancelAnimationFrame(animFrameRef.current);
837
+ animFrameRef.current = null;
838
+ }
839
+ stopAudio();
840
+ setPlaying(false);
841
+ }, [stopAudio]);
842
+
843
+ /**
844
+ * Start the score at an offset so picture and sound share one clock.
845
+ * The buffer is rendered ahead of time, so pressing play is instant.
846
+ */
847
+ const startAudioAt = useCallback(async (offset) => {
848
+ const buffer = custom || (music && music !== 'none' ? scoreRef.current : null);
849
+ if (!buffer) return;
850
+ const ctx = new AudioContext();
851
+ audioCtxRef.current = ctx;
852
+ if (ctx.state === 'suspended') {
853
+ await Promise.race([ctx.resume().catch(() => {}), new Promise((r) => setTimeout(r, 400))]);
854
+ }
855
+ if (audioCtxRef.current !== ctx) { void ctx.close(); return; }
856
+ const source = ctx.createBufferSource();
857
+ source.buffer = buffer;
858
+ source.connect(ctx.destination);
859
+ source.start(0, Math.max(0, Math.min(offset, buffer.duration - 0.01)));
860
+ audioSourceRef.current = source;
861
+ }, [music, custom]);
862
+
863
+ const togglePlay = useCallback(() => {
864
+ if (playing) {
865
+ stopPlayback();
866
+ return;
867
+ }
868
+ let startT = Number(scrubRef.current);
869
+ if (startT >= duration - 0.05) {
870
+ startT = 0;
871
+ applyScrub(0);
872
+ }
873
+ setPlaying(true);
874
+ void startAudioAt(startT);
875
+ const startWallTime = performance.now() - startT * 1000;
876
+ const step = (now) => {
877
+ const curT = (now - startWallTime) / 1000;
878
+ if (curT >= duration) {
879
+ applyScrub(duration);
880
+ stopAudio();
881
+ setPlaying(false);
882
+ animFrameRef.current = null;
883
+ } else {
884
+ applyScrub(curT);
885
+ animFrameRef.current = requestAnimationFrame(step);
886
+ }
887
+ };
888
+ animFrameRef.current = requestAnimationFrame(step);
889
+ }, [playing, duration, applyScrub, stopPlayback, startAudioAt, stopAudio]);
890
+
891
+ useEffect(() => () => {
892
+ if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
893
+ stopAudio();
894
+ }, [stopAudio]);
895
+
896
+ // Render the score once so auditioning is instant and export reuses the buffer.
897
+ // 只有模型写的 score_js 会被渲染:没有兜底引擎,拿不到就是没声音,并说明原因。
898
+ useEffect(() => {
899
+ if (custom) { scoreRef.current = custom; return undefined; }
900
+ if (music === 'none') { scoreRef.current = null; setScoreBuffer(null); return undefined; }
901
+ if (!anim.scoreUrl) {
902
+ scoreRef.current = null;
903
+ setScoreBuffer(null);
904
+ setNote('这一版没有配乐代码,无法出声;请让 Agent 重新提交 score_js');
905
+ return undefined;
906
+ }
907
+ let cancelled = false;
908
+ scoreRef.current = null;
909
+
910
+ renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats })
911
+ .then((buf) => {
912
+ if (cancelled) return;
913
+ if (!buf) {
914
+ setNote('配乐代码没有渲染出声音,请让 Agent 修 score_js');
915
+ setScoreBuffer(null);
916
+ return;
917
+ }
918
+ scoreRef.current = buf;
919
+ // Hand the buffer to the timeline so it can draw the true waveform.
920
+ setScoreBuffer(buf);
921
+ })
922
+ .catch(() => {});
923
+ return () => { cancelled = true; };
924
+ }, [music, custom, duration, cuts, cues, beats, anim.scoreUrl]);
925
+
926
+ // The first build effect resets the buffer; keep the timeline in step with it.
927
+ useEffect(() => {
928
+ if (custom) setScoreBuffer(custom);
929
+ // eslint-disable-next-line react-hooks/exhaustive-deps
930
+ }, [custom]);
931
+
932
+ const onPickFile = useCallback(async (event) => {
933
+ const file = event.target.files && event.target.files[0];
934
+ if (!file) return;
935
+ try {
936
+ const buffer = await audioFileToBuffer(file, duration);
937
+ setCustom(buffer);
938
+ setCustomName(file.name);
939
+ setNote(`已载入 ${file.name},导出时会混入`);
940
+ } catch (error) {
941
+ setNote(`音频读取失败:${error.message}`);
942
+ }
943
+ }, [duration]);
944
+
945
+ const onPickDirectory = useCallback(async () => {
946
+ if (uiWorkspace && typeof uiWorkspace.pickDirectory === 'function') {
947
+ try {
948
+ const chosen = await uiWorkspace.pickDirectory();
949
+ if (chosen) {
950
+ setCustomDir(chosen);
951
+ setTargetDir(chosen);
952
+ return;
953
+ }
954
+ } catch (e) {
955
+ console.warn('uiWorkspace.pickDirectory error:', e);
956
+ }
957
+ }
958
+ const val = window.prompt('请输入保存视频的目标文件夹路径(绝对路径):', customDir || targetDir || cwd || '');
959
+ if (val && val.trim()) {
960
+ setCustomDir(val.trim());
961
+ setTargetDir(val.trim());
962
+ }
963
+ }, [uiWorkspace, customDir, targetDir, cwd]);
964
+
965
+ const onExport = useCallback(async () => {
966
+ if (busy) return;
967
+ setBusy('export');
968
+ setProgress(0);
969
+ setNote('');
970
+ setSavedPath('');
971
+ setStage('');
972
+ try {
973
+ // The score may still be rendering when export is pressed; wait for it so the
974
+ // exported audio is the same one the preview played. 没有兜底引擎:等不到就报错。
975
+ let score = scoreRef.current;
976
+ if (!score && !custom && anim.scoreUrl) {
977
+ setStage('正在合成配乐…');
978
+ score = await renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats }).catch(() => null);
979
+ }
980
+ if (!custom && music !== 'none' && !score) {
981
+ throw new Error('配乐还没合成出来,无法导出。请让 Agent 重新提交 score_js。');
982
+ }
983
+ const { blob, container, hasAudio, audioPeak } = await recordAnimation(frameRef.current, {
984
+ duration,
985
+ fps,
986
+ cuts,
987
+ container: activeFormat,
988
+ musicBuffer: custom || undefined,
989
+ scoreBuffer: score || undefined,
990
+ clipId: anim.id,
991
+ onStage: setStage,
992
+ onProgress: setProgress,
993
+ });
994
+ if (!blob || !blob.size) throw new Error('录制结果为空');
995
+
996
+ const wanted = custom || music !== 'none';
997
+ // A zero peak means the browser never started audio: say so instead of
998
+ // handing over a silent file as if it were scored.
999
+ const audioNote = !wanted
1000
+ ? '无声'
1001
+ : (hasAudio && audioPeak > 1e-4)
1002
+ ? '含配乐'
1003
+ : '⚠ 配乐未进入录制,这段是无声的';
1004
+
1005
+ const fileName = `${anim.title || anim.id || 'animation'}.${container.ext}`;
1006
+
1007
+ // One click does both: download to the browser, and also write into the
1008
+ // chosen directory, so the file is never hidden in only one place.
1009
+ download(blob, fileName);
1010
+
1011
+ setStage('正在写入目录…');
1012
+ try {
1013
+ const query = new URLSearchParams({ name: fileName });
1014
+ const exportDir = customDir || cwd;
1015
+ if (exportDir) query.set('dir', exportDir);
1016
+ const res = await fetch(`${API}/export?${query}`, {
1017
+ method: 'POST',
1018
+ headers: { 'content-type': blob.type || 'application/octet-stream' },
1019
+ body: blob,
1020
+ });
1021
+ const data = await res.json().catch(() => ({}));
1022
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
1023
+ setSavedPath(data.path);
1024
+ setNote(`已下载并保存到 ${data.path}(${(blob.size / 1048576).toFixed(1)} MB · ${container.label} · ${audioNote})`);
1025
+ } catch (error) {
1026
+ // The download already happened, so the render is never lost.
1027
+ setNote(`已下载 ${fileName} · ${container.label} · ${audioNote}(未写入目录:${error.message})`);
1028
+ }
1029
+ } catch (error) {
1030
+ setNote(`导出失败:${error.message}`);
1031
+ } finally {
1032
+ setBusy('');
1033
+ setStage('');
1034
+ }
1035
+ }, [busy, duration, fps, music, custom, cuts, cues, anim, activeFormat, customDir, cwd]);
1036
+
1037
+ return h('div', { className: 'cas-card' },
1038
+ h('div', { className: 'cas-card-head' },
1039
+ h('span', { className: 'cas-dot' }),
1040
+ h('span', { className: 'cas-card-title' }, anim.title || '动画'),
1041
+ h('span', { className: 'cas-card-meta' },
1042
+ [
1043
+ anim.styleId ? `${anim.styleId} ${anim.styleName || ''}`.trim() : '',
1044
+ `${duration}s`,
1045
+ `${fps}fps`,
1046
+ beats.length ? `${beats.length} 个卡点` : (cuts.length ? `${cuts.length} 个切点` : ''),
1047
+ ].filter(Boolean).join(' · ')),
1048
+ h('span', { className: 'cas-grow' }),
1049
+ h('button', {
1050
+ className: 'cas-btn cas-btn-primary',
1051
+ onClick: onExport,
1052
+ disabled: !!busy,
1053
+ }, busy === 'export' ? (stage || `录制中 ${Math.round(progress * 100)}%`) : '导出视频'),
1054
+ ),
1055
+ h('div', { className: 'cas-stage', 'data-aspect': aspect },
1056
+ h('iframe', {
1057
+ ref: frameRef,
1058
+ className: 'cas-frame',
1059
+ title: anim.title || '动画',
1060
+ src: `${API}/animation/${anim.id}`,
1061
+ sandbox: 'allow-scripts allow-same-origin',
1062
+ onLoad: () => {
1063
+ const frame = frameRef.current;
1064
+ const doc = frame && frame.contentDocument;
1065
+ const win = frame && frame.contentWindow;
1066
+ const canvas = doc && doc.querySelector('canvas');
1067
+ if (canvas && canvas.width && canvas.height) {
1068
+ const ratio = canvas.width / canvas.height;
1069
+ setAspect(ratio > 1.2 ? '16:9' : ratio < 0.85 ? '9:16' : '1:1');
1070
+ }
1071
+ if (win && typeof win.renderAt === 'function') {
1072
+ try { win.renderAt(0); } catch { /* ignore */ }
1073
+ }
1074
+ },
1075
+ }),
1076
+ ),
1077
+ h(Timeline, {
1078
+ duration,
1079
+ scrub,
1080
+ beats,
1081
+ cuts,
1082
+ cues,
1083
+ score: scoreBuffer,
1084
+ fps,
1085
+ onSeek: (t) => { stopPlayback(); applyScrub(t); },
1086
+ }),
1087
+ h('div', { className: 'cas-bar' },
1088
+ h('button', {
1089
+ className: 'cas-play-btn',
1090
+ title: playing ? '暂停' : '播放',
1091
+ onClick: togglePlay,
1092
+ }, playing ? '⏸' : '▶'),
1093
+ h('input', {
1094
+ className: 'cas-scrub', type: 'range', min: 0, max: duration, step: 0.05,
1095
+ value: scrub,
1096
+ onChange: (e) => {
1097
+ stopPlayback();
1098
+ applyScrub(e.target.value);
1099
+ },
1100
+ }),
1101
+ h('span', { className: 'cas-t' }, `${Number(scrub).toFixed(2)}s / ${duration}s`),
1102
+ h('button', {
1103
+ className: 'cas-play-btn',
1104
+ style: { width: '24px', height: '24px', fontSize: '11px' },
1105
+ title: '从头播放',
1106
+ onClick: () => {
1107
+ stopPlayback();
1108
+ applyScrub(0);
1109
+ },
1110
+ }, '↺'),
1111
+ ),
1112
+ h('div', { className: 'cas-score' },
1113
+ h('span', { className: 'cas-score-label' }, '配乐'),
1114
+ custom || (music && music !== 'none')
1115
+ ? h('span', { className: 'cas-score-prompt', title: customName || music },
1116
+ customName ? `自定义:${customName}` : music)
1117
+ : h('span', { className: 'cas-score-hint' }, '无配乐'),
1118
+ h('span', { className: 'cas-score-hint' },
1119
+ custom ? '已载入自定义音频'
1120
+ : `${cuts.length} 个切点${cues.length ? ` + ${cues.length} 个画面重点` : ''} 已卡点`),
1121
+ h('span', { className: 'cas-grow' }),
1122
+ h('button', {
1123
+ className: 'cas-btn',
1124
+ onClick: () => {
1125
+ stopPlayback();
1126
+ if (custom || music === 'none') { setCustom(null); setCustomName(''); setMusic(anim.music || ''); return; }
1127
+ setMusic('none');
1128
+ },
1129
+ disabled: !!busy,
1130
+ }, custom || music === 'none' ? '恢复配乐' : '静音'),
1131
+ h('button', {
1132
+ className: 'cas-btn',
1133
+ onClick: () => { if (fileRef.current) fileRef.current.click(); },
1134
+ disabled: !!busy,
1135
+ }, '换音频…'),
1136
+ h('input', {
1137
+ ref: fileRef, type: 'file', accept: 'audio/*',
1138
+ style: { display: 'none' }, onChange: onPickFile,
1139
+ }),
1140
+ ),
1141
+ h('div', { className: 'cas-score' },
1142
+ h('span', { className: 'cas-score-label' }, '格式'),
1143
+ available.length > 1
1144
+ ? h('select', {
1145
+ className: 'cas-select',
1146
+ value: activeFormat,
1147
+ onChange: (e) => setFormat(e.target.value),
1148
+ }, available.map((c) => h('option', { key: c.id, value: c.id }, c.label)))
1149
+ : h('span', { className: 'cas-score-hint' }, available[0] ? available[0].label : '无可用容器'),
1150
+ h('span', { className: 'cas-score-hint' },
1151
+ available.some((c) => c.id === 'mp4')
1152
+ ? 'MP4 为 H.264 + AAC,可直接发微信、剪映、Premiere'
1153
+ : '当前浏览器不支持 MP4 录制,可用 ffmpeg 转码'),
1154
+ ),
1155
+ // State the destination before the export so the file is never a mystery.
1156
+ h('div', { className: 'cas-path' },
1157
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } },
1158
+ h('span', null, savedPath
1159
+ ? h('span', { className: 'cas-path-ok' }, `已保存到 ${savedPath}`)
1160
+ : (customDir || targetDir
1161
+ ? `导出目录:${customDir || targetDir}`
1162
+ : '导出目录:会话工作目录下的 code-animation/')),
1163
+ h('button', {
1164
+ className: 'cas-btn',
1165
+ style: { fontSize: '11px', padding: '2px 8px', height: '22px' },
1166
+ onClick: onPickDirectory,
1167
+ disabled: !!busy,
1168
+ }, '📂 选择文件夹…'),
1169
+ ),
1170
+ ),
1171
+ (anim.warnings && anim.warnings.length)
1172
+ ? h('div', { className: 'cas-warn' }, `建议检查:${anim.warnings.join(';')}`)
1173
+ : null,
1174
+ note ? h('div', { className: 'cas-note' }, note) : null,
1175
+ );
1176
+ }
1177
+
1178
+ /**
1179
+ * The Turn-level creation workspace.
1180
+ *
1181
+ * It renders beneath the closing prose and outside the collapsible Tool group,
1182
+ * so the reference stills, the storyboard draft and the finished animation stay
1183
+ * visible without expanding the process log.
1184
+ */
1185
+ function CreationTail(props) {
1186
+ const data = props.turn && props.turn.data ? props.turn.data.get(KIND) : undefined;
1187
+ const useSessions = props.useSessions;
1188
+ const sessionId = props.sessionId;
1189
+ // The session's working directory decides where exports land.
1190
+ const cwd = useSessions && sessionId
1191
+ ? useSessions((state) => state.byId[sessionId]?.cwd)
1192
+ : undefined;
1193
+ if (!data) return null;
1194
+
1195
+ // The reference stills and the storyboard draft render in their own Tool rows
1196
+ // (see ShowStillsRow and StoryboardRow), which exist as soon as each result
1197
+ // lands. Repeating them here would show the same card twice once the Turn ends,
1198
+ // so this Turn-level seat only carries what has no Tool row of its own.
1199
+ const latestAnim = data.animations[data.animations.length - 1];
1200
+ if (!latestAnim) return null;
1201
+
1202
+ return h('div', { className: 'cas-tail' },
1203
+ h('style', null, CSS),
1204
+ h(AnimationCard, { anim: latestAnim, cwd, uiWorkspace: props.uiWorkspace }),
1205
+ );
1206
+ }
1207
+
1208
+ /**
1209
+ * Fold one Turn's creation artifacts out of the session log.
1210
+ *
1211
+ * Everything is narrowed from the persisted call arguments and result text, so a
1212
+ * replayed Session reproduces the same cards with no Host change and no reliance
1213
+ * on live plugin memory.
1214
+ */
1215
+ const creationTurnDefinition = {
1216
+ kind: KIND,
1217
+ match: (event) => {
1218
+ if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' };
1219
+ if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' };
1220
+ // Replacement copies stay model-only; only appended results are transcript material.
1221
+ if (event.type === 'tool/result' && event.surfaceOp === 'append') {
1222
+ return { id: String(event.data.turn), role: 'update' };
1223
+ }
1224
+ return null;
1225
+ },
1226
+ start: (_context, match) => ({
1227
+ turn: match.event.data.turn,
1228
+ calls: new Map(),
1229
+ stills: null,
1230
+ storyboards: [],
1231
+ animations: [],
1232
+ }),
1233
+ update: (context, match) => {
1234
+ const state = context.state;
1235
+
1236
+ if (match.event.type === 'tool/call') {
1237
+ const calls = new Map(state.calls);
1238
+ calls.set(String(match.event.data.callId), {
1239
+ name: match.event.data.name,
1240
+ argsRaw: match.event.data.arguments,
1241
+ });
1242
+ return { ...state, calls };
1243
+ }
1244
+ if (match.event.type !== 'tool/result') return state;
1245
+
1246
+ const message = match.event.data.message;
1247
+ if (message.isError === true) return state;
1248
+ const call = state.calls.get(String(message.source.callId));
1249
+ if (!call) return state;
1250
+
1251
+ const args = parseArgs(call.argsRaw);
1252
+ const text = (message.content || [])
1253
+ .filter((block) => block.type === 'text')
1254
+ .map((block) => block.text)
1255
+ .join('\n');
1256
+
1257
+ if (call.name === 'show_stills') {
1258
+ const items = (Array.isArray(args.ids) ? args.ids : []).map((id) => {
1259
+ const key = String(id).toUpperCase();
1260
+ const match2 = new RegExp(`${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+([^|\\n]+)`).exec(text);
1261
+ return {
1262
+ id: key,
1263
+ name: match2 ? match2[1].trim() : '',
1264
+ desc: (new RegExp(`${key}\\s+[^|\\n]+|([^\\n]+)`).exec(text) || [])[1] || '',
1265
+ };
1266
+ });
1267
+ const missing = (new RegExp('没有参考静帧:([^\\n(]+)').exec(text) || [])[1];
1268
+ return {
1269
+ ...state,
1270
+ stills: {
1271
+ items,
1272
+ note: typeof args.note === 'string' ? args.note : '',
1273
+ missing: missing ? missing.split('、').map((s) => s.trim()).filter(Boolean) : [],
1274
+ },
1275
+ };
1276
+ }
1277
+
1278
+ if (call.name === 'save_storyboard') {
1279
+ const shots = Array.isArray(args.shots) ? args.shots : [];
1280
+ // Shot starts after 0 are the cut points the score will be fitted to.
1281
+ const cuts = shots
1282
+ .map((s) => Number(s.start))
1283
+ .filter((t) => Number.isFinite(t) && t > 0.05)
1284
+ .sort((a, b) => a - b);
1285
+ return {
1286
+ ...state,
1287
+ storyboards: [...state.storyboards, {
1288
+ title: String(args.title || ''),
1289
+ styleId: String(args.style_id || ''),
1290
+ styleName: '',
1291
+ duration: Number(args.duration) || 0,
1292
+ aspect: String(args.aspect || ''),
1293
+ music: String(args.music || ''),
1294
+ cuts,
1295
+ shots: shots.map((s, i) => ({
1296
+ index: i + 1,
1297
+ start: Number(s.start) || 0,
1298
+ end: Number(s.end) || 0,
1299
+ scene: String(s.scene || ''),
1300
+ text: String(s.text || ''),
1301
+ camera: String(s.camera || ''),
1302
+ })),
1303
+ warnings: (new RegExp('需要留意:([^\\n]+)').exec(text) || [])[1]
1304
+ ? (new RegExp('需要留意:([^\\n]+)').exec(text))[1].split(';').map((s) => s.trim()).filter(Boolean)
1305
+ : [],
1306
+ }],
1307
+ };
1308
+ }
1309
+
1310
+ if (call.name === 'save_animation') {
1311
+ const id = (new RegExp(`${API}/animation/([A-Za-z0-9_-]+)`).exec(text) || [])[1]
1312
+ || String(args.storyboard_id || '').replace(/^sb-/, 'anim-');
1313
+ if (!id) return state;
1314
+ const warn = (new RegExp('^(?:注意|建议检查):(.+)$', 'm').exec(text) || [])[1];
1315
+ // The linked storyboard supplies the cut points, so the score can be fitted
1316
+ // to the real edits rather than to a fixed tempo.
1317
+ const board = state.storyboards.find((b) => b.id === args.storyboard_id)
1318
+ || state.storyboards[state.storyboards.length - 1];
1319
+ // The card can only play the model's score if it knows where it lives.
1320
+ // 先读回执里的「配乐地址」,读不到就直接按 id 拼:配乐现在是必填项,每次保存
1321
+ // 都一定写了 <id>.score.js,所以这个地址永远有效——不再依赖回执文案的措辞。
1322
+ const scoreUrl = (new RegExp('配乐地址:([^\\s]+)').exec(text) || [])[1] || `${API}/score/${id}`;
1323
+ return {
1324
+ ...state,
1325
+ animations: [...state.animations, {
1326
+ id,
1327
+ title: String(args.title || ''),
1328
+ styleId: String(args.style_id || ''),
1329
+ styleName: '',
1330
+ duration: Number(args.duration) || 8,
1331
+ fps: Number(args.fps) || 30,
1332
+ music: String(args.music || (board && board.music) || ''),
1333
+ scoreUrl,
1334
+ cuts: board ? board.cuts || [] : [],
1335
+ cues: Array.isArray(args.cues) ? args.cues : [],
1336
+ beats: Array.isArray(args.beats) ? args.beats : [],
1337
+ warnings: warn ? warn.split(';').map((s) => s.trim()).filter(Boolean) : [],
1338
+ }],
1339
+ };
1340
+ }
1341
+
1342
+ return state;
1343
+ },
1344
+ buildLocationData: (context, scope, previous) => {
1345
+ if (scope !== 'turn' || context.state === undefined) return null;
1346
+ const state = context.state;
1347
+ // Reuse the previous value when nothing changed, so the snapshot does not republish.
1348
+ if (
1349
+ previous && previous.kind === 'turn' && previous.turn === state.turn && previous.key === KIND
1350
+ && previous.value.stills === state.stills
1351
+ && previous.value.storyboards === state.storyboards
1352
+ && previous.value.animations === state.animations
1353
+ ) return previous;
1354
+ return {
1355
+ kind: 'turn',
1356
+ turn: state.turn,
1357
+ key: KIND,
1358
+ value: {
1359
+ stills: state.stills,
1360
+ storyboards: state.storyboards,
1361
+ animations: state.animations,
1362
+ },
1363
+ };
1364
+ },
1365
+ };
1366
+
1367
+ /**
1368
+ * Rebuild a storyboard card model from the Tool result's structured `meta`.
1369
+ *
1370
+ * The Host echoes the validated draft as `meta`, so this path uses the stored
1371
+ * fields instead of re-parsing the rendered prose the way the Turn fold does.
1372
+ */
1373
+ function boardFromMeta(meta) {
1374
+ if (!meta || !Array.isArray(meta.shots)) return null;
1375
+ const shots = meta.shots;
1376
+ return {
1377
+ id: String(meta.id || ''),
1378
+ title: String(meta.title || ''),
1379
+ styleId: String(meta.styleId || ''),
1380
+ styleName: String(meta.styleName || ''),
1381
+ duration: Number(meta.duration) || 0,
1382
+ aspect: String(meta.aspect || ''),
1383
+ music: String(meta.music || ''),
1384
+ cuts: shots
1385
+ .map((s) => Number(s.start))
1386
+ .filter((t) => Number.isFinite(t) && t > 0.05)
1387
+ .sort((a, b) => a - b),
1388
+ shots: shots.map((s, i) => ({
1389
+ index: Number(s.index) || i + 1,
1390
+ start: Number(s.start) || 0,
1391
+ end: Number(s.end) || 0,
1392
+ scene: String(s.scene || ''),
1393
+ text: String(s.text || ''),
1394
+ camera: String(s.camera || ''),
1395
+ })),
1396
+ warnings: Array.isArray(meta.warnings) ? meta.warnings : [],
1397
+ };
1398
+ }
1399
+
1400
+ /**
1401
+ * Inline Tool views for the two cards the flow depends on.
1402
+ *
1403
+ * The Turn-level `turnTail` only publishes on `turn/end`, so during the Turn —
1404
+ * exactly when the user is being asked to choose a style or approve a draft —
1405
+ * those cards are not mounted yet. These views render the same content in the
1406
+ * Tool row that produced it, which exists as soon as its result lands.
1407
+ */
1408
+ function ShowStillsRow(props) {
1409
+ const { block } = props || {};
1410
+ const meta = block && block.meta;
1411
+ const shown = meta && Array.isArray(meta.shown) ? meta.shown : null;
1412
+ if (!shown || !shown.length) return null;
1413
+ return h('div', { className: 'cas-tail cas-inline' },
1414
+ h('style', null, CSS),
1415
+ h(StillGallery, {
1416
+ // The Host names the field `description`; the gallery renders `desc`.
1417
+ stills: shown.map((item) => ({
1418
+ id: String(item.id || ''),
1419
+ name: String(item.name || ''),
1420
+ desc: String(item.description || ''),
1421
+ })),
1422
+ note: String(meta.note || ''),
1423
+ missing: Array.isArray(meta.missing) ? meta.missing : [],
1424
+ }),
1425
+ );
1426
+ }
1427
+
1428
+ function StoryboardRow(props) {
1429
+ const { block } = props || {};
1430
+ const board = boardFromMeta(block && block.meta);
1431
+ if (!board) return null;
1432
+ return h('div', { className: 'cas-tail cas-inline' },
1433
+ h('style', null, CSS),
1434
+ h(StoryboardCard, { board }),
1435
+ );
1436
+ }
1437
+
1438
+ /**
1439
+ * 样式库视图:把 193 种已上架风格连同参考图一次列全。
1440
+ *
1441
+ * 目录来自 Host 的 /code-animation-api/styles,参考图走 /still/<id>,两者都只
1442
+ * 认已上架的风格,所以这里看到的就是 Agent 能选到的那一批。
1443
+ *
1444
+ * 它注册成 conversation.view,也就是「对话 / 轨迹」旁边多出来的那个页签。
1445
+ */
1446
+ function StyleLibrary({ sessionId, useSessions }) {
1447
+ const selector = useMemo(() => (state) => {
1448
+ const value = state.byId[sessionId]?.projectionValues?.agentPreset;
1449
+ return typeof value === 'string' ? value : undefined;
1450
+ }, [sessionId]);
1451
+ const preset = useSessions(selector);
1452
+ const [data, setData] = useState(null);
1453
+ const [error, setError] = useState('');
1454
+ const [query, setQuery] = useState('');
1455
+ const [series, setSeries] = useState('*');
1456
+ const [zoom, setZoom] = useState('');
1457
+
1458
+ useEffect(() => {
1459
+ let alive = true;
1460
+ fetch(`${API}/styles`)
1461
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
1462
+ .then((payload) => { if (alive) setData(payload); })
1463
+ .catch((cause) => { if (alive) setError(cause.message || '目录加载失败'); });
1464
+ return () => { alive = false; };
1465
+ }, []);
1466
+
1467
+ useEffect(() => {
1468
+ const onKey = (event) => { if (event.key === 'Escape') setZoom(''); };
1469
+ window.addEventListener('keydown', onKey);
1470
+ return () => window.removeEventListener('keydown', onKey);
1471
+ }, []);
1472
+
1473
+ const all = (data && data.styles) || [];
1474
+ const list = useMemo(() => {
1475
+ const keyword = query.trim().toLowerCase();
1476
+ return all.filter((style) => {
1477
+ if (series !== '*' && style.series !== series) return false;
1478
+ if (!keyword) return true;
1479
+ return [style.id, style.name, style.en, style.description, style.fit, (style.tags || []).join(' ')]
1480
+ .join(' ').toLowerCase().includes(keyword);
1481
+ });
1482
+ }, [all, query, series]);
1483
+
1484
+ const chips = [{ id: '*', name: '全部' }, ...((data && data.series) || [])];
1485
+
1486
+ // 这个页签对所有会话都注册,但只有创作模式的会话有内容可看。
1487
+ if (preset !== 'code-animation') {
1488
+ return h('div', { className: 'cas-lib-view' },
1489
+ h('style', null, CSS),
1490
+ h('div', { className: 'cas-lib-empty' }, '样式库只在创作模式的会话里可用'));
1491
+ }
1492
+
1493
+ return h('div', { className: 'cas-lib-view' },
1494
+ h('style', null, CSS),
1495
+ h('div', { className: 'cas-lib-head' },
1496
+ h('span', { className: 'cas-lib-title' }, '样式库'),
1497
+ h('span', { className: 'cas-lib-count' }, error || `${list.length} / ${all.length}`),
1498
+ h('input', {
1499
+ className: 'cas-lib-search',
1500
+ placeholder: '搜编号、名字、气质或题材,例如 水墨 / 赛博 / B3',
1501
+ value: query,
1502
+ onChange: (event) => setQuery(event.target.value),
1503
+ }),
1504
+ ),
1505
+ h('div', { className: 'cas-lib-chips' },
1506
+ chips.map((chip) => h('button', {
1507
+ key: chip.id,
1508
+ type: 'button',
1509
+ className: 'cas-chip',
1510
+ 'data-on': series === chip.id ? '1' : '0',
1511
+ onClick: () => setSeries(chip.id),
1512
+ }, chip.id === '*' ? chip.name : `${chip.id} ${chip.name}`)),
1513
+ ),
1514
+ h('div', { className: 'cas-lib-body' },
1515
+ error
1516
+ ? h('div', { className: 'cas-lib-empty' }, `目录加载失败:${error}`)
1517
+ : !data
1518
+ ? h('div', { className: 'cas-lib-empty' }, '正在加载…')
1519
+ : !list.length
1520
+ ? h('div', { className: 'cas-lib-empty' }, '没有匹配的风格')
1521
+ : h('div', { className: 'cas-lib-grid' },
1522
+ list.map((style) => h('button', {
1523
+ key: style.id,
1524
+ type: 'button',
1525
+ className: 'cas-lib-card',
1526
+ title: [style.description, style.fit].filter(Boolean).join(' · '),
1527
+ onClick: () => setZoom(style.stillUrl),
1528
+ },
1529
+ h('img', { src: style.stillUrl, alt: `${style.id} ${style.name}`, loading: 'lazy' }),
1530
+ h('div', { className: 'cas-lib-cap' },
1531
+ h('span', { className: 'cas-lib-id' }, style.id),
1532
+ h('span', { className: 'cas-lib-name' }, style.name),
1533
+ h('span', { className: 'cas-lib-desc' }, style.description || style.fit || ''),
1534
+ ),
1535
+ ))),
1536
+ ),
1537
+ zoom
1538
+ ? h('div', {
1539
+ className: 'cas-lightbox',
1540
+ onClick: () => setZoom(''),
1541
+ }, h('img', { src: zoom, alt: '' }))
1542
+ : null);
1543
+ }
1544
+
1545
+ return {
1546
+ inject: ['slots', 'uiConversation'],
1547
+ apply(ctx) {
1548
+ ctx.effect(() => ctx.uiConversation.events.register(creationTurnDefinition), 'code-animation-studio:turn-data');
1549
+
1550
+ ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
1551
+ name: 'conversation.chat.turnTail',
1552
+ id: 'code-animation-studio',
1553
+ order: 30,
1554
+ label: '代码动画',
1555
+ }, (props) => h(CreationTail, { ...props, uiWorkspace: ctx.get('uiWorkspace') })));
1556
+
1557
+ // One view per producing Tool, keyed by the wire Tool name.
1558
+ ctx.slots.inject('tool.call.toolview', function* () {
1559
+ yield ctx.slots.register({
1560
+ name: 'tool.call.toolview',
1561
+ key: 'show_stills',
1562
+ }, ShowStillsRow);
1563
+ yield ctx.slots.register({
1564
+ name: 'tool.call.toolview',
1565
+ key: 'save_storyboard',
1566
+ }, StoryboardRow);
1567
+ });
1568
+
1569
+ // 样式库做成「对话 / 轨迹」旁边的第三个页签:view 槽的每个注册就是页签行
1570
+ // 里的一项,内容由同一个组件渲染。非创作模式的会话也能点开,里面只说明
1571
+ // 它属于创作模式,不报错也不空白。
1572
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1573
+ name: 'conversation.view',
1574
+ id: 'styles',
1575
+ order: 20,
1576
+ label: '样式库',
1577
+ }, StyleLibrary));
1578
+ },
1579
+ };
1580
+ },
1581
+ });