dsh-creation-mode 0.0.0-stage → 3.0.0
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.
- package/LICENSE +33 -0
- package/README.md +26 -2
- package/client.js +2650 -0
- package/cordis.patch.yml +106 -0
- package/data.js +354 -0
- package/flow.js +338 -0
- package/icon.svg +13 -0
- package/index.js +234 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
- package/stills/A1.jpg +0 -0
- package/stills/A2.jpg +0 -0
- package/stills/A3.jpg +0 -0
- package/stills/A4.jpg +0 -0
- package/stills/A5.jpg +0 -0
- package/stills/B1.jpg +0 -0
- package/stills/B2.jpg +0 -0
- package/stills/B3.jpg +0 -0
- package/stills/B4.jpg +0 -0
- package/stills/B5.jpg +0 -0
- package/stills/C1.jpg +0 -0
- package/stills/C2.jpg +0 -0
- package/stills/C3.jpg +0 -0
- package/stills/C4.jpg +0 -0
- package/stills/D1.jpg +0 -0
- package/stills/D2.jpg +0 -0
- package/stills/D3.jpg +0 -0
- package/stills/D4.jpg +0 -0
- package/stills/E1.jpg +0 -0
- package/stills/E2.jpg +0 -0
- package/stills/E3.jpg +0 -0
- package/stills/F1.jpg +0 -0
- package/stills/F2.jpg +0 -0
- package/stills/F3.jpg +0 -0
- package/stills/G1.jpg +0 -0
- package/stills/G2.jpg +0 -0
- package/stills/H1.jpg +0 -0
- package/stills/H10.jpg +0 -0
- package/stills/H11.jpg +0 -0
- package/stills/H12.jpg +0 -0
- package/stills/H2.jpg +0 -0
- package/stills/H3.jpg +0 -0
- package/stills/H4.jpg +0 -0
- package/stills/H5.jpg +0 -0
- package/stills/H6.jpg +0 -0
- package/stills/H7.jpg +0 -0
- package/stills/H8.jpg +0 -0
- package/stills/H9.jpg +0 -0
- package/stills/K1.jpg +0 -0
- package/stills/K10.jpg +0 -0
- package/stills/K11.jpg +0 -0
- package/stills/K12.jpg +0 -0
- package/stills/K13.jpg +0 -0
- package/stills/K14.jpg +0 -0
- package/stills/K15.jpg +0 -0
- package/stills/K16.jpg +0 -0
- package/stills/K17.jpg +0 -0
- package/stills/K18.jpg +0 -0
- package/stills/K19.jpg +0 -0
- package/stills/K2.jpg +0 -0
- package/stills/K20.jpg +0 -0
- package/stills/K21.jpg +0 -0
- package/stills/K22.jpg +0 -0
- package/stills/K23.jpg +0 -0
- package/stills/K24.jpg +0 -0
- package/stills/K25.jpg +0 -0
- package/stills/K26.jpg +0 -0
- package/stills/K27.jpg +0 -0
- package/stills/K28.jpg +0 -0
- package/stills/K29.jpg +0 -0
- package/stills/K3.jpg +0 -0
- package/stills/K30.jpg +0 -0
- package/stills/K31.jpg +0 -0
- package/stills/K32.jpg +0 -0
- package/stills/K33.jpg +0 -0
- package/stills/K34.jpg +0 -0
- package/stills/K35.jpg +0 -0
- package/stills/K36.jpg +0 -0
- package/stills/K37.jpg +0 -0
- package/stills/K38.jpg +0 -0
- package/stills/K4.jpg +0 -0
- package/stills/K5.jpg +0 -0
- package/stills/K6.jpg +0 -0
- package/stills/K7.jpg +0 -0
- package/stills/K8.jpg +0 -0
- package/stills/K9.jpg +0 -0
- package/stills/L1.jpg +0 -0
- package/stills/L10.jpg +0 -0
- package/stills/L11.jpg +0 -0
- package/stills/L12.jpg +0 -0
- package/stills/L13.jpg +0 -0
- package/stills/L14.jpg +0 -0
- package/stills/L15.jpg +0 -0
- package/stills/L16.jpg +0 -0
- package/stills/L17.jpg +0 -0
- package/stills/L18.jpg +0 -0
- package/stills/L19.jpg +0 -0
- package/stills/L2.jpg +0 -0
- package/stills/L20.jpg +0 -0
- package/stills/L21.jpg +0 -0
- package/stills/L22.jpg +0 -0
- package/stills/L23.jpg +0 -0
- package/stills/L24.jpg +0 -0
- package/stills/L25.jpg +0 -0
- package/stills/L26.jpg +0 -0
- package/stills/L27.jpg +0 -0
- package/stills/L28.jpg +0 -0
- package/stills/L29.jpg +0 -0
- package/stills/L3.jpg +0 -0
- package/stills/L30.jpg +0 -0
- package/stills/L31.jpg +0 -0
- package/stills/L32.jpg +0 -0
- package/stills/L33.jpg +0 -0
- package/stills/L34.jpg +0 -0
- package/stills/L35.jpg +0 -0
- package/stills/L36.jpg +0 -0
- package/stills/L37.jpg +0 -0
- package/stills/L38.jpg +0 -0
- package/stills/L39.jpg +0 -0
- package/stills/L4.jpg +0 -0
- package/stills/L40.jpg +0 -0
- package/stills/L41.jpg +0 -0
- package/stills/L42.jpg +0 -0
- package/stills/L43.jpg +0 -0
- package/stills/L44.jpg +0 -0
- package/stills/L45.jpg +0 -0
- package/stills/L46.jpg +0 -0
- package/stills/L47.jpg +0 -0
- package/stills/L48.jpg +0 -0
- package/stills/L49.jpg +0 -0
- package/stills/L5.jpg +0 -0
- package/stills/L50.jpg +0 -0
- package/stills/L51.jpg +0 -0
- package/stills/L52.jpg +0 -0
- package/stills/L53.jpg +0 -0
- package/stills/L54.jpg +0 -0
- package/stills/L55.jpg +0 -0
- package/stills/L56.jpg +0 -0
- package/stills/L57.jpg +0 -0
- package/stills/L58.jpg +0 -0
- package/stills/L59.jpg +0 -0
- package/stills/L6.jpg +0 -0
- package/stills/L7.jpg +0 -0
- package/stills/L8.jpg +0 -0
- package/stills/L9.jpg +0 -0
- package/stills/M1.jpg +0 -0
- package/stills/M10.jpg +0 -0
- package/stills/M11.jpg +0 -0
- package/stills/M12.jpg +0 -0
- package/stills/M13.jpg +0 -0
- package/stills/M14.jpg +0 -0
- package/stills/M15.jpg +0 -0
- package/stills/M16.jpg +0 -0
- package/stills/M17.jpg +0 -0
- package/stills/M18.jpg +0 -0
- package/stills/M19.jpg +0 -0
- package/stills/M2.jpg +0 -0
- package/stills/M20.jpg +0 -0
- package/stills/M21.jpg +0 -0
- package/stills/M22.jpg +0 -0
- package/stills/M23.jpg +0 -0
- package/stills/M24.jpg +0 -0
- package/stills/M25.jpg +0 -0
- package/stills/M26.jpg +0 -0
- package/stills/M27.jpg +0 -0
- package/stills/M28.jpg +0 -0
- package/stills/M29.jpg +0 -0
- package/stills/M3.jpg +0 -0
- package/stills/M30.jpg +0 -0
- package/stills/M31.jpg +0 -0
- package/stills/M32.jpg +0 -0
- package/stills/M33.jpg +0 -0
- package/stills/M34.jpg +0 -0
- package/stills/M35.jpg +0 -0
- package/stills/M36.jpg +0 -0
- package/stills/M37.jpg +0 -0
- package/stills/M38.jpg +0 -0
- package/stills/M39.jpg +0 -0
- package/stills/M4.jpg +0 -0
- package/stills/M40.jpg +0 -0
- package/stills/M5.jpg +0 -0
- package/stills/M6.jpg +0 -0
- package/stills/M7.jpg +0 -0
- package/stills/M8.jpg +0 -0
- package/stills/M9.jpg +0 -0
- package/stills/O1.jpg +0 -0
- package/stills/O10.jpg +0 -0
- package/stills/O11.jpg +0 -0
- package/stills/O12.jpg +0 -0
- package/stills/O13.jpg +0 -0
- package/stills/O14.jpg +0 -0
- package/stills/O15.jpg +0 -0
- package/stills/O16.jpg +0 -0
- package/stills/O17.jpg +0 -0
- package/stills/O18.jpg +0 -0
- package/stills/O19.jpg +0 -0
- package/stills/O2.jpg +0 -0
- package/stills/O20.jpg +0 -0
- package/stills/O21.jpg +0 -0
- package/stills/O22.jpg +0 -0
- package/stills/O23.jpg +0 -0
- package/stills/O24.jpg +0 -0
- package/stills/O25.jpg +0 -0
- package/stills/O26.jpg +0 -0
- package/stills/O27.jpg +0 -0
- package/stills/O28.jpg +0 -0
- package/stills/O29.jpg +0 -0
- package/stills/O3.jpg +0 -0
- package/stills/O30.jpg +0 -0
- package/stills/O31.jpg +0 -0
- package/stills/O32.jpg +0 -0
- package/stills/O33.jpg +0 -0
- package/stills/O34.jpg +0 -0
- package/stills/O35.jpg +0 -0
- package/stills/O36.jpg +0 -0
- package/stills/O37.jpg +0 -0
- package/stills/O38.jpg +0 -0
- package/stills/O39.jpg +0 -0
- package/stills/O4.jpg +0 -0
- package/stills/O40.jpg +0 -0
- package/stills/O41.jpg +0 -0
- package/stills/O42.jpg +0 -0
- package/stills/O43.jpg +0 -0
- package/stills/O44.jpg +0 -0
- package/stills/O45.jpg +0 -0
- package/stills/O46.jpg +0 -0
- package/stills/O47.jpg +0 -0
- package/stills/O48.jpg +0 -0
- package/stills/O49.jpg +0 -0
- package/stills/O5.jpg +0 -0
- package/stills/O50.jpg +0 -0
- package/stills/O51.jpg +0 -0
- package/stills/O52.jpg +0 -0
- package/stills/O53.jpg +0 -0
- package/stills/O54.jpg +0 -0
- package/stills/O55.jpg +0 -0
- package/stills/O56.jpg +0 -0
- package/stills/O57.jpg +0 -0
- package/stills/O58.jpg +0 -0
- package/stills/O59.jpg +0 -0
- package/stills/O6.jpg +0 -0
- package/stills/O60.jpg +0 -0
- package/stills/O61.jpg +0 -0
- package/stills/O62.jpg +0 -0
- package/stills/O63.jpg +0 -0
- package/stills/O64.jpg +0 -0
- package/stills/O65.jpg +0 -0
- package/stills/O66.jpg +0 -0
- package/stills/O67.jpg +0 -0
- package/stills/O68.jpg +0 -0
- package/stills/O69.jpg +0 -0
- package/stills/O7.jpg +0 -0
- package/stills/O70.jpg +0 -0
- package/stills/O71.jpg +0 -0
- package/stills/O72.jpg +0 -0
- package/stills/O73.jpg +0 -0
- package/stills/O74.jpg +0 -0
- package/stills/O75.jpg +0 -0
- package/stills/O76.jpg +0 -0
- package/stills/O77.jpg +0 -0
- package/stills/O78.jpg +0 -0
- package/stills/O79.jpg +0 -0
- package/stills/O8.jpg +0 -0
- package/stills/O80.jpg +0 -0
- package/stills/O81.jpg +0 -0
- package/stills/O82.jpg +0 -0
- package/stills/O83.jpg +0 -0
- package/stills/O84.jpg +0 -0
- package/stills/O85.jpg +0 -0
- package/stills/O86.jpg +0 -0
- package/stills/O87.jpg +0 -0
- package/stills/O88.jpg +0 -0
- package/stills/O89.jpg +0 -0
- package/stills/O9.jpg +0 -0
- package/stills/O90.jpg +0 -0
- package/stills/O91.jpg +0 -0
- package/stills/O92.jpg +0 -0
- package/stills/O93.jpg +0 -0
- package/stills/O94.jpg +0 -0
- package/stills/O95.jpg +0 -0
- package/stills/O96.jpg +0 -0
- package/stills/O97.jpg +0 -0
- package/stills/O98.jpg +0 -0
- package/stills/R1.jpg +0 -0
- package/stills/R2.jpg +0 -0
- package/stills/R3.jpg +0 -0
- package/stills/R4.jpg +0 -0
- package/stills/R5.jpg +0 -0
- package/stills/R6.jpg +0 -0
- package/stills/R7.jpg +0 -0
- package/stills/V1.jpg +0 -0
- package/stills/V10.jpg +0 -0
- package/stills/V11.jpg +0 -0
- package/stills/V12.jpg +0 -0
- package/stills/V13.jpg +0 -0
- package/stills/V14.jpg +0 -0
- package/stills/V15.jpg +0 -0
- package/stills/V2.jpg +0 -0
- package/stills/V3.jpg +0 -0
- package/stills/V4.jpg +0 -0
- package/stills/V5.jpg +0 -0
- package/stills/V6.jpg +0 -0
- package/stills/V7.jpg +0 -0
- package/stills/V8.jpg +0 -0
- package/stills/V9.jpg +0 -0
- package/stills/X1.jpg +0 -0
- package/stills/X10.jpg +0 -0
- package/stills/X11.jpg +0 -0
- package/stills/X12.jpg +0 -0
- package/stills/X13.jpg +0 -0
- package/stills/X14.jpg +0 -0
- package/stills/X15.jpg +0 -0
- package/stills/X16.jpg +0 -0
- package/stills/X17.jpg +0 -0
- package/stills/X18.jpg +0 -0
- package/stills/X19.jpg +0 -0
- package/stills/X2.jpg +0 -0
- package/stills/X20.jpg +0 -0
- package/stills/X21.jpg +0 -0
- package/stills/X22.jpg +0 -0
- package/stills/X23.jpg +0 -0
- package/stills/X24.jpg +0 -0
- package/stills/X25.jpg +0 -0
- package/stills/X26.jpg +0 -0
- package/stills/X27.jpg +0 -0
- package/stills/X28.jpg +0 -0
- package/stills/X29.jpg +0 -0
- package/stills/X3.jpg +0 -0
- package/stills/X30.jpg +0 -0
- package/stills/X31.jpg +0 -0
- package/stills/X4.jpg +0 -0
- package/stills/X5.jpg +0 -0
- package/stills/X6.jpg +0 -0
- package/stills/X7.jpg +0 -0
- package/stills/X8.jpg +0 -0
- package/stills/X9.jpg +0 -0
- package/stills/Z1.jpg +0 -0
- package/stills/Z10.jpg +0 -0
- package/stills/Z11.jpg +0 -0
- package/stills/Z12.jpg +0 -0
- package/stills/Z13.jpg +0 -0
- package/stills/Z14.jpg +0 -0
- package/stills/Z15.jpg +0 -0
- package/stills/Z2.jpg +0 -0
- package/stills/Z3.jpg +0 -0
- package/stills/Z4.jpg +0 -0
- package/stills/Z5.jpg +0 -0
- package/stills/Z6.jpg +0 -0
- package/stills/Z7.jpg +0 -0
- package/stills/Z8.jpg +0 -0
- package/stills/Z9.jpg +0 -0
- package/stills/approved.json +198 -0
- package/stills/index.json +1 -0
- package/styles.json +5090 -0
- package/tools.js +788 -0
package/client.js
ADDED
|
@@ -0,0 +1,2650 @@
|
|
|
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
|
+
const PALETTE = ['#ff8a3d', '#e0457b', '#7b5bff', '#3fd7c0', '#4d6bfe', '#ffc04d'];
|
|
113
|
+
|
|
114
|
+
/** Parse one tool call's raw argument text; malformed input degrades to {}. */
|
|
115
|
+
function parseArgs(raw) {
|
|
116
|
+
if (typeof raw !== 'string' || raw === '') return {};
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
120
|
+
} catch {
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Real-time, cue-accurate score synthesis.
|
|
127
|
+
*
|
|
128
|
+
* Two problems have to be solved together for a cut to feel like a 卡点:
|
|
129
|
+
*
|
|
130
|
+
* 1. The cut must sit on a strong musical position. That is a tempo-and-phase
|
|
131
|
+
* problem: pick the beat period that puts every cut on a bar line, not merely
|
|
132
|
+
* "near a beat". (This is the same thing `web-audio-beat-detector` solves when it
|
|
133
|
+
* jointly estimates tempo and first-beat offset — here it is inverted, because the
|
|
134
|
+
* cut times are known up front and the music is composed to them.)
|
|
135
|
+
*
|
|
136
|
+
* 2. The bar has to be an actual groove. A drone with occasional booms does not read
|
|
137
|
+
* as an accent, because there is no pulse for the accent to break. So every bar
|
|
138
|
+
* carries kick, snare, hats and bass, and the cut lands where the ear expects the
|
|
139
|
+
* downbeat.
|
|
140
|
+
*
|
|
141
|
+
* On top of that, the arrangement sidechains under each cue so the impact lands in a
|
|
142
|
+
* gap rather than on top of an equally loud bed.
|
|
143
|
+
*
|
|
144
|
+
* Everything is synthesised with Web Audio through `OfflineAudioContext`, so there are
|
|
145
|
+
* no bundled assets and no network.
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
const MUSIC_PRESETS = [
|
|
149
|
+
{ id: 'auto', label: '智能匹配分镜卡点' },
|
|
150
|
+
{ id: 'guofeng', label: '国风水墨韵律' },
|
|
151
|
+
{ id: 'epic', label: '史诗宏大震撼' },
|
|
152
|
+
{ id: 'synth', label: '赛博未来科技' },
|
|
153
|
+
{ id: 'pulse', label: '快节奏动感脉冲' },
|
|
154
|
+
{ id: 'bright', label: '活泼轻快灵动' },
|
|
155
|
+
{ id: 'chiptune', label: '8-bit 芯片音乐' },
|
|
156
|
+
{ id: 'ambient', label: '沉浸深邃氛围' },
|
|
157
|
+
{ id: 'none', label: '无配乐' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const SCALES = {
|
|
161
|
+
pentatonic: [0, 2, 4, 7, 9],
|
|
162
|
+
minorPent: [0, 3, 5, 7, 10],
|
|
163
|
+
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
164
|
+
major: [0, 2, 4, 5, 7, 9, 11],
|
|
165
|
+
dorian: [0, 2, 3, 5, 7, 9, 10],
|
|
166
|
+
phrygian: [0, 1, 3, 5, 7, 8, 10],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Bar patterns, sixteen steps each: one character per sixteenth note.
|
|
171
|
+
*
|
|
172
|
+
* `K`/`k` kick (accent/soft), `X` kick and snare together, `S` snare,
|
|
173
|
+
* `H`/`h` hat (accent/soft), `o` open hat, `.` silence.
|
|
174
|
+
*
|
|
175
|
+
* `four` is four-on-the-floor (a kick on every beat), `backbeat` is kick on 1 and 3
|
|
176
|
+
* with the snare on 2 and 4, `sparse` leaves air for an arrival. Each family holds
|
|
177
|
+
* several variants because repeating one bar verbatim is what made every score read
|
|
178
|
+
* as the same loop; consecutive bars rotate through them.
|
|
179
|
+
*/
|
|
180
|
+
const GROOVE_PATTERNS = {
|
|
181
|
+
four: [
|
|
182
|
+
'K.h.X.h.K.h.X.h.',
|
|
183
|
+
'K.hhX.hhK.hhX.hh',
|
|
184
|
+
'K.h.X.h.K.h.X.oH',
|
|
185
|
+
'K.h.X.hhK.h.X.hh',
|
|
186
|
+
],
|
|
187
|
+
backbeat: [
|
|
188
|
+
'K.h.S.h.K.h.S.h.',
|
|
189
|
+
'K..hS..hK..hS..h',
|
|
190
|
+
'K.hhS.hhK.hhS.hh',
|
|
191
|
+
'K.h.S.h.K.h.S.oH',
|
|
192
|
+
],
|
|
193
|
+
sparse: [
|
|
194
|
+
'K.......o.......',
|
|
195
|
+
'K.....k.o.......',
|
|
196
|
+
'K.......h...S...',
|
|
197
|
+
'K...k...o...S...',
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One instrument set per mood.
|
|
203
|
+
*
|
|
204
|
+
* Every preset used to share a single electronic kit — the same sine kick, the same
|
|
205
|
+
* noise snare, the same saw pluck — so changing mood moved only the scale, tempo and
|
|
206
|
+
* reverb and every score still sounded like the same track. These sets change the
|
|
207
|
+
* instrument families themselves: a taiko and a plucked zither for 国风, an orchestra
|
|
208
|
+
* for 史诗, an 808/909 machine for 赛博, a marimba for 轻快, an FM bell for 氛围.
|
|
209
|
+
*
|
|
210
|
+
* Voice parameters are read by the primitives below; `null` drops the voice entirely.
|
|
211
|
+
*/
|
|
212
|
+
/**
|
|
213
|
+
* Instruments, each built by a different synthesis method.
|
|
214
|
+
*
|
|
215
|
+
* A parameter sweep over one oscillator cannot make a zither, a flute and a piano —
|
|
216
|
+
* they differ in *how* they make sound, not in cutoff and decay. Each entry here names
|
|
217
|
+
* the method its voice uses:
|
|
218
|
+
*
|
|
219
|
+
* pluck filtered oscillator stack, exponential decay, optional pitch bend
|
|
220
|
+
* struck additive partials with hammer noise; higher partials decay faster (piano)
|
|
221
|
+
* fm carrier ring-modulated by a ratio (marimba, bell, e-piano)
|
|
222
|
+
* bowed detuned saw with bow noise, vibrato and a formant peak (violin, cello, brass)
|
|
223
|
+
* wind sine/triangle with breath noise and vibrato (笛子)
|
|
224
|
+
* chip unfiltered pulse wave by duty cycle (NES/8-bit)
|
|
225
|
+
*/
|
|
226
|
+
const INSTRUMENTS = {
|
|
227
|
+
// ---- 中国乐器 ----
|
|
228
|
+
zheng: {
|
|
229
|
+
synth: 'pluck', wave: 'triangle', attack: 0.0015, c0: 3600, c1: 520, bend: -0.5, decayScale: 0.9,
|
|
230
|
+
// 古筝的泛音列比钢琴更亮、更不谐和
|
|
231
|
+
partials: [{ wave: 'sine', octave: 12, gain: 0.40 }, { wave: 'sine', octave: 19, gain: 0.16 }, { wave: 'triangle', octave: 24, gain: 0.08 }],
|
|
232
|
+
},
|
|
233
|
+
dizi: {
|
|
234
|
+
synth: 'wind', wave: 'sine', attack: 0.05, decayScale: 1.7, sustain: true,
|
|
235
|
+
breath: 0.20, breathF: 2600, air: 0.05, vibrato: 5.6, vibratoDepth: 0.013, c0: 2600, c1: 2000, level: 0.85,
|
|
236
|
+
},
|
|
237
|
+
// ---- 西洋乐器 ----
|
|
238
|
+
piano: {
|
|
239
|
+
synth: 'struck', attack: 0.003, decayScale: 1.5, hammer: 0.09, hammerF: 3200,
|
|
240
|
+
// 非谐泛音 + 高次泛音衰减更快:这是钢琴与拨弦最大的区别
|
|
241
|
+
partials: [
|
|
242
|
+
{ octave: 0, gain: 1 }, { octave: 12, gain: 0.34 }, { octave: 19, gain: 0.20 },
|
|
243
|
+
{ octave: 24, gain: 0.10 }, { octave: 28, gain: 0.05 },
|
|
244
|
+
],
|
|
245
|
+
c0: 2800, c1: 700,
|
|
246
|
+
},
|
|
247
|
+
violin: {
|
|
248
|
+
synth: 'bowed', wave: 'sawtooth', attack: 0.075, decayScale: 1.8, sustain: true,
|
|
249
|
+
bow: 0.09, bowF: 3000, vibrato: 5.8, vibratoDepth: 0.010, formant: 900, c0: 3200, c1: 2400, level: 0.9,
|
|
250
|
+
},
|
|
251
|
+
cello: {
|
|
252
|
+
synth: 'bowed', wave: 'sawtooth', attack: 0.11, decayScale: 2.0, sustain: true,
|
|
253
|
+
bow: 0.07, bowF: 1800, vibrato: 4.6, vibratoDepth: 0.008, formant: 520, c0: 1700, c1: 1300, level: 1.0,
|
|
254
|
+
},
|
|
255
|
+
brass: {
|
|
256
|
+
synth: 'bowed', wave: 'sawtooth', attack: 0.055, decayScale: 1.9, sustain: false,
|
|
257
|
+
bow: 0.02, bowF: 1500, vibrato: 5.2, vibratoDepth: 0.005, formant: 1200, c0: 2200, c1: 900, level: 1.4,
|
|
258
|
+
},
|
|
259
|
+
guitar: {
|
|
260
|
+
synth: 'pluck', wave: 'triangle', attack: 0.004, c0: 2600, c1: 700, decayScale: 1.0,
|
|
261
|
+
partials: [{ wave: 'sine', octave: 12, gain: 0.30 }, { wave: 'sine', octave: 19, gain: 0.12 }],
|
|
262
|
+
},
|
|
263
|
+
// ---- 打击与键盘 ----
|
|
264
|
+
marimba: { synth: 'fm', ratio: 4, index: 1.6, attack: 0.003, decayScale: 0.8, level: 1.25 },
|
|
265
|
+
bell: { synth: 'fm', ratio: 3.5, index: 1.4, attack: 0.006, decayScale: 2.6, level: 1.25 },
|
|
266
|
+
ePiano: { synth: 'fm', ratio: 2, index: 1.1, attack: 0.004, decayScale: 1.6, level: 1.2 },
|
|
267
|
+
// ---- 电子与 8-bit ----
|
|
268
|
+
acid: { synth: 'pluck', wave: 'square', attack: 0.002, c0: 4200, c1: 900, decayScale: 0.6, q: 7 },
|
|
269
|
+
stab: { synth: 'pluck', wave: 'sawtooth', attack: 0.001, c0: 5200, c1: 1400, decayScale: 0.35 },
|
|
270
|
+
padSynth: { synth: 'pluck', wave: 'sawtooth', attack: 0.01, c0: 2000, c1: 800, decayScale: 1.2 },
|
|
271
|
+
chipPulse: { synth: 'chip', duty: 0.25, attack: 0.001, decayScale: 0.55 },
|
|
272
|
+
chipSquare: { synth: 'chip', duty: 0.5, attack: 0.001, decayScale: 0.65 },
|
|
273
|
+
chipTri: { synth: 'chipTri', attack: 0.002, decayScale: 0.8 },
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* One kit per mood: which instruments play, plus the drum design.
|
|
278
|
+
*
|
|
279
|
+
* `lead` is the melody line; `answer` is an optional second instrument that takes
|
|
280
|
+
* every other melodic step, which is what turns a single line into an exchange
|
|
281
|
+
* (古筝 问答 笛子) instead of the same timbre repeating.
|
|
282
|
+
*/
|
|
283
|
+
const KITS = {
|
|
284
|
+
guofeng: {
|
|
285
|
+
trim: 1.997, // 响度校准
|
|
286
|
+
// 太鼓:鼓皮噪声为主
|
|
287
|
+
kick: { f0: 96, f1: 52, sweep: 0.12, dur: 0.55, peak: 0.34, tone: 0.55, thump: 0.36, thumpDur: 0.10, thumpF0: 1400, thumpF1: 160, thumpType: 'bandpass', thumpQ: 0.8 },
|
|
288
|
+
// 梆子:极短高 Q 木质
|
|
289
|
+
snare: { kind: 'wood', type: 'bandpass', f0: 1700, q: 4.5, dur: 0.075, peak: 0.20, attack: 0.0008, body: 430, bodyLevel: 0.10, bodyDur: 0.07 },
|
|
290
|
+
hat: { type: 'highpass', f0: 4600, dur: 0.05, peak: 0.07, attack: 0.004 },
|
|
291
|
+
lead: 'zheng',
|
|
292
|
+
answer: 'dizi',
|
|
293
|
+
bass: { wave: 'sine', attack: 0.03, sustain: true, release: 0.16, level: 0.30, cutoff: 320 },
|
|
294
|
+
pad: { voices: [{ wave: 'sine', detune: -5 }, { wave: 'triangle', detune: 5 }], attack: 0.65, sustain: true, release: 0.3, cutoff: 700, level: 0.18 },
|
|
295
|
+
riser: { type: 'bandpass', f0: 500, f1: 5200, q: 2, peak: 0.24 },
|
|
296
|
+
impact: { sub0: 92, sub1: 34, subDur: 0.9, crash: 2600, crashQ: 0.6, stab: 'triangle', stabPeak: 0.13 },
|
|
297
|
+
},
|
|
298
|
+
epic: {
|
|
299
|
+
trim: 0.611, // 响度校准
|
|
300
|
+
kick: { f0: 88, f1: 40, sweep: 0.20, dur: 0.80, peak: 0.44, tone: 0.85, thump: 0.26, thumpDur: 0.12, thumpF0: 1100, thumpF1: 120 },
|
|
301
|
+
snare: { kind: 'noise', type: 'bandpass', f0: 2100, q: 1.1, dur: 0.19, peak: 0.26, attack: 0.001, body: 185, bodyWave: 'sine', bodyLevel: 0.20, bodyDur: 0.16 },
|
|
302
|
+
hat: { type: 'highpass', f0: 6600, dur: 0.045, peak: 0.055, attack: 0.004 },
|
|
303
|
+
// 弦乐群:小提琴 + 大提琴分解,而不是铜管主奏
|
|
304
|
+
lead: 'violin',
|
|
305
|
+
answer: 'cello',
|
|
306
|
+
bass: { voices: [{ wave: 'sawtooth', detune: -7 }, { wave: 'sawtooth', detune: 0 }, { wave: 'sawtooth', detune: 7 }], attack: 0.09, sustain: true, release: 0.2, level: 0.26, cutoff: 900 },
|
|
307
|
+
pad: { voices: [{ wave: 'sawtooth', detune: -10 }, { wave: 'sawtooth', detune: -3 }, { wave: 'sawtooth', detune: 3 }, { wave: 'sawtooth', detune: 10 }], attack: 0.4, sustain: true, release: 0.3, cutoff: 2100, level: 0.14 },
|
|
308
|
+
riser: { type: 'bandpass', f0: 300, f1: 4500, q: 1.4, peak: 0.34 },
|
|
309
|
+
impact: { sub0: 76, sub1: 30, subDur: 1.1, crash: 3000, crashQ: 0.5, stab: 'sawtooth', stabPeak: 0.17 },
|
|
310
|
+
},
|
|
311
|
+
synth: {
|
|
312
|
+
trim: 4.377, // 响度校准
|
|
313
|
+
kick: { f0: 182, f1: 42, sweep: 0.055, dur: 0.55, peak: 0.60, tone: 1, thump: 0.05, thumpDur: 0.02, thumpF0: 800, thumpF1: 90 },
|
|
314
|
+
snare: { kind: 'noise', type: 'highpass', f0: 1500, q: 0.8, dur: 0.12, peak: 0.26, attack: 0.0008, body: 225, bodyLevel: 0.14, bodyDur: 0.08 },
|
|
315
|
+
hat: { type: 'highpass', f0: 9200, dur: 0.03, peak: 0.10, attack: 0.0006 },
|
|
316
|
+
lead: 'acid',
|
|
317
|
+
bass: { wave: 'sawtooth', attack: 0.004, sustain: true, release: 0.08, level: 0.30, c0: 260, c1: 420, q: 9, resonant: true },
|
|
318
|
+
pad: { voices: [{ wave: 'sawtooth', detune: -14 }, { wave: 'sawtooth', detune: 14 }], attack: 0.22, sustain: true, release: 0.25, cutoff: 1150, level: 0.13 },
|
|
319
|
+
riser: { type: 'bandpass', f0: 600, f1: 7000, q: 4, peak: 0.30 },
|
|
320
|
+
impact: { sub0: 84, sub1: 26, subDur: 0.95, crash: 4200, crashQ: 0.9, stab: 'square', stabPeak: 0.14 },
|
|
321
|
+
},
|
|
322
|
+
chiptune: {
|
|
323
|
+
trim: 14.917, // 响度校准
|
|
324
|
+
// NES 没有真正的底鼓:三角波快速下扫当鼓,是芯片音乐的标志手法
|
|
325
|
+
kick: { f0: 220, f1: 48, sweep: 0.04, dur: 0.16, peak: 0.55, tone: 1, thump: 0.12, thumpDur: 0.015, thumpF0: 1200, thumpF1: 200, thumpType: 'highpass', wave: 'triangle' },
|
|
326
|
+
// 噪声通道:短促、不做滤波塑形
|
|
327
|
+
snare: { kind: 'noise', type: 'highpass', f0: 2400, q: 0.6, dur: 0.10, peak: 0.24, attack: 0.0004 },
|
|
328
|
+
hat: { type: 'highpass', f0: 7000, dur: 0.025, peak: 0.11, attack: 0.0004 },
|
|
329
|
+
lead: 'chipPulse',
|
|
330
|
+
answer: 'chipSquare',
|
|
331
|
+
arpeggio: true,
|
|
332
|
+
// 三角波贝斯:NES 三个音源之一
|
|
333
|
+
bass: { wave: 'triangle', attack: 0.002, sustain: true, release: 0.04, level: 0.34, cutoff: 2400 },
|
|
334
|
+
pad: null,
|
|
335
|
+
riser: { type: 'highpass', f0: 2000, f1: 8000, q: 1, peak: 0.22 },
|
|
336
|
+
impact: { sub0: 120, sub1: 40, subDur: 0.4, crash: 5200, crashQ: 1.1, stab: 'square', stabPeak: 0.16 },
|
|
337
|
+
},
|
|
338
|
+
pulse: {
|
|
339
|
+
trim: 6.709, // 响度校准
|
|
340
|
+
kick: { f0: 205, f1: 52, sweep: 0.05, dur: 0.26, peak: 0.62, tone: 1, thump: 0.09, thumpDur: 0.02, thumpF0: 900, thumpF1: 100 },
|
|
341
|
+
snare: { kind: 'clap', type: 'bandpass', f0: 1800, q: 1.5, peak: 0.24 },
|
|
342
|
+
hat: { type: 'highpass', f0: 8600, dur: 0.028, peak: 0.10, attack: 0.0006 },
|
|
343
|
+
lead: 'stab',
|
|
344
|
+
answer: 'padSynth',
|
|
345
|
+
bass: { wave: 'square', attack: 0.004, sustain: true, release: 0.06, level: 0.30, cutoff: 760 },
|
|
346
|
+
pad: { voices: [{ wave: 'square', detune: -8 }, { wave: 'sawtooth', detune: 8 }], attack: 0.18, sustain: true, release: 0.2, cutoff: 2600, level: 0.09 },
|
|
347
|
+
riser: { type: 'bandpass', f0: 700, f1: 6500, q: 3, peak: 0.30 },
|
|
348
|
+
impact: { sub0: 88, sub1: 28, subDur: 0.8, crash: 3800, crashQ: 0.8, stab: 'sawtooth', stabPeak: 0.15 },
|
|
349
|
+
},
|
|
350
|
+
bright: {
|
|
351
|
+
trim: 8.116, // 响度校准
|
|
352
|
+
kick: { f0: 150, f1: 72, sweep: 0.04, dur: 0.17, peak: 0.26, tone: 0.5, thump: 0.30, thumpDur: 0.04, thumpF0: 1800, thumpF1: 300, thumpType: 'bandpass', thumpQ: 0.9 },
|
|
353
|
+
snare: { kind: 'wood', type: 'bandpass', f0: 2500, q: 4, dur: 0.055, peak: 0.20, attack: 0.0006, body: 620, bodyLevel: 0.09, bodyDur: 0.05 },
|
|
354
|
+
hat: { type: 'highpass', f0: 6200, dur: 0.06, peak: 0.085, attack: 0.006 },
|
|
355
|
+
// 马林巴主奏,钢琴应答
|
|
356
|
+
lead: 'marimba',
|
|
357
|
+
answer: 'piano',
|
|
358
|
+
bass: { wave: 'sine', attack: 0.008, sustain: true, release: 0.08, level: 0.28, cutoff: 500 },
|
|
359
|
+
pad: { voices: [{ wave: 'triangle', detune: -7 }, { wave: 'sine', detune: 7 }], attack: 0.28, sustain: true, release: 0.2, cutoff: 3200, level: 0.12 },
|
|
360
|
+
riser: { type: 'bandpass', f0: 900, f1: 6000, q: 2.5, peak: 0.22 },
|
|
361
|
+
impact: { sub0: 96, sub1: 40, subDur: 0.6, crash: 4600, crashQ: 0.9, stab: 'sine', stabPeak: 0.12 },
|
|
362
|
+
},
|
|
363
|
+
ambient: {
|
|
364
|
+
trim: 1.742, // 响度校准
|
|
365
|
+
// 完全不敲鼓
|
|
366
|
+
kick: null,
|
|
367
|
+
snare: null,
|
|
368
|
+
hat: null,
|
|
369
|
+
lead: 'bell',
|
|
370
|
+
answer: 'ePiano',
|
|
371
|
+
bass: { wave: 'sine', attack: 0.5, sustain: true, release: 0.5, level: 0.26, cutoff: 240 },
|
|
372
|
+
pad: { voices: [{ wave: 'sine', detune: -12 }, { wave: 'triangle', detune: -2 }, { wave: 'triangle', detune: 2 }, { wave: 'sine', detune: 12 }], attack: 0.9, sustain: true, release: 0.5, cutoff: 560, level: 0.18 },
|
|
373
|
+
riser: { type: 'bandpass', f0: 200, f1: 3000, q: 1.2, peak: 0.18 },
|
|
374
|
+
impact: { sub0: 64, sub1: 26, subDur: 1.6, crash: 1800, crashQ: 0.4, stab: 'sine', stabPeak: 0.10 },
|
|
375
|
+
},
|
|
376
|
+
cinematic: {
|
|
377
|
+
trim: 1.994, // 响度校准
|
|
378
|
+
kick: { f0: 122, f1: 45, sweep: 0.14, dur: 0.55, peak: 0.42, tone: 0.9, thump: 0.18, thumpDur: 0.07, thumpF0: 1000, thumpF1: 120 },
|
|
379
|
+
snare: { kind: 'noise', type: 'bandpass', f0: 2000, q: 1.0, dur: 0.15, peak: 0.24, attack: 0.001, body: 200, bodyWave: 'triangle', bodyLevel: 0.15, bodyDur: 0.1 },
|
|
380
|
+
hat: { type: 'highpass', f0: 6800, dur: 0.04, peak: 0.07, attack: 0.003 },
|
|
381
|
+
// 钢琴主奏,小提琴应答
|
|
382
|
+
lead: 'piano',
|
|
383
|
+
answer: 'violin',
|
|
384
|
+
bass: { voices: [{ wave: 'triangle', detune: -4 }, { wave: 'sine', detune: 4 }], attack: 0.02, sustain: true, release: 0.12, level: 0.30, cutoff: 600 },
|
|
385
|
+
pad: { voices: [{ wave: 'sawtooth', detune: -8 }, { wave: 'sawtooth', detune: 0 }, { wave: 'sawtooth', detune: 8 }], attack: 0.5, sustain: true, release: 0.3, cutoff: 1350, level: 0.14 },
|
|
386
|
+
riser: { type: 'bandpass', f0: 400, f1: 5000, q: 2, peak: 0.28 },
|
|
387
|
+
impact: { sub0: 80, sub1: 28, subDur: 0.95, crash: 3400, crashQ: 0.7, stab: 'triangle', stabPeak: 0.14 },
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** Unknown ids (including `auto` and the retired `lofi`) keep a distinct default. */
|
|
392
|
+
function kitFor(mood) {
|
|
393
|
+
return KITS[mood] || KITS.cinematic;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function mulberry32(seed) {
|
|
397
|
+
let a = seed >>> 0;
|
|
398
|
+
return () => {
|
|
399
|
+
a = (a + 0x6D2B79F5) | 0;
|
|
400
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
401
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
402
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function midiToHz(midi) {
|
|
407
|
+
return 440 * 2 ** ((midi - 69) / 12);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function createNoiseBuffer(ctx, seconds, rng) {
|
|
411
|
+
const len = Math.max(1, Math.floor(ctx.sampleRate * seconds));
|
|
412
|
+
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
413
|
+
const data = buf.getChannelData(0);
|
|
414
|
+
for (let i = 0; i < len; i++) data[i] = rng() * 2 - 1;
|
|
415
|
+
return buf;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function createReverbBuffer(ctx, seconds, decay, rng) {
|
|
419
|
+
const len = Math.max(1, Math.floor(ctx.sampleRate * seconds));
|
|
420
|
+
const buf = ctx.createBuffer(2, len, ctx.sampleRate);
|
|
421
|
+
for (let ch = 0; ch < 2; ch++) {
|
|
422
|
+
const data = buf.getChannelData(ch);
|
|
423
|
+
for (let i = 0; i < len; i++) data[i] = (rng() * 2 - 1) * (1 - i / len) ** decay;
|
|
424
|
+
}
|
|
425
|
+
return buf;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Turn the free-form music description into synthesis parameters.
|
|
430
|
+
*
|
|
431
|
+
* Both Chinese and English words are matched, because the description is whatever the
|
|
432
|
+
* Agent wrote for this clip rather than a fixed preset id.
|
|
433
|
+
*/
|
|
434
|
+
function analyzePrompt(promptOrId) {
|
|
435
|
+
const text = String(promptOrId || '').toLowerCase();
|
|
436
|
+
const has = (...words) => words.some((w) => text.includes(w));
|
|
437
|
+
|
|
438
|
+
if (has('国风', '水墨', '中国风', '古', '山水', '禅', 'guofeng', 'chinese', 'ink')) {
|
|
439
|
+
return {
|
|
440
|
+
mood: 'guofeng', scale: SCALES.pentatonic, root: 52, bpm: 88,
|
|
441
|
+
chords: [0, 3, 4, 1, 0, 4], wave: 'triangle', pluckDecay: 0.9,
|
|
442
|
+
groove: 'sparse', reverbWet: 0.42, cutoff: 2400, impact: 0.85,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (has('史诗', '宏大', '震撼', '大片', '战争', '英雄', 'epic', 'cinematic', 'trailer')) {
|
|
446
|
+
return {
|
|
447
|
+
mood: 'epic', scale: SCALES.minor, root: 41, bpm: 80,
|
|
448
|
+
chords: [0, 5, 3, 4], wave: 'sawtooth', pluckDecay: 0.45,
|
|
449
|
+
groove: 'backbeat', reverbWet: 0.38, cutoff: 1600, impact: 1.0,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
if (has('赛博', '科技', '未来', 'crt', '故障', 'synth', 'cyber', 'electronic', 'techno')) {
|
|
453
|
+
return {
|
|
454
|
+
mood: 'synth', scale: SCALES.minor, root: 43, bpm: 124,
|
|
455
|
+
chords: [0, 3, 5, 2], wave: 'sawtooth', pluckDecay: 0.3,
|
|
456
|
+
groove: 'four', reverbWet: 0.24, cutoff: 2800, impact: 0.92,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
if (has('轻快', '活泼', '卡通', '趣味', '明快', 'bright', 'playful', 'happy')) {
|
|
460
|
+
return {
|
|
461
|
+
mood: 'bright', scale: SCALES.major, root: 48, bpm: 116,
|
|
462
|
+
chords: [0, 4, 5, 3], wave: 'sine', pluckDecay: 0.35,
|
|
463
|
+
groove: 'backbeat', reverbWet: 0.2, cutoff: 3200, impact: 0.7,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (has('快', '脉冲', '燃', '节奏', '紧张', 'pulse', 'drive', 'energy', 'fast')) {
|
|
467
|
+
return {
|
|
468
|
+
mood: 'pulse', scale: SCALES.dorian, root: 45, bpm: 128,
|
|
469
|
+
chords: [0, 2, 3, 0], wave: 'sawtooth', pluckDecay: 0.25,
|
|
470
|
+
groove: 'four', reverbWet: 0.18, cutoff: 2200, impact: 0.9,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (has('8bit', '8-bit', '芯片', '红白机', 'fc', 'nes', 'chiptune', '像素游戏', 'gameboy', 'gb')) {
|
|
474
|
+
return {
|
|
475
|
+
mood: 'chiptune', scale: SCALES.major, root: 60, bpm: 150,
|
|
476
|
+
chords: [0, 4, 5, 3], wave: 'square', pluckDecay: 0.16,
|
|
477
|
+
groove: 'backbeat', reverbWet: 0.12, cutoff: 6000, impact: 0.7,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (has('氛围', '静谧', '空灵', '孤独', 'ambient', 'calm', 'drone', 'space')) {
|
|
481
|
+
return {
|
|
482
|
+
mood: 'ambient', scale: SCALES.minorPent, root: 45, bpm: 72,
|
|
483
|
+
chords: [0, 5, 3, 4], wave: 'sine', pluckDecay: 0.8,
|
|
484
|
+
groove: 'sparse', reverbWet: 0.5, cutoff: 1200, impact: 0.6,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
mood: 'cinematic', scale: SCALES.minorPent, root: 45, bpm: 96,
|
|
489
|
+
chords: [0, 3, 4, 2], wave: 'triangle', pluckDecay: 0.45,
|
|
490
|
+
groove: 'backbeat', reverbWet: 0.34, cutoff: 2000, impact: 0.88,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Choose the beat period whose bar lines land on the clip's cuts.
|
|
496
|
+
*
|
|
497
|
+
* Every cut is a musical arrival, so it should fall on a downbeat. Candidate tempos are
|
|
498
|
+
* scored by the mean distance between each cut and the nearest bar line, with a small
|
|
499
|
+
* preference for the mood's intended tempo when two candidates fit equally well.
|
|
500
|
+
*
|
|
501
|
+
* @param cuts - cut times in seconds.
|
|
502
|
+
* @param duration - clip length.
|
|
503
|
+
* @param hintedBpm - the mood's tempo, used to break ties and when there are no cuts.
|
|
504
|
+
* @returns `{ beat, bar, bpm, fit }` where `fit` is 1 for a perfect alignment.
|
|
505
|
+
*/
|
|
506
|
+
function fitGroove(cuts, duration, hintedBpm) {
|
|
507
|
+
const target = Number(hintedBpm) || 96;
|
|
508
|
+
const usable = (Array.isArray(cuts) ? cuts : [])
|
|
509
|
+
.map(Number)
|
|
510
|
+
.filter((t) => Number.isFinite(t) && t > 0.15 && t < duration - 0.1);
|
|
511
|
+
|
|
512
|
+
if (!usable.length) {
|
|
513
|
+
const beat = 60 / target;
|
|
514
|
+
return { beat, bar: beat * 4, bpm: target, fit: 0 };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let best = null;
|
|
518
|
+
for (let bpm = 60; bpm <= 190; bpm += 1) {
|
|
519
|
+
const beat = 60 / bpm;
|
|
520
|
+
const bar = beat * 4;
|
|
521
|
+
// A bar shorter than ~0.35s has no room for a groove; longer than 3s drags.
|
|
522
|
+
if (bar < 0.35 || bar > 3) continue;
|
|
523
|
+
|
|
524
|
+
let err = 0;
|
|
525
|
+
for (const t of usable) {
|
|
526
|
+
const k = t / bar;
|
|
527
|
+
err += Math.abs(k - Math.round(k));
|
|
528
|
+
}
|
|
529
|
+
err /= usable.length;
|
|
530
|
+
|
|
531
|
+
// Ties go to the tempo the mood asked for, so a well-fitting clip keeps its feel.
|
|
532
|
+
const comfort = Math.abs(bpm - target) / (target * 8);
|
|
533
|
+
const score = err * 6 + comfort;
|
|
534
|
+
if (!best || score < best.score) best = { beat, bar, bpm, err, score };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!best) {
|
|
538
|
+
const beat = 60 / target;
|
|
539
|
+
return { beat, bar: beat * 4, bpm: target, fit: 0 };
|
|
540
|
+
}
|
|
541
|
+
return { beat: best.beat, bar: best.bar, bpm: best.bpm, fit: Math.max(0, 1 - best.err * 4) };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Synthesise a score tailored to one clip.
|
|
546
|
+
*
|
|
547
|
+
* @param musicPromptOrPreset - free-form description (or a legacy preset id).
|
|
548
|
+
* @param options - `{ duration, cuts, cues, seed, sampleRate }`. `cuts` are storyboard
|
|
549
|
+
* edits; `cues` are in-shot visual peaks. Both get a hit, sample-accurately.
|
|
550
|
+
*/
|
|
551
|
+
async function renderMusic(musicPromptOrPreset, options = {}) {
|
|
552
|
+
const duration = Math.max(0.5, Number(options.duration) || 8);
|
|
553
|
+
const sampleRate = Number(options.sampleRate) || 44100;
|
|
554
|
+
const seed = Number(options.seed) || 20261103;
|
|
555
|
+
const rng = mulberry32(seed);
|
|
556
|
+
const spec = analyzePrompt(musicPromptOrPreset);
|
|
557
|
+
// The instrument set for this mood; every voice below reads from it.
|
|
558
|
+
const K = kitFor(spec.mood);
|
|
559
|
+
|
|
560
|
+
// Collect every moment the picture needs an accent on.
|
|
561
|
+
const rawCuts = Array.isArray(options.cuts) ? options.cuts : [];
|
|
562
|
+
const rawCues = Array.isArray(options.cues) ? options.cues : [];
|
|
563
|
+
const allCues = [];
|
|
564
|
+
|
|
565
|
+
for (const c of rawCuts) {
|
|
566
|
+
const t = Number(c);
|
|
567
|
+
if (Number.isFinite(t) && t > 0.05 && t < duration - 0.05) {
|
|
568
|
+
allCues.push({ time: t, type: 'cut', power: 1 });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
for (const cue of rawCues) {
|
|
572
|
+
const t = typeof cue === 'number' ? cue : Number(cue.time ?? cue.t);
|
|
573
|
+
if (Number.isFinite(t) && t > 0.05 && t < duration - 0.05) {
|
|
574
|
+
allCues.push({
|
|
575
|
+
time: t,
|
|
576
|
+
type: (cue && cue.type) || 'hit',
|
|
577
|
+
power: (cue && Number(cue.power)) || 0.75,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
allCues.sort((a, b) => a.time - b.time);
|
|
583
|
+
const cues = [];
|
|
584
|
+
for (const c of allCues) {
|
|
585
|
+
if (!cues.some((prev) => Math.abs(prev.time - c.time) < 0.07)) cues.push(c);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const totalFrames = Math.ceil(duration * sampleRate);
|
|
589
|
+
const ctx = new OfflineAudioContext(2, totalFrames, sampleRate);
|
|
590
|
+
|
|
591
|
+
// ---- master chain: headroom, glue, then a hard ceiling so impacts never clip ----
|
|
592
|
+
const master = ctx.createGain();
|
|
593
|
+
master.gain.value = 0.6;
|
|
594
|
+
|
|
595
|
+
const glue = ctx.createDynamicsCompressor();
|
|
596
|
+
glue.threshold.value = -16;
|
|
597
|
+
glue.knee.value = 8;
|
|
598
|
+
glue.ratio.value = 4;
|
|
599
|
+
glue.attack.value = 0.004;
|
|
600
|
+
glue.release.value = 0.18;
|
|
601
|
+
|
|
602
|
+
const ceiling = ctx.createDynamicsCompressor();
|
|
603
|
+
ceiling.threshold.value = -3;
|
|
604
|
+
ceiling.knee.value = 0;
|
|
605
|
+
ceiling.ratio.value = 20;
|
|
606
|
+
ceiling.attack.value = 0.001;
|
|
607
|
+
ceiling.release.value = 0.08;
|
|
608
|
+
|
|
609
|
+
// A soft ceiling of last resort. The two compressors above shape the mix, but they
|
|
610
|
+
// are dynamics processors, not limiters: a bright 8-bit arpeggio at full level can
|
|
611
|
+
// still overshoot. tanh keeps that from clipping on the way out.
|
|
612
|
+
const softClip = ctx.createWaveShaper();
|
|
613
|
+
const curve = new Float32Array(1024);
|
|
614
|
+
for (let i = 0; i < curve.length; i += 1) {
|
|
615
|
+
const x = (i / (curve.length - 1)) * 2 - 1;
|
|
616
|
+
curve[i] = Math.tanh(x * 1.35);
|
|
617
|
+
}
|
|
618
|
+
softClip.curve = curve;
|
|
619
|
+
|
|
620
|
+
master.connect(glue);
|
|
621
|
+
glue.connect(ceiling);
|
|
622
|
+
ceiling.connect(softClip);
|
|
623
|
+
softClip.connect(ctx.destination);
|
|
624
|
+
|
|
625
|
+
const verb = ctx.createConvolver();
|
|
626
|
+
verb.buffer = createReverbBuffer(ctx, Math.min(2.4, duration * 0.8), 2.2, rng);
|
|
627
|
+
const wet = ctx.createGain();
|
|
628
|
+
wet.gain.value = spec.reverbWet;
|
|
629
|
+
verb.connect(wet);
|
|
630
|
+
// `wet` joins the kit bus below, not `master`: routing the reverb past the trim let
|
|
631
|
+
// a wet preset (氛围 uses 0.5) ignore its own loudness calibration.
|
|
632
|
+
|
|
633
|
+
const fade = ctx.createGain();
|
|
634
|
+
fade.gain.setValueAtTime(0.0001, 0);
|
|
635
|
+
fade.gain.linearRampToValueAtTime(1, Math.min(0.15, duration * 0.04));
|
|
636
|
+
// This only removes the tail click. The ending itself is a cadence written by the
|
|
637
|
+
// arrangement below, so the last chord rings instead of being faded out mid-loop.
|
|
638
|
+
fade.gain.setValueAtTime(1, Math.max(0.15, duration - 0.06));
|
|
639
|
+
fade.gain.linearRampToValueAtTime(0.0001, duration);
|
|
640
|
+
fade.connect(master);
|
|
641
|
+
|
|
642
|
+
const { beat, bar, bpm, fit } = fitGroove(rawCuts, duration, spec.bpm);
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The bed bus carries the groove. Cue impacts connect past it to `kitBus`, so when
|
|
646
|
+
* the bed ducks the hit is exposed rather than masked.
|
|
647
|
+
*
|
|
648
|
+
* `kitBus` is the per-kit trim: instruments differ in loudness by more than 20 dB
|
|
649
|
+
* (a bowed string ensemble against a bare pulse wave), so each kit carries one
|
|
650
|
+
* calibration gain instead of every voice guessing its own level.
|
|
651
|
+
*/
|
|
652
|
+
const kitBus = ctx.createGain();
|
|
653
|
+
kitBus.gain.value = K.trim ?? 1;
|
|
654
|
+
kitBus.connect(fade);
|
|
655
|
+
wet.connect(kitBus);
|
|
656
|
+
|
|
657
|
+
const bed = ctx.createGain();
|
|
658
|
+
bed.gain.value = 1;
|
|
659
|
+
bed.connect(kitBus);
|
|
660
|
+
|
|
661
|
+
const scale = SCALES[spec.scale] || SCALES.minorPent;
|
|
662
|
+
|
|
663
|
+
const noiseBuf = createNoiseBuffer(ctx, 1.5, rng);
|
|
664
|
+
const hatsBuf = createNoiseBuffer(ctx, 0.2, rng);
|
|
665
|
+
|
|
666
|
+
// ---- voices ----------------------------------------------------------------
|
|
667
|
+
//
|
|
668
|
+
// Each voice is a thin wrapper over a primitive, and the primitive reads its
|
|
669
|
+
// parameters from the mood's kit. The call signatures stay as they were, so the
|
|
670
|
+
// arrangement and ending code above is unchanged.
|
|
671
|
+
|
|
672
|
+
/** Filtered noise burst: snare, clap, hat, shaker, woodblock, cymbal. */
|
|
673
|
+
const noiseBurst = (t, power, dest, k, overrides) => {
|
|
674
|
+
const cfg = overrides ? { ...k, ...overrides } : k;
|
|
675
|
+
const src = ctx.createBufferSource();
|
|
676
|
+
src.buffer = hatsBuf;
|
|
677
|
+
const f = ctx.createBiquadFilter();
|
|
678
|
+
f.type = cfg.type || 'bandpass';
|
|
679
|
+
f.frequency.setValueAtTime(cfg.f0, t);
|
|
680
|
+
if (cfg.f1) f.frequency.exponentialRampToValueAtTime(cfg.f1, t + cfg.dur);
|
|
681
|
+
f.Q.value = cfg.q ?? 1;
|
|
682
|
+
const g = ctx.createGain();
|
|
683
|
+
const attack = cfg.attack ?? 0.001;
|
|
684
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
685
|
+
g.gain.linearRampToValueAtTime(cfg.peak * power, t + attack);
|
|
686
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur);
|
|
687
|
+
src.connect(f);
|
|
688
|
+
f.connect(g);
|
|
689
|
+
g.connect(dest);
|
|
690
|
+
if (cfg.verb) g.connect(verb);
|
|
691
|
+
src.start(t);
|
|
692
|
+
src.stop(t + cfg.dur + 0.03);
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Drum: 808/909 kick, taiko, timpani, tom, or a soft brush thump.
|
|
697
|
+
*
|
|
698
|
+
* `tone` and `thump` set the balance between the pitched body and the skin noise.
|
|
699
|
+
* Every kit used to be a bare sine sweep, which is the single most "electronic"
|
|
700
|
+
* thing in the score; a frame drum is mostly noise over a short low tone, while an
|
|
701
|
+
* 808 is almost pure tone.
|
|
702
|
+
*/
|
|
703
|
+
const playKick = (t, power, dest) => {
|
|
704
|
+
const k = K.kick;
|
|
705
|
+
if (!k) return;
|
|
706
|
+
if (k.tone !== 0) {
|
|
707
|
+
const osc = ctx.createOscillator();
|
|
708
|
+
osc.type = 'sine';
|
|
709
|
+
osc.frequency.setValueAtTime(k.f0, t);
|
|
710
|
+
osc.frequency.exponentialRampToValueAtTime(k.f1, t + k.sweep);
|
|
711
|
+
const g = ctx.createGain();
|
|
712
|
+
g.gain.setValueAtTime(k.peak * (k.tone ?? 1) * power, t);
|
|
713
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + k.dur);
|
|
714
|
+
osc.connect(g);
|
|
715
|
+
g.connect(dest);
|
|
716
|
+
osc.start(t);
|
|
717
|
+
osc.stop(t + k.dur + 0.02);
|
|
718
|
+
}
|
|
719
|
+
// The skin: noise the pitched body alone cannot give.
|
|
720
|
+
if (k.thump > 0) {
|
|
721
|
+
noiseBurst(t, k.thump * power, dest, {
|
|
722
|
+
type: k.thumpType || 'lowpass',
|
|
723
|
+
f0: k.thumpF0 ?? 900, f1: k.thumpF1 ?? 110,
|
|
724
|
+
dur: k.thumpDur || 0.05, q: k.thumpQ ?? 0.7, peak: 1, attack: 0.0005,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const playSnare = (t, power, dest) => {
|
|
730
|
+
const k = K.snare;
|
|
731
|
+
if (!k) return;
|
|
732
|
+
if (k.kind === 'clap') {
|
|
733
|
+
// A clap is three very short bursts, not one long noise hit.
|
|
734
|
+
for (let i = 0; i < 3; i++) {
|
|
735
|
+
noiseBurst(t + i * 0.011, power * (1 - i * 0.16), dest, k, { dur: 0.05 + i * 0.02, attack: 0.0005 });
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
noiseBurst(t, power, dest, k);
|
|
740
|
+
if (k.body) {
|
|
741
|
+
// A body tone under the noise so the hit reads on small speakers too.
|
|
742
|
+
const body = ctx.createOscillator();
|
|
743
|
+
body.type = k.bodyWave || 'triangle';
|
|
744
|
+
body.frequency.setValueAtTime(k.body, t);
|
|
745
|
+
body.frequency.exponentialRampToValueAtTime(k.body * 0.72, t + 0.07);
|
|
746
|
+
const bg = ctx.createGain();
|
|
747
|
+
bg.gain.setValueAtTime(k.bodyLevel * power, t);
|
|
748
|
+
bg.gain.exponentialRampToValueAtTime(0.0001, t + (k.bodyDur ?? 0.1));
|
|
749
|
+
body.connect(bg);
|
|
750
|
+
bg.connect(dest);
|
|
751
|
+
body.start(t);
|
|
752
|
+
body.stop(t + (k.bodyDur ?? 0.1) + 0.03);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const playHat = (t, power, dest, open = false) => {
|
|
757
|
+
const k = K.hat;
|
|
758
|
+
if (!k) return;
|
|
759
|
+
noiseBurst(t, power, dest, k, open ? { dur: Math.max(k.dur, 0.16) } : undefined);
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* General tonal voice: bass, pad, strings, brass.
|
|
764
|
+
*
|
|
765
|
+
* One oscillator per entry in `voices`, so a preset can stack detuned layers for a
|
|
766
|
+
* string ensemble instead of the single sine every preset used to get.
|
|
767
|
+
*/
|
|
768
|
+
const tonal = (midi, t, length, level, dest, k) => {
|
|
769
|
+
const lp = ctx.createBiquadFilter();
|
|
770
|
+
lp.type = 'lowpass';
|
|
771
|
+
if (k.c1) {
|
|
772
|
+
lp.frequency.setValueAtTime(k.c0 ?? k.cutoff ?? 2000, t);
|
|
773
|
+
lp.frequency.exponentialRampToValueAtTime(k.c1, t + Math.min(length, 0.5));
|
|
774
|
+
} else {
|
|
775
|
+
lp.frequency.value = k.cutoff ?? 2000;
|
|
776
|
+
}
|
|
777
|
+
if (k.q) lp.Q.value = k.q;
|
|
778
|
+
|
|
779
|
+
const g = ctx.createGain();
|
|
780
|
+
const attack = k.attack ?? 0.01;
|
|
781
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
782
|
+
g.gain.linearRampToValueAtTime(level, t + attack);
|
|
783
|
+
if (k.sustain) {
|
|
784
|
+
const release = k.release ?? 0.12;
|
|
785
|
+
g.gain.setValueAtTime(level, Math.max(t + attack, t + length - release));
|
|
786
|
+
g.gain.linearRampToValueAtTime(0.0001, t + length);
|
|
787
|
+
} else {
|
|
788
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + length);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
for (const v of (k.voices || [{ wave: k.wave || 'sine', detune: 0 }])) {
|
|
792
|
+
const osc = ctx.createOscillator();
|
|
793
|
+
osc.type = v.wave;
|
|
794
|
+
osc.detune.value = v.detune || 0;
|
|
795
|
+
osc.frequency.setValueAtTime(midiToHz(midi), t);
|
|
796
|
+
if (k.vibrato) {
|
|
797
|
+
const lfo = ctx.createOscillator();
|
|
798
|
+
lfo.frequency.value = k.vibrato;
|
|
799
|
+
const lg = ctx.createGain();
|
|
800
|
+
lg.gain.value = midiToHz(midi) * 0.008;
|
|
801
|
+
lfo.connect(lg);
|
|
802
|
+
lg.connect(osc.frequency);
|
|
803
|
+
lfo.start(t);
|
|
804
|
+
lfo.stop(t + length + 0.05);
|
|
805
|
+
}
|
|
806
|
+
osc.connect(lp);
|
|
807
|
+
osc.start(t);
|
|
808
|
+
osc.stop(t + length + 0.04);
|
|
809
|
+
}
|
|
810
|
+
lp.connect(g);
|
|
811
|
+
g.connect(dest);
|
|
812
|
+
if (k.verb !== false) g.connect(verb);
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
/** FM voice: marimba, bell. Two oscillators give a struck, inharmonic attack. */
|
|
816
|
+
const fmVoice = (midi, t, dur, level, dest, k) => {
|
|
817
|
+
const hz = midiToHz(midi);
|
|
818
|
+
const car = ctx.createOscillator();
|
|
819
|
+
car.type = 'sine';
|
|
820
|
+
car.frequency.value = hz;
|
|
821
|
+
const mod = ctx.createOscillator();
|
|
822
|
+
mod.type = 'sine';
|
|
823
|
+
mod.frequency.value = hz * (k.ratio ?? 2);
|
|
824
|
+
const mg = ctx.createGain();
|
|
825
|
+
mg.gain.setValueAtTime(hz * (k.index ?? 1.5), t);
|
|
826
|
+
mg.gain.exponentialRampToValueAtTime(hz * 0.02, t + dur * 0.6);
|
|
827
|
+
mod.connect(mg);
|
|
828
|
+
mg.connect(car.frequency);
|
|
829
|
+
|
|
830
|
+
const g = ctx.createGain();
|
|
831
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
832
|
+
g.gain.linearRampToValueAtTime(level, t + (k.attack ?? 0.004));
|
|
833
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
834
|
+
car.connect(g);
|
|
835
|
+
g.connect(dest);
|
|
836
|
+
if (k.verb !== false) g.connect(verb);
|
|
837
|
+
car.start(t);
|
|
838
|
+
car.stop(t + dur + 0.03);
|
|
839
|
+
mod.start(t);
|
|
840
|
+
mod.stop(t + dur + 0.03);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const playBass = (midi, t, length, power) => {
|
|
844
|
+
tonal(midi, t, length, (K.bass.level ?? 0.32) * power, bed, K.bass);
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
/** NES-style pulse wave; `duty` sets the harmonic content (0.125/0.25/0.5). */
|
|
848
|
+
const pulseWave = (duty) => {
|
|
849
|
+
const size = 32;
|
|
850
|
+
const real = new Float32Array(size);
|
|
851
|
+
const imag = new Float32Array(size);
|
|
852
|
+
for (let k = 1; k < size; k += 1) imag[k] = (2 / (k * Math.PI)) * Math.sin(Math.PI * k * duty);
|
|
853
|
+
return ctx.createPeriodicWave(real, imag);
|
|
854
|
+
};
|
|
855
|
+
const pulseCache = new Map();
|
|
856
|
+
|
|
857
|
+
/** Filtered noise used as a breath, bow or hammer transient. */
|
|
858
|
+
const transient = (t, dur, level, kind, freq, q = 1) => {
|
|
859
|
+
const src = ctx.createBufferSource();
|
|
860
|
+
src.buffer = hatsBuf;
|
|
861
|
+
const f = ctx.createBiquadFilter();
|
|
862
|
+
f.type = kind;
|
|
863
|
+
f.frequency.value = freq;
|
|
864
|
+
f.Q.value = q;
|
|
865
|
+
const g = ctx.createGain();
|
|
866
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
867
|
+
g.gain.linearRampToValueAtTime(level, t + Math.min(0.006, dur * 0.25));
|
|
868
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
869
|
+
src.connect(f);
|
|
870
|
+
f.connect(g);
|
|
871
|
+
g.connect(bed);
|
|
872
|
+
src.start(t);
|
|
873
|
+
src.stop(t + dur + 0.02);
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/** Vibrato on one oscillator, in cents relative to its own pitch. */
|
|
877
|
+
const applyVibrato = (osc, t, dur, rate, depth) => {
|
|
878
|
+
if (!rate) return;
|
|
879
|
+
const lfo = ctx.createOscillator();
|
|
880
|
+
lfo.frequency.value = rate;
|
|
881
|
+
const g = ctx.createGain();
|
|
882
|
+
g.gain.value = midiToHz(60) * depth * 12;
|
|
883
|
+
lfo.connect(g);
|
|
884
|
+
g.connect(osc.frequency);
|
|
885
|
+
lfo.start(t);
|
|
886
|
+
lfo.stop(t + dur + 0.05);
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
/** Additive stack with faster decay on higher partials — a struck string. */
|
|
890
|
+
const struckVoice = (midi, t, dur, level, k) => {
|
|
891
|
+
const lp = ctx.createBiquadFilter();
|
|
892
|
+
lp.type = 'lowpass';
|
|
893
|
+
lp.frequency.setValueAtTime(k.c0 ?? 2800, t);
|
|
894
|
+
lp.frequency.exponentialRampToValueAtTime(k.c1 ?? 700, t + dur);
|
|
895
|
+
const out = ctx.createGain();
|
|
896
|
+
out.gain.setValueAtTime(0.0001, t);
|
|
897
|
+
out.gain.linearRampToValueAtTime(level, t + (k.attack ?? 0.003));
|
|
898
|
+
out.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
899
|
+
for (const part of (k.partials || [{ octave: 0, gain: 1 }])) {
|
|
900
|
+
const osc = ctx.createOscillator();
|
|
901
|
+
osc.type = 'sine';
|
|
902
|
+
osc.frequency.setValueAtTime(midiToHz(midi + part.octave), t);
|
|
903
|
+
// Upper partials die first; that two-stage decay is what reads as "piano".
|
|
904
|
+
const pg = ctx.createGain();
|
|
905
|
+
const partialDur = part.octave === 0 ? dur : dur * Math.max(0.25, 1 - part.octave / 40);
|
|
906
|
+
pg.gain.setValueAtTime(0.0001, t);
|
|
907
|
+
pg.gain.linearRampToValueAtTime(part.gain, t + (k.attack ?? 0.003));
|
|
908
|
+
pg.gain.exponentialRampToValueAtTime(0.0001, t + partialDur);
|
|
909
|
+
osc.connect(pg);
|
|
910
|
+
pg.connect(lp);
|
|
911
|
+
osc.start(t);
|
|
912
|
+
osc.stop(t + partialDur + 0.03);
|
|
913
|
+
}
|
|
914
|
+
if (k.hammer) transient(t, 0.03, k.hammer * level, 'highpass', k.hammerF ?? 3200);
|
|
915
|
+
lp.connect(out);
|
|
916
|
+
out.connect(bed);
|
|
917
|
+
out.connect(verb);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
/** Bowed string: saw with bow noise, vibrato and a formant peak. */
|
|
921
|
+
const bowedVoice = (midi, t, dur, level, k) => {
|
|
922
|
+
const lp = ctx.createBiquadFilter();
|
|
923
|
+
lp.type = 'lowpass';
|
|
924
|
+
lp.frequency.setValueAtTime(k.c0 ?? 3000, t);
|
|
925
|
+
if (k.sustain) lp.frequency.linearRampToValueAtTime(k.c1 ?? 2000, t + Math.min(0.3, dur));
|
|
926
|
+
else lp.frequency.exponentialRampToValueAtTime(k.c1 ?? 900, t + dur);
|
|
927
|
+
const peak = ctx.createBiquadFilter();
|
|
928
|
+
peak.type = 'peaking';
|
|
929
|
+
peak.frequency.value = k.formant ?? 900;
|
|
930
|
+
peak.Q.value = 1.1;
|
|
931
|
+
peak.gain.value = 7;
|
|
932
|
+
const g = ctx.createGain();
|
|
933
|
+
const attack = k.attack ?? 0.07;
|
|
934
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
935
|
+
g.gain.linearRampToValueAtTime(level, t + attack);
|
|
936
|
+
if (k.sustain) {
|
|
937
|
+
g.gain.setValueAtTime(level, Math.max(t + attack, t + dur - 0.12));
|
|
938
|
+
g.gain.linearRampToValueAtTime(0.0001, t + dur);
|
|
939
|
+
} else {
|
|
940
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
941
|
+
}
|
|
942
|
+
for (const detune of [-7, 7]) {
|
|
943
|
+
const osc = ctx.createOscillator();
|
|
944
|
+
osc.type = k.wave || 'sawtooth';
|
|
945
|
+
osc.detune.value = detune;
|
|
946
|
+
osc.frequency.setValueAtTime(midiToHz(midi), t);
|
|
947
|
+
applyVibrato(osc, t, dur, k.vibrato, k.vibratoDepth ?? 0.008);
|
|
948
|
+
osc.connect(lp);
|
|
949
|
+
osc.start(t);
|
|
950
|
+
osc.stop(t + dur + 0.04);
|
|
951
|
+
}
|
|
952
|
+
if (k.bow) transient(t, 0.05, k.bow * level, 'bandpass', k.bowF ?? 2600, 0.8);
|
|
953
|
+
lp.connect(peak);
|
|
954
|
+
peak.connect(g);
|
|
955
|
+
g.connect(bed);
|
|
956
|
+
g.connect(verb);
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
/** 笛子: a hollow tone plus breath noise, with vibrato on the held part. */
|
|
960
|
+
const windVoice = (midi, t, dur, level, k) => {
|
|
961
|
+
const lp = ctx.createBiquadFilter();
|
|
962
|
+
lp.type = 'lowpass';
|
|
963
|
+
lp.frequency.setValueAtTime(k.c0 ?? 2400, t);
|
|
964
|
+
if (k.sustain) lp.frequency.linearRampToValueAtTime(k.c1 ?? 1800, t + Math.min(0.4, dur));
|
|
965
|
+
const g = ctx.createGain();
|
|
966
|
+
const attack = k.attack ?? 0.05;
|
|
967
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
968
|
+
g.gain.linearRampToValueAtTime(level, t + attack);
|
|
969
|
+
g.gain.setValueAtTime(level, Math.max(t + attack, t + dur - 0.1));
|
|
970
|
+
g.gain.linearRampToValueAtTime(0.0001, t + dur);
|
|
971
|
+
// A flute is nearly a sine; its character lives in the breath and the vibrato.
|
|
972
|
+
const osc = ctx.createOscillator();
|
|
973
|
+
osc.type = k.wave || 'sine';
|
|
974
|
+
osc.frequency.setValueAtTime(midiToHz(midi), t);
|
|
975
|
+
applyVibrato(osc, t, dur, k.vibrato, k.vibratoDepth ?? 0.012);
|
|
976
|
+
const wobble = ctx.createOscillator();
|
|
977
|
+
wobble.type = 'triangle';
|
|
978
|
+
wobble.frequency.setValueAtTime(midiToHz(midi + 12), t);
|
|
979
|
+
const wg = ctx.createGain();
|
|
980
|
+
wg.gain.value = 0.18;
|
|
981
|
+
wobble.connect(wg);
|
|
982
|
+
wg.connect(lp);
|
|
983
|
+
osc.connect(lp);
|
|
984
|
+
osc.start(t);
|
|
985
|
+
osc.stop(t + dur + 0.04);
|
|
986
|
+
wobble.start(t);
|
|
987
|
+
wobble.stop(t + dur + 0.04);
|
|
988
|
+
if (k.breath) transient(t, 0.09, k.breath * level, 'bandpass', k.breathF ?? 2600, 0.7);
|
|
989
|
+
if (k.air) transient(t + 0.06, Math.max(0.1, dur - 0.06), k.air * level, 'highpass', 3600);
|
|
990
|
+
lp.connect(g);
|
|
991
|
+
g.connect(bed);
|
|
992
|
+
g.connect(verb);
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
/** Unfiltered pulse/triangle: the 8-bit voice. Nothing shapes it but its envelope. */
|
|
996
|
+
const chipVoice = (midi, t, dur, level, k) => {
|
|
997
|
+
const osc = ctx.createOscillator();
|
|
998
|
+
if (k.synth === 'chipTri') {
|
|
999
|
+
osc.type = 'triangle';
|
|
1000
|
+
} else {
|
|
1001
|
+
const duty = k.duty ?? 0.5;
|
|
1002
|
+
if (!pulseCache.has(duty)) pulseCache.set(duty, pulseWave(duty));
|
|
1003
|
+
osc.setPeriodicWave(pulseCache.get(duty));
|
|
1004
|
+
}
|
|
1005
|
+
osc.frequency.setValueAtTime(midiToHz(midi), t);
|
|
1006
|
+
const g = ctx.createGain();
|
|
1007
|
+
const attack = k.attack ?? 0.001;
|
|
1008
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
1009
|
+
g.gain.linearRampToValueAtTime(level, t + attack);
|
|
1010
|
+
g.gain.setValueAtTime(level, Math.max(t + attack, t + dur - 0.02));
|
|
1011
|
+
g.gain.linearRampToValueAtTime(0.0001, t + dur);
|
|
1012
|
+
osc.connect(g);
|
|
1013
|
+
// A touch of the room keeps it from sounding like a bare test tone.
|
|
1014
|
+
g.connect(bed);
|
|
1015
|
+
g.connect(verb);
|
|
1016
|
+
osc.start(t);
|
|
1017
|
+
osc.stop(t + dur + 0.02);
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
/** Dispatch one melodic note to whichever instrument the kit names. */
|
|
1021
|
+
const playInstrument = (name, midi, t, vol, decay) => {
|
|
1022
|
+
const k = INSTRUMENTS[name] || INSTRUMENTS.piano;
|
|
1023
|
+
const dur = Math.max(0.08, decay * (k.decayScale ?? 1));
|
|
1024
|
+
const level = vol * (k.level ?? 1);
|
|
1025
|
+
switch (k.synth) {
|
|
1026
|
+
case 'fm': fmVoice(midi, t, dur, level, bed, k); break;
|
|
1027
|
+
case 'struck': struckVoice(midi, t, dur, level, k); break;
|
|
1028
|
+
case 'bowed': bowedVoice(midi, t, dur, level, k); break;
|
|
1029
|
+
case 'wind': windVoice(midi, t, dur, level, k); break;
|
|
1030
|
+
case 'chip':
|
|
1031
|
+
case 'chipTri': chipVoice(midi, t, dur, level, k); break;
|
|
1032
|
+
default: {
|
|
1033
|
+
// Struck/plucked string: fast attack, exponential decay, filter sweep, and a
|
|
1034
|
+
// stack of partials; `q` gives a synth stab its resonant onset.
|
|
1035
|
+
const lp = ctx.createBiquadFilter();
|
|
1036
|
+
lp.type = 'lowpass';
|
|
1037
|
+
lp.frequency.setValueAtTime(k.c0 ?? spec.cutoff, t);
|
|
1038
|
+
lp.frequency.exponentialRampToValueAtTime(k.c1 ?? 420, t + dur);
|
|
1039
|
+
if (k.q) lp.Q.value = k.q;
|
|
1040
|
+
const g = ctx.createGain();
|
|
1041
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
1042
|
+
g.gain.linearRampToValueAtTime(level, t + (k.attack ?? 0.005));
|
|
1043
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
1044
|
+
const layers = [{ wave: k.wave || spec.wave, gain: 1 }];
|
|
1045
|
+
for (const part of (k.partials || [])) layers.push(part);
|
|
1046
|
+
for (const layer of layers) {
|
|
1047
|
+
const osc = ctx.createOscillator();
|
|
1048
|
+
osc.type = layer.wave;
|
|
1049
|
+
osc.frequency.setValueAtTime(midiToHz(midi + (layer.octave || 0)), t);
|
|
1050
|
+
if (k.bend) osc.frequency.exponentialRampToValueAtTime(midiToHz(midi + (layer.octave || 0) + k.bend), t + Math.min(0.25, dur));
|
|
1051
|
+
const lg = ctx.createGain();
|
|
1052
|
+
lg.gain.value = layer.gain;
|
|
1053
|
+
osc.connect(lg);
|
|
1054
|
+
lg.connect(lp);
|
|
1055
|
+
osc.start(t);
|
|
1056
|
+
osc.stop(t + dur + 0.04);
|
|
1057
|
+
}
|
|
1058
|
+
lp.connect(g);
|
|
1059
|
+
g.connect(bed);
|
|
1060
|
+
g.connect(verb);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
/** The kit's melody line; `answer` alternates with it for a call-and-response. */
|
|
1066
|
+
const playPluck = (midi, t, vol, decay, useAnswer) => {
|
|
1067
|
+
const name = useAnswer && K.answer ? K.answer : K.lead;
|
|
1068
|
+
playInstrument(name, midi, t, vol, decay);
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const playPad = (midi, t, length, vol) => {
|
|
1072
|
+
const k = K.pad;
|
|
1073
|
+
if (!k) return;
|
|
1074
|
+
const attack = Math.min(k.attack ?? 0.35, length * 0.4);
|
|
1075
|
+
tonal(midi, t, length, vol * (k.level ?? 1), bed, { ...k, attack });
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
const playRiser = (targetT, power) => {
|
|
1079
|
+
const k = K.riser || { type: 'bandpass', f0: 400, f1: 6000, q: 3, peak: 0.3 };
|
|
1080
|
+
const lead = Math.min(0.4, targetT);
|
|
1081
|
+
if (lead < 0.12) return;
|
|
1082
|
+
const startT = targetT - lead;
|
|
1083
|
+
const src = ctx.createBufferSource();
|
|
1084
|
+
src.buffer = noiseBuf;
|
|
1085
|
+
src.loop = true;
|
|
1086
|
+
const bp = ctx.createBiquadFilter();
|
|
1087
|
+
bp.type = k.type;
|
|
1088
|
+
bp.Q.value = k.q;
|
|
1089
|
+
bp.frequency.setValueAtTime(k.f0, startT);
|
|
1090
|
+
bp.frequency.exponentialRampToValueAtTime(k.f1, targetT);
|
|
1091
|
+
const g = ctx.createGain();
|
|
1092
|
+
g.gain.setValueAtTime(0.0001, startT);
|
|
1093
|
+
g.gain.linearRampToValueAtTime(k.peak * power, targetT - 0.02);
|
|
1094
|
+
g.gain.linearRampToValueAtTime(0.0001, targetT);
|
|
1095
|
+
src.connect(bp);
|
|
1096
|
+
bp.connect(g);
|
|
1097
|
+
g.connect(bed);
|
|
1098
|
+
src.start(startT);
|
|
1099
|
+
src.stop(targetT + 0.03);
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
/** The cut accent: sub boom + noise crash + an in-key chord stab. */
|
|
1103
|
+
const playImpact = (t, power, rootMidi) => {
|
|
1104
|
+
const sub = ctx.createOscillator();
|
|
1105
|
+
sub.type = 'sine';
|
|
1106
|
+
sub.frequency.setValueAtTime(72, t);
|
|
1107
|
+
sub.frequency.exponentialRampToValueAtTime(28, t + 0.6);
|
|
1108
|
+
const sg = ctx.createGain();
|
|
1109
|
+
sg.gain.setValueAtTime(0.42 * power, t);
|
|
1110
|
+
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.75);
|
|
1111
|
+
sub.connect(sg);
|
|
1112
|
+
sg.connect(kitBus);
|
|
1113
|
+
sub.start(t);
|
|
1114
|
+
sub.stop(t + 0.8);
|
|
1115
|
+
|
|
1116
|
+
playKick(t, 1.1 * power, kitBus);
|
|
1117
|
+
|
|
1118
|
+
// The crash bright and length come from the kit too: a gong for 国风, a bright
|
|
1119
|
+
// metal crash for 赛博, a soft wash for 氛围.
|
|
1120
|
+
const crash = ctx.createBufferSource();
|
|
1121
|
+
crash.buffer = noiseBuf;
|
|
1122
|
+
const bp = ctx.createBiquadFilter();
|
|
1123
|
+
bp.type = 'bandpass';
|
|
1124
|
+
bp.frequency.value = K.impact.crash;
|
|
1125
|
+
bp.Q.value = K.impact.crashQ;
|
|
1126
|
+
const cg = ctx.createGain();
|
|
1127
|
+
cg.gain.setValueAtTime(0.3 * power, t);
|
|
1128
|
+
cg.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
|
|
1129
|
+
crash.connect(bp);
|
|
1130
|
+
bp.connect(cg);
|
|
1131
|
+
cg.connect(kitBus);
|
|
1132
|
+
cg.connect(verb);
|
|
1133
|
+
crash.start(t);
|
|
1134
|
+
crash.stop(t + 1);
|
|
1135
|
+
|
|
1136
|
+
// A stab makes the hit harmonic instead of a bare boom.
|
|
1137
|
+
const stabNotes = [0, 7, 12];
|
|
1138
|
+
for (const off of stabNotes) {
|
|
1139
|
+
const osc = ctx.createOscillator();
|
|
1140
|
+
osc.type = K.impact.stab;
|
|
1141
|
+
osc.frequency.value = midiToHz(rootMidi + off);
|
|
1142
|
+
const g = ctx.createGain();
|
|
1143
|
+
g.gain.setValueAtTime(0.0001, t);
|
|
1144
|
+
g.gain.linearRampToValueAtTime(K.impact.stabPeak * power, t + 0.008);
|
|
1145
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
|
|
1146
|
+
osc.connect(g);
|
|
1147
|
+
g.connect(kitBus);
|
|
1148
|
+
g.connect(verb);
|
|
1149
|
+
osc.start(t);
|
|
1150
|
+
osc.stop(t + 0.55);
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
// ---- sidechain: the bed ducks under every cue ----
|
|
1155
|
+
bed.gain.setValueAtTime(1, 0);
|
|
1156
|
+
for (const cue of cues) {
|
|
1157
|
+
const from = Math.max(0, cue.time - 0.1);
|
|
1158
|
+
const depth = 0.2 + (1 - Math.min(1, cue.power)) * 0.28;
|
|
1159
|
+
bed.gain.setValueAtTime(1, from);
|
|
1160
|
+
bed.gain.linearRampToValueAtTime(depth, cue.time);
|
|
1161
|
+
bed.gain.setValueAtTime(depth, cue.time + 0.045);
|
|
1162
|
+
bed.gain.linearRampToValueAtTime(1, Math.min(duration, cue.time + 0.2));
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ---- arrangement: bars of real groove, with a lead-in and a written ending ----
|
|
1166
|
+
//
|
|
1167
|
+
// The clip is not "N copies of one bar". A three-part shape is imposed instead:
|
|
1168
|
+
// the first bar thins out as a lead-in, the middle bars rotate through the pattern
|
|
1169
|
+
// variants so no two consecutive bars are identical, and the last bar stops the
|
|
1170
|
+
// groove and resolves on the tonic. Previously every bar repeated one hardcoded
|
|
1171
|
+
// pattern and the piece simply faded, which read as the same loop cut off.
|
|
1172
|
+
const totalBars = Math.max(1, Math.ceil(duration / bar));
|
|
1173
|
+
const cueTimes = new Set(cues.map((c) => c.time.toFixed(2)));
|
|
1174
|
+
const isCueAt = (t) => cueTimes.has(t.toFixed(2));
|
|
1175
|
+
|
|
1176
|
+
const variants = GROOVE_PATTERNS[spec.groove] || GROOVE_PATTERNS.backbeat;
|
|
1177
|
+
const endingBar = totalBars - 1;
|
|
1178
|
+
const endingT = endingBar * bar;
|
|
1179
|
+
// Only spend a bar on a lead-in when the clip is long enough to afford one.
|
|
1180
|
+
const hasIntro = totalBars >= 3;
|
|
1181
|
+
const tonic = spec.root + (scale[spec.chords[0] % scale.length] || 0);
|
|
1182
|
+
|
|
1183
|
+
for (let b = 0; b < totalBars; b++) {
|
|
1184
|
+
const barT = b * bar;
|
|
1185
|
+
if (barT >= duration) break;
|
|
1186
|
+
const isEnding = b === endingBar;
|
|
1187
|
+
const isIntro = hasIntro && b === 0;
|
|
1188
|
+
const barLen = Math.min(bar, duration - barT);
|
|
1189
|
+
const stepDur = bar / 16;
|
|
1190
|
+
const chordIdx = spec.chords[b % spec.chords.length];
|
|
1191
|
+
const rootNote = spec.root + (scale[chordIdx % scale.length] || 0);
|
|
1192
|
+
|
|
1193
|
+
if (isEnding) {
|
|
1194
|
+
// Hold the tonic under the cadence for as long as the clip still has room.
|
|
1195
|
+
const ring = Math.max(0.4, duration - barT);
|
|
1196
|
+
playPad(tonic, barT, ring, 0.2);
|
|
1197
|
+
playBass(tonic - 12, barT, ring, 0.95);
|
|
1198
|
+
} else {
|
|
1199
|
+
// A final bar usually gets cut short; only start a pad the bar can hold.
|
|
1200
|
+
if (barLen > beat * 1.2) playPad(rootNote, barT, barLen, isIntro ? 0.12 : 0.16);
|
|
1201
|
+
playBass(rootNote - 12, barT, Math.min(beat * 1.8, barLen), isIntro ? 0.7 : 0.9);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const pattern = variants[b % variants.length];
|
|
1205
|
+
for (let s = 0; s < 16; s++) {
|
|
1206
|
+
const t = barT + s * stepDur;
|
|
1207
|
+
if (t >= duration - 0.02) break;
|
|
1208
|
+
if (isCueAt(t)) continue; // the impact covers this step
|
|
1209
|
+
|
|
1210
|
+
if (isEnding) {
|
|
1211
|
+
// One downbeat, then silence so the resolution is heard rather than filled.
|
|
1212
|
+
if (s === 0) playKick(t, 1, bed);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (isIntro) {
|
|
1216
|
+
// Thin lead-in: a soft pulse and one open hat, no full groove yet.
|
|
1217
|
+
if (s === 0) playKick(t, 0.55, bed);
|
|
1218
|
+
if (s === 8) playHat(t, 0.3, bed, true);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const mark = pattern[s] || '.';
|
|
1223
|
+
if (mark === 'K') playKick(t, 1, bed);
|
|
1224
|
+
else if (mark === 'k') playKick(t, 0.7, bed);
|
|
1225
|
+
else if (mark === 'X') { playKick(t, 1, bed); playSnare(t, 0.8, bed); }
|
|
1226
|
+
else if (mark === 'S') playSnare(t, 0.88, bed);
|
|
1227
|
+
else if (mark === 'H') playHat(t, 0.7, bed, false);
|
|
1228
|
+
else if (mark === 'h') playHat(t, 0.45, bed, false);
|
|
1229
|
+
else if (mark === 'o') playHat(t, 0.6, bed, true);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Melodic movement, rotated per bar so the phrase does not repeat verbatim.
|
|
1233
|
+
if (!isEnding) {
|
|
1234
|
+
const steps = isIntro ? [0] : [0, 6 + (b % 2) * 2, 10];
|
|
1235
|
+
steps.forEach((s, stepIndex) => {
|
|
1236
|
+
const t = barT + s * stepDur;
|
|
1237
|
+
if (t >= duration - 0.05) return;
|
|
1238
|
+
if (isCueAt(t)) return;
|
|
1239
|
+
const degree = scale[Math.floor(rng() * scale.length)];
|
|
1240
|
+
const octave = rng() > 0.45 ? 12 : 0;
|
|
1241
|
+
const midi = rootNote + degree + octave;
|
|
1242
|
+
const vol = 0.13 + rng() * 0.05;
|
|
1243
|
+
// The second instrument answers on alternating steps, so a phrase is a交换
|
|
1244
|
+
// between two timbres rather than one colour repeating.
|
|
1245
|
+
const answer = stepIndex % 2 === 1 && K.answer;
|
|
1246
|
+
if (K.arpeggio) {
|
|
1247
|
+
// 8-bit lead writing: cycle the chord quickly instead of holding one note.
|
|
1248
|
+
const arp = [midi, midi + 4, midi + 7, midi + 12];
|
|
1249
|
+
const gap = Math.min(0.055, stepDur / 4);
|
|
1250
|
+
arp.forEach((note, i) => playPluck(note, t + i * gap, vol * 0.85, gap * 3.2, i % 2 === 1));
|
|
1251
|
+
} else {
|
|
1252
|
+
playPluck(midi, t, vol, spec.pluckDecay, answer);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Fill into the next cut when one lands on this bar's end.
|
|
1258
|
+
const nextCut = isEnding ? undefined
|
|
1259
|
+
: cues.find((c) => c.time > barT + bar * 0.6 && c.time < barT + bar + 0.02);
|
|
1260
|
+
if (nextCut && beat > 0.12) {
|
|
1261
|
+
const fillStart = nextCut.time - beat * 0.5;
|
|
1262
|
+
for (let k = 0; k < 3; k++) {
|
|
1263
|
+
const t = fillStart + k * (beat * 0.5) / 3;
|
|
1264
|
+
if (t > barT && t < duration - 0.02 && !isCueAt(t)) playSnare(t, 0.35 + k * 0.18, bed);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ---- the cuts themselves: riser then impact, sample-accurate ----
|
|
1270
|
+
for (const cue of cues) {
|
|
1271
|
+
playRiser(cue.time, cue.power);
|
|
1272
|
+
const chordIdx = Math.max(0, Math.floor(cue.time / bar)) % spec.chords.length;
|
|
1273
|
+
const rootNote = spec.root + (scale[spec.chords[chordIdx] % scale.length] || 0);
|
|
1274
|
+
playImpact(cue.time, cue.power * spec.impact, rootNote);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ---- the ending: a real cadence on the tonic, not a cut ----
|
|
1278
|
+
//
|
|
1279
|
+
// A tonic triad that rings to the last sample, plus one impact as the final button.
|
|
1280
|
+
// This is what makes a five-second clip feel finished instead of looped-and-stopped.
|
|
1281
|
+
if (endingT < duration - 0.05) {
|
|
1282
|
+
const ring = duration - endingT;
|
|
1283
|
+
// Keep the cadence out of any sidechain dip that lands on the same downbeat.
|
|
1284
|
+
bed.gain.setValueAtTime(1, endingT);
|
|
1285
|
+
for (const degree of [0, 2, 4]) {
|
|
1286
|
+
const tone = tonic + (scale[degree % scale.length] || 0);
|
|
1287
|
+
playPluck(tone + 12, endingT, 0.1, Math.min(ring, 1.5));
|
|
1288
|
+
playPluck(tone, endingT + 0.012, 0.08, Math.min(ring, 1.5));
|
|
1289
|
+
}
|
|
1290
|
+
playPluck(tonic + 24, endingT, 0.07, Math.min(ring, 1.2));
|
|
1291
|
+
if (!isCueAt(endingT)) playImpact(endingT, 0.7 * spec.impact, tonic);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const buffer = await ctx.startRendering();
|
|
1295
|
+
// Report the tempo actually used, so a caller can log why the cuts fit.
|
|
1296
|
+
buffer.__groove = {
|
|
1297
|
+
bpm, bar, fit: Number(fit.toFixed(3)), cues: cues.length,
|
|
1298
|
+
bars: totalBars, groove: spec.groove, variants: variants.length,
|
|
1299
|
+
};
|
|
1300
|
+
return buffer;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** Audition a score through the speakers. */
|
|
1304
|
+
function previewMusic(promptOrPreset, duration, options = {}) {
|
|
1305
|
+
const ctx = new AudioContext();
|
|
1306
|
+
let stopped = false;
|
|
1307
|
+
let source = null;
|
|
1308
|
+
|
|
1309
|
+
const pending = renderMusic(promptOrPreset, { ...options, duration, sampleRate: ctx.sampleRate })
|
|
1310
|
+
.then((buffer) => {
|
|
1311
|
+
if (stopped) { void ctx.close(); return; }
|
|
1312
|
+
source = ctx.createBufferSource();
|
|
1313
|
+
source.buffer = buffer;
|
|
1314
|
+
source.connect(ctx.destination);
|
|
1315
|
+
source.onended = () => { void ctx.close(); };
|
|
1316
|
+
source.start(0);
|
|
1317
|
+
})
|
|
1318
|
+
.catch(() => { void ctx.close(); });
|
|
1319
|
+
|
|
1320
|
+
return {
|
|
1321
|
+
stop() {
|
|
1322
|
+
stopped = true;
|
|
1323
|
+
try { if (source) source.stop(); } catch { /* already ended */ }
|
|
1324
|
+
void pending;
|
|
1325
|
+
void ctx.close();
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Decode a user-supplied file and fit it to the clip length. */
|
|
1331
|
+
async function audioFileToBuffer(file, duration, sampleRate) {
|
|
1332
|
+
const bytes = await file.arrayBuffer();
|
|
1333
|
+
const ctx = new OfflineAudioContext(2, Math.ceil(duration * (sampleRate || 44100)), sampleRate || 44100);
|
|
1334
|
+
const decoded = await ctx.decodeAudioData(bytes);
|
|
1335
|
+
const targetLength = Math.ceil(duration * decoded.sampleRate);
|
|
1336
|
+
const out = ctx.createBuffer(2, targetLength, decoded.sampleRate);
|
|
1337
|
+
|
|
1338
|
+
for (let ch = 0; ch < 2; ch++) {
|
|
1339
|
+
const from = decoded.getChannelData(Math.min(ch, decoded.numberOfChannels - 1));
|
|
1340
|
+
const to = out.getChannelData(ch);
|
|
1341
|
+
const take = Math.min(targetLength, from.length);
|
|
1342
|
+
to.set(from.subarray(0, take));
|
|
1343
|
+
if (take < targetLength && from.length > 0) {
|
|
1344
|
+
let offset = take;
|
|
1345
|
+
while (offset < targetLength) {
|
|
1346
|
+
const chunk = Math.min(from.length, targetLength - offset);
|
|
1347
|
+
to.set(from.subarray(0, chunk), offset);
|
|
1348
|
+
offset += chunk;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const fadeLen = Math.min(Math.floor(out.sampleRate * 0.4), targetLength);
|
|
1354
|
+
for (let ch = 0; ch < 2; ch++) {
|
|
1355
|
+
const data = out.getChannelData(ch);
|
|
1356
|
+
for (let i = 0; i < fadeLen; i++) data[targetLength - 1 - i] *= i / fadeLen;
|
|
1357
|
+
}
|
|
1358
|
+
return out;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Container preference, best first.
|
|
1366
|
+
*
|
|
1367
|
+
* Chromium records genuine H.264/AAC MP4 through MediaRecorder, so MP4 is the
|
|
1368
|
+
* default; the WebM entries are the fallback for browsers without it.
|
|
1369
|
+
*/
|
|
1370
|
+
const CONTAINERS = [
|
|
1371
|
+
{ 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'] },
|
|
1372
|
+
{ id: 'webm', label: 'WebM', ext: 'webm', mimes: ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'] },
|
|
1373
|
+
];
|
|
1374
|
+
|
|
1375
|
+
/** Which container this browser can actually record, MP4 first. */
|
|
1376
|
+
function pickContainer(preferred) {
|
|
1377
|
+
const order = preferred === 'webm' ? ['webm', 'mp4'] : ['mp4', 'webm'];
|
|
1378
|
+
for (const id of order) {
|
|
1379
|
+
const container = CONTAINERS.find((c) => c.id === id);
|
|
1380
|
+
const mime = container.mimes.find((m) => (
|
|
1381
|
+
typeof MediaRecorder !== 'undefined'
|
|
1382
|
+
&& MediaRecorder.isTypeSupported
|
|
1383
|
+
&& MediaRecorder.isTypeSupported(m)
|
|
1384
|
+
));
|
|
1385
|
+
if (mime) return { ...container, mime };
|
|
1386
|
+
}
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function download(blob, name) {
|
|
1391
|
+
const url = URL.createObjectURL(blob);
|
|
1392
|
+
const a = document.createElement('a');
|
|
1393
|
+
a.href = url;
|
|
1394
|
+
a.download = name;
|
|
1395
|
+
document.body.appendChild(a);
|
|
1396
|
+
a.click();
|
|
1397
|
+
a.remove();
|
|
1398
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Shape the arrangement around each beat, at one of two strengths.
|
|
1403
|
+
*
|
|
1404
|
+
* There are two different jobs here, and treating them the same makes one of them
|
|
1405
|
+
* wrong:
|
|
1406
|
+
*
|
|
1407
|
+
* ordinary beat (power < 0.72)
|
|
1408
|
+
* The rhythm should keep running; the beat just needs to be clearly audible on
|
|
1409
|
+
* top of it. So the arrangement is not interrupted — it gets a small dip right
|
|
1410
|
+
* under the transient, which clears space without breaking the groove. Cutting
|
|
1411
|
+
* the bed here would read as a mistake, because a normal accent is supposed to
|
|
1412
|
+
* land *inside* the音乐, not punctuate it.
|
|
1413
|
+
*
|
|
1414
|
+
* climax beat (power >= 0.72)
|
|
1415
|
+
* This is the one the audience is meant to feel, so the arrangement stops: a
|
|
1416
|
+
* hard cut to near-silence ~90-130ms before, the hit lands in the hole, then the
|
|
1417
|
+
* bed returns slightly hot. Silence to full level is the largest contrast there
|
|
1418
|
+
* is, and it is reserved for peaks so it does not become fatiguing.
|
|
1419
|
+
*
|
|
1420
|
+
* Buses handed to the score:
|
|
1421
|
+
* `bed` - the arrangement; this is what gets shaped
|
|
1422
|
+
* `dest` - the hits; never shaped, so they are what the gap exposes
|
|
1423
|
+
*
|
|
1424
|
+
* @returns `{ total, climax }` counts after shaping.
|
|
1425
|
+
*/
|
|
1426
|
+
const CLIMAX_POWER = 0.72;
|
|
1427
|
+
|
|
1428
|
+
function shapeBeats(bedGain, beats, duration) {
|
|
1429
|
+
const marks = (Array.isArray(beats) ? beats : [])
|
|
1430
|
+
.map((b) => ({
|
|
1431
|
+
time: Number(typeof b === 'number' ? b : b && b.time),
|
|
1432
|
+
power: Math.max(0.1, Math.min(1, Number(b && b.power) || 0.8)),
|
|
1433
|
+
}))
|
|
1434
|
+
.filter((b) => Number.isFinite(b.time) && b.time > 0.06 && b.time < duration - 0.02)
|
|
1435
|
+
.sort((a, b) => a.time - b.time);
|
|
1436
|
+
|
|
1437
|
+
if (!marks.length) return { total: 0, climax: 0 };
|
|
1438
|
+
|
|
1439
|
+
const g = bedGain.gain;
|
|
1440
|
+
g.setValueAtTime(1, 0);
|
|
1441
|
+
|
|
1442
|
+
let climax = 0;
|
|
1443
|
+
for (const mark of marks) {
|
|
1444
|
+
const t = mark.time;
|
|
1445
|
+
|
|
1446
|
+
if (mark.power >= CLIMAX_POWER) {
|
|
1447
|
+
// Climax: let the music fall away, then land the hit in the quiet.
|
|
1448
|
+
//
|
|
1449
|
+
// A hard cut would read as a dropout — the ear hears a broken tape, not a
|
|
1450
|
+
// build. So the arrangement fades out over a longer lead (~260ms at full
|
|
1451
|
+
// power) and reaches near-silence only right at the beat. What the listener
|
|
1452
|
+
// perceives is the music stepping back to make room, then the hit.
|
|
1453
|
+
climax++;
|
|
1454
|
+
const fade = 0.20 + mark.power * 0.12;
|
|
1455
|
+
const gapStart = Math.max(0, t - fade);
|
|
1456
|
+
const restoreAt = Math.min(duration, t + 0.045);
|
|
1457
|
+
|
|
1458
|
+
g.setValueAtTime(1, gapStart);
|
|
1459
|
+
// Exponential-shaped fall: quick early, slow into the floor, so it feels like
|
|
1460
|
+
// a release rather than a switch.
|
|
1461
|
+
g.linearRampToValueAtTime(0.62, gapStart + fade * 0.42);
|
|
1462
|
+
g.linearRampToValueAtTime(0.22, gapStart + fade * 0.74);
|
|
1463
|
+
g.linearRampToValueAtTime(0.05, t - 0.006);
|
|
1464
|
+
g.setValueAtTime(0.05, restoreAt);
|
|
1465
|
+
// Return gently and slightly hot, so the beat reads as an arrival.
|
|
1466
|
+
g.linearRampToValueAtTime(1.05, Math.min(duration, restoreAt + 0.14));
|
|
1467
|
+
g.linearRampToValueAtTime(1, Math.min(duration, restoreAt + 0.32));
|
|
1468
|
+
} else {
|
|
1469
|
+
// Ordinary: keep the groove, just clear a little room under the hit.
|
|
1470
|
+
// Depth scales with power so a 0.3 beat barely moves and a 0.7 one is firm.
|
|
1471
|
+
// The dip is a rounded shape, not a step: stepping a gain in a few ms clicks.
|
|
1472
|
+
const dip = 0.46 - mark.power * 0.30; // 0.37 at 0.3, 0.25 at 0.7
|
|
1473
|
+
const from = Math.max(0, t - 0.075);
|
|
1474
|
+
const hold = Math.min(duration, t + 0.028);
|
|
1475
|
+
|
|
1476
|
+
g.setValueAtTime(1, from);
|
|
1477
|
+
g.linearRampToValueAtTime(0.78, t - 0.028);
|
|
1478
|
+
g.linearRampToValueAtTime(dip, t - 0.004);
|
|
1479
|
+
g.setValueAtTime(dip, hold);
|
|
1480
|
+
g.linearRampToValueAtTime(1.02, Math.min(duration, hold + 0.07));
|
|
1481
|
+
g.linearRampToValueAtTime(1, Math.min(duration, hold + 0.18));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return { total: marks.length, climax };
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Render a score the model wrote for this clip.
|
|
1490
|
+
*
|
|
1491
|
+
* The code runs against an OfflineAudioContext with the same helpers the built-in
|
|
1492
|
+
* engine uses, so a model-written score can do anything the engine can — it just is
|
|
1493
|
+
* not limited to the engine's instrument set.
|
|
1494
|
+
*
|
|
1495
|
+
* Two buses are handed in: `bed` is where the arrangement should go, and `dest` is
|
|
1496
|
+
* the raw output. Anything connected to `bed` is ducked around each beat; anything
|
|
1497
|
+
* connected to `dest` is not, which is where the hits belong.
|
|
1498
|
+
*
|
|
1499
|
+
* @param url - `/code-animation-api/score/<id>`.
|
|
1500
|
+
* @param options - `{ duration, cuts, cues, beats }`.
|
|
1501
|
+
* @returns the rendered AudioBuffer, or null when the code is missing or throws.
|
|
1502
|
+
*/
|
|
1503
|
+
async function renderModelScore(url, options) {
|
|
1504
|
+
const source = await fetch(url).then((r) => (r.ok ? r.text() : '')).catch(() => '');
|
|
1505
|
+
if (!source) return null;
|
|
1506
|
+
|
|
1507
|
+
const duration = Math.max(0.5, Number(options.duration) || 8);
|
|
1508
|
+
const sampleRate = 44100;
|
|
1509
|
+
const ctx = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate);
|
|
1510
|
+
|
|
1511
|
+
// Raw output: anything the model connects here is heard as-is, so hits belong here.
|
|
1512
|
+
const out = ctx.createGain();
|
|
1513
|
+
out.gain.value = 1;
|
|
1514
|
+
out.connect(ctx.destination);
|
|
1515
|
+
|
|
1516
|
+
// The bed bus: the arrangement goes here and is shaped around each beat — a small
|
|
1517
|
+
// dip under ordinary accents, a full stop before a climax. Hits belong on `dest`
|
|
1518
|
+
// so they are what the gap exposes.
|
|
1519
|
+
const bed = ctx.createGain();
|
|
1520
|
+
bed.gain.value = 1;
|
|
1521
|
+
bed.connect(ctx.destination);
|
|
1522
|
+
const shaped = shapeBeats(bed, options.beats, duration);
|
|
1523
|
+
|
|
1524
|
+
const rand = (() => {
|
|
1525
|
+
let a = 20261103;
|
|
1526
|
+
return () => {
|
|
1527
|
+
a = (a + 0x6D2B79F5) | 0;
|
|
1528
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
1529
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
1530
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
1531
|
+
};
|
|
1532
|
+
})();
|
|
1533
|
+
const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);
|
|
1534
|
+
|
|
1535
|
+
try {
|
|
1536
|
+
// `beats` carries the exact moments the model exported from its own animation
|
|
1537
|
+
// code, so the score can land on the picture without guessing.
|
|
1538
|
+
// eslint-disable-next-line no-new-func
|
|
1539
|
+
const fn = new Function(
|
|
1540
|
+
'ctx', 'dest', 'bed', 'duration', 'cuts', 'cues', 'beats', 'rand', 'hz', source,
|
|
1541
|
+
);
|
|
1542
|
+
fn(ctx, out, bed, duration, options.cuts || [], options.cues || [], options.beats || [], rand, hz);
|
|
1543
|
+
const buffer = await ctx.startRendering();
|
|
1544
|
+
buffer.__shapedBeats = shaped.total;
|
|
1545
|
+
buffer.__climaxBeats = shaped.climax;
|
|
1546
|
+
let peak = 0;
|
|
1547
|
+
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
|
|
1548
|
+
const data = buffer.getChannelData(ch);
|
|
1549
|
+
for (let i = 0; i < data.length; i += 7) peak = Math.max(peak, Math.abs(data[i]));
|
|
1550
|
+
}
|
|
1551
|
+
return peak > 1e-4 ? buffer : null;
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
console.warn('model score failed, falling back to the built-in engine:', error);
|
|
1554
|
+
return null;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Drive a detached document frame frame by frame and record it through MediaRecorder.
|
|
1560
|
+
* @param opts.music - a preset id, or an AudioBuffer for a user-supplied track.
|
|
1561
|
+
* Any audio is mixed at render time, so video and score start together.
|
|
1562
|
+
* @param opts.cuts - cut times in seconds; the score is fitted and accented to them.
|
|
1563
|
+
*/
|
|
1564
|
+
/**
|
|
1565
|
+
* Find the canvas an export should record.
|
|
1566
|
+
*
|
|
1567
|
+
* The frame is loaded asynchronously and a page may create its canvas from script,
|
|
1568
|
+
* so this waits for the document and retries instead of failing on a first miss.
|
|
1569
|
+
* When several canvases exist the largest is used: decorative or helper canvases
|
|
1570
|
+
* are typically small.
|
|
1571
|
+
*/
|
|
1572
|
+
async function resolveCanvas(iframe, timeoutMs = 2000) {
|
|
1573
|
+
const started = performance.now();
|
|
1574
|
+
let doc;
|
|
1575
|
+
let seen = '';
|
|
1576
|
+
|
|
1577
|
+
while (performance.now() - started < timeoutMs) {
|
|
1578
|
+
doc = iframe && iframe.contentDocument;
|
|
1579
|
+
if (doc && doc.readyState !== 'loading') {
|
|
1580
|
+
const canvases = Array.from(doc.querySelectorAll('canvas'));
|
|
1581
|
+
if (canvases.length) {
|
|
1582
|
+
canvases.sort((a, b) => (b.width * b.height) - (a.width * a.height));
|
|
1583
|
+
return canvases[0];
|
|
1584
|
+
}
|
|
1585
|
+
// Remember what the page does contain, for an actionable failure message.
|
|
1586
|
+
const counts = ['div', 'svg', 'video', 'img']
|
|
1587
|
+
.map((tag) => [tag, doc.querySelectorAll(tag).length])
|
|
1588
|
+
.filter(([, n]) => n > 0)
|
|
1589
|
+
.map(([tag, n]) => `${n} 个 <${tag}>`);
|
|
1590
|
+
seen = counts.join('、');
|
|
1591
|
+
}
|
|
1592
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const win = iframe && iframe.contentWindow;
|
|
1596
|
+
const hasRenderAt = win && typeof win.renderAt === 'function';
|
|
1597
|
+
throw new Error([
|
|
1598
|
+
'画面里没有可录制的 <canvas>',
|
|
1599
|
+
seen ? `(页面里找到 ${seen})` : '',
|
|
1600
|
+
hasRenderAt ? '' : ',且没有 window.renderAt',
|
|
1601
|
+
'。这个动画是 DOM/CSS 绘制的,无法逐帧录制;需要让 Agent 改为渲染到 <canvas> 的实现。',
|
|
1602
|
+
].filter(Boolean).join(''));
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Stable per-clip seed for the score engine.
|
|
1607
|
+
*
|
|
1608
|
+
* `renderMusic` falls back to one hardcoded seed, so before this every clip in
|
|
1609
|
+
* every style drew the identical random sequence — the same melodic contours,
|
|
1610
|
+
* the same noise and reverb buffers. Hashing the clip id keeps one clip's score
|
|
1611
|
+
* reproducible while letting different clips differ.
|
|
1612
|
+
*/
|
|
1613
|
+
function seedOf(text) {
|
|
1614
|
+
let h = 2166136261;
|
|
1615
|
+
const s = String(text || 'clip');
|
|
1616
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
1617
|
+
h ^= s.charCodeAt(i);
|
|
1618
|
+
h = Math.imul(h, 16777619);
|
|
1619
|
+
}
|
|
1620
|
+
return (h >>> 0) || 1;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
async function recordAnimation(iframe, opts) {
|
|
1624
|
+
const win = iframe && iframe.contentWindow;
|
|
1625
|
+
if (!win) throw new Error('预览还没准备好');
|
|
1626
|
+
const canvas = await resolveCanvas(iframe);
|
|
1627
|
+
if (typeof canvas.captureStream !== 'function') throw new Error('当前浏览器不支持 canvas.captureStream');
|
|
1628
|
+
const container = pickContainer(opts.container);
|
|
1629
|
+
if (!container) throw new Error('当前浏览器不支持 MediaRecorder 录制');
|
|
1630
|
+
const mime = container.mime;
|
|
1631
|
+
|
|
1632
|
+
const stream = canvas.captureStream(0);
|
|
1633
|
+
const videoTrack = stream.getVideoTracks()[0];
|
|
1634
|
+
const tracks = [videoTrack];
|
|
1635
|
+
|
|
1636
|
+
// Audio, when requested, is mixed into the same recording session so the two
|
|
1637
|
+
// tracks share one clock.
|
|
1638
|
+
let audioCtx;
|
|
1639
|
+
let source;
|
|
1640
|
+
let audioBuffer;
|
|
1641
|
+
let analyser;
|
|
1642
|
+
const wantsMusic = opts.music && opts.music !== 'none';
|
|
1643
|
+
if (wantsMusic || opts.musicBuffer) {
|
|
1644
|
+
if (opts.musicBuffer) {
|
|
1645
|
+
audioBuffer = opts.musicBuffer;
|
|
1646
|
+
} else if (opts.scoreBuffer) {
|
|
1647
|
+
// Reuse the buffer already rendered for auditioning: same audio as the preview.
|
|
1648
|
+
audioBuffer = opts.scoreBuffer;
|
|
1649
|
+
} else {
|
|
1650
|
+
if (opts.onStage) opts.onStage('正在合成配乐…');
|
|
1651
|
+
audioBuffer = await renderMusic(opts.music, {
|
|
1652
|
+
duration: opts.duration,
|
|
1653
|
+
seed: opts.musicSeed ?? seedOf(opts.clipId),
|
|
1654
|
+
cuts: opts.cuts,
|
|
1655
|
+
cues: opts.cues,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
audioCtx = new AudioContext();
|
|
1659
|
+
const destination = audioCtx.createMediaStreamDestination();
|
|
1660
|
+
source = audioCtx.createBufferSource();
|
|
1661
|
+
source.buffer = audioBuffer;
|
|
1662
|
+
source.connect(destination);
|
|
1663
|
+
// Tap the same signal so the export can prove audio actually flowed.
|
|
1664
|
+
analyser = audioCtx.createAnalyser();
|
|
1665
|
+
analyser.fftSize = 2048;
|
|
1666
|
+
source.connect(analyser);
|
|
1667
|
+
tracks.push(...destination.stream.getAudioTracks());
|
|
1668
|
+
|
|
1669
|
+
// Bring the context up BEFORE the recorder exists. Doing this after start()
|
|
1670
|
+
// leaves the recorder capturing silence while the picture already runs, which
|
|
1671
|
+
// truncates the score (measured: a 3.000s clip came out as 2.640s) and shifts
|
|
1672
|
+
// every later beat.
|
|
1673
|
+
if (audioCtx.state === 'suspended') {
|
|
1674
|
+
await Promise.race([
|
|
1675
|
+
audioCtx.resume().catch(() => {}),
|
|
1676
|
+
new Promise((r) => setTimeout(r, 800)),
|
|
1677
|
+
]);
|
|
1678
|
+
}
|
|
1679
|
+
if (audioCtx.state !== 'running' && opts.onStage) {
|
|
1680
|
+
opts.onStage('音频未能启动,本段可能无声');
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const recorder = new MediaRecorder(new MediaStream(tracks), { mimeType: mime, videoBitsPerSecond: 16000000 });
|
|
1685
|
+
const parts = [];
|
|
1686
|
+
recorder.ondataavailable = (e) => { if (e.data && e.data.size) parts.push(e.data); };
|
|
1687
|
+
const finished = new Promise((resolve) => {
|
|
1688
|
+
// Resolve with the container that was actually negotiated, not a hardcoded one.
|
|
1689
|
+
recorder.onstop = () => resolve(new Blob(parts, { type: mime }));
|
|
1690
|
+
});
|
|
1691
|
+
recorder.start();
|
|
1692
|
+
|
|
1693
|
+
const fps = Math.max(1, Number(opts.fps) || 30);
|
|
1694
|
+
const total = Math.max(1, Math.round(opts.duration * fps));
|
|
1695
|
+
const hasRenderAt = typeof win.renderAt === 'function';
|
|
1696
|
+
const t0 = performance.now();
|
|
1697
|
+
let audioPeak = 0;
|
|
1698
|
+
|
|
1699
|
+
// Start the score with no await in between: the recorder and the first frame must
|
|
1700
|
+
// share one origin, or the whole track lands late.
|
|
1701
|
+
if (source) source.start(0);
|
|
1702
|
+
|
|
1703
|
+
try {
|
|
1704
|
+
for (let i = 0; i < total; i++) {
|
|
1705
|
+
if (opts.signal && opts.signal.aborted) break;
|
|
1706
|
+
if (hasRenderAt) await win.renderAt(i / fps);
|
|
1707
|
+
videoTrack.requestFrame();
|
|
1708
|
+
// Sample the score periodically; a peak of zero means the browser never
|
|
1709
|
+
// started audio, which would otherwise export as silent video.
|
|
1710
|
+
if (analyser && i % 5 === 0) {
|
|
1711
|
+
const probe = new Float32Array(analyser.fftSize);
|
|
1712
|
+
analyser.getFloatTimeDomainData(probe);
|
|
1713
|
+
for (let k = 0; k < probe.length; k++) {
|
|
1714
|
+
const v = Math.abs(probe[k]);
|
|
1715
|
+
if (v > audioPeak) audioPeak = v;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (opts.onProgress) opts.onProgress((i + 1) / total);
|
|
1719
|
+
const target = ((i + 1) * 1000) / fps;
|
|
1720
|
+
const wait = target - (performance.now() - t0);
|
|
1721
|
+
if (wait > 1) await new Promise((r) => setTimeout(r, wait));
|
|
1722
|
+
}
|
|
1723
|
+
} finally {
|
|
1724
|
+
recorder.stop();
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const blob = await finished;
|
|
1728
|
+
try { if (source) source.stop(); } catch { /* already ended */ }
|
|
1729
|
+
if (audioCtx) void audioCtx.close();
|
|
1730
|
+
return { blob, container, hasAudio: tracks.length > 1, audioPeak };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Two stacked tracks for the finished clip.
|
|
1735
|
+
*
|
|
1736
|
+
* The picture track marks the storyboard cuts; the sound track draws the score's real
|
|
1737
|
+
* waveform and marks every beat the animation code reported. Seeing where an accent
|
|
1738
|
+
* sits against the picture is the only way to diagnose a sync problem by eye, so the
|
|
1739
|
+
* waveform is drawn from the rendered buffer rather than a decorative placeholder.
|
|
1740
|
+
*/
|
|
1741
|
+
function Timeline({ duration, scrub, beats, cuts, cues, score, fps, onSeek }) {
|
|
1742
|
+
const canvasRef = useRef(null);
|
|
1743
|
+
|
|
1744
|
+
// Redraw whenever the buffer or the marks change.
|
|
1745
|
+
useEffect(() => {
|
|
1746
|
+
const canvas = canvasRef.current;
|
|
1747
|
+
if (!canvas) return undefined;
|
|
1748
|
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
1749
|
+
const width = canvas.clientWidth || 600;
|
|
1750
|
+
const height = 56;
|
|
1751
|
+
canvas.width = Math.round(width * dpr);
|
|
1752
|
+
canvas.height = Math.round(height * dpr);
|
|
1753
|
+
const g = canvas.getContext('2d');
|
|
1754
|
+
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1755
|
+
g.clearRect(0, 0, width, height);
|
|
1756
|
+
|
|
1757
|
+
const lane = (top, h, fill) => {
|
|
1758
|
+
g.fillStyle = fill;
|
|
1759
|
+
g.fillRect(0, top, width, h);
|
|
1760
|
+
};
|
|
1761
|
+
lane(0, height, '#0b0d12');
|
|
1762
|
+
|
|
1763
|
+
const xOf = (t) => (Math.max(0, Math.min(duration, t)) / duration) * width;
|
|
1764
|
+
|
|
1765
|
+
if (score && typeof score.getChannelData === 'function') {
|
|
1766
|
+
// Peak envelope per pixel column: the honest shape of what will be heard.
|
|
1767
|
+
const data = score.getChannelData(0);
|
|
1768
|
+
const mid = height / 2;
|
|
1769
|
+
g.strokeStyle = '#3fb6f0';
|
|
1770
|
+
g.lineWidth = 1;
|
|
1771
|
+
g.beginPath();
|
|
1772
|
+
for (let x = 0; x < width; x++) {
|
|
1773
|
+
const from = Math.floor((x / width) * data.length);
|
|
1774
|
+
const to = Math.floor(((x + 1) / width) * data.length);
|
|
1775
|
+
let peak = 0;
|
|
1776
|
+
for (let i = from; i < to; i += 2) {
|
|
1777
|
+
const v = Math.abs(data[i]);
|
|
1778
|
+
if (v > peak) peak = v;
|
|
1779
|
+
}
|
|
1780
|
+
const half = Math.min(mid - 2, peak * mid * 1.6);
|
|
1781
|
+
g.moveTo(x + 0.5, mid - half);
|
|
1782
|
+
g.lineTo(x + 0.5, mid + half);
|
|
1783
|
+
}
|
|
1784
|
+
g.stroke();
|
|
1785
|
+
} else {
|
|
1786
|
+
g.fillStyle = '#1a1f2b';
|
|
1787
|
+
g.fillRect(0, height / 2 - 1, width, 2);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Beat markers: the moments the animation code said matter.
|
|
1791
|
+
for (const b of beats) {
|
|
1792
|
+
const x = xOf(b.time);
|
|
1793
|
+
const power = Number.isFinite(b.power) ? b.power : 0.8;
|
|
1794
|
+
g.strokeStyle = `rgba(255,138,61,${0.45 + power * 0.55})`;
|
|
1795
|
+
g.lineWidth = 1 + power * 1.2;
|
|
1796
|
+
g.beginPath();
|
|
1797
|
+
g.moveTo(x, 0);
|
|
1798
|
+
g.lineTo(x, height);
|
|
1799
|
+
g.stroke();
|
|
1800
|
+
g.fillStyle = `rgba(255,138,61,${0.6 + power * 0.4})`;
|
|
1801
|
+
g.beginPath();
|
|
1802
|
+
g.arc(x, 3, 2 + power * 1.6, 0, Math.PI * 2);
|
|
1803
|
+
g.fill();
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Cue markers that were not already covered by a beat.
|
|
1807
|
+
for (const c of cues) {
|
|
1808
|
+
const near = beats.some((b) => Math.abs(b.time - c.time) < 0.05);
|
|
1809
|
+
if (near) continue;
|
|
1810
|
+
const x = xOf(c.time);
|
|
1811
|
+
g.strokeStyle = 'rgba(255,192,77,0.55)';
|
|
1812
|
+
g.lineWidth = 1;
|
|
1813
|
+
g.setLineDash([2, 3]);
|
|
1814
|
+
g.beginPath();
|
|
1815
|
+
g.moveTo(x, 0);
|
|
1816
|
+
g.lineTo(x, height);
|
|
1817
|
+
g.stroke();
|
|
1818
|
+
g.setLineDash([]);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Playhead.
|
|
1822
|
+
const px = xOf(scrub);
|
|
1823
|
+
g.strokeStyle = '#ffffff';
|
|
1824
|
+
g.lineWidth = 1.5;
|
|
1825
|
+
g.beginPath();
|
|
1826
|
+
g.moveTo(px, 0);
|
|
1827
|
+
g.lineTo(px, height);
|
|
1828
|
+
g.stroke();
|
|
1829
|
+
|
|
1830
|
+
return undefined;
|
|
1831
|
+
}, [duration, scrub, beats, cues, score]);
|
|
1832
|
+
|
|
1833
|
+
/** Click or drag anywhere on the track to scrub there. */
|
|
1834
|
+
const seek = (event) => {
|
|
1835
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
1836
|
+
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
|
1837
|
+
onSeek(ratio * duration);
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
return h('div', { className: 'cas-tracks' },
|
|
1841
|
+
// Picture track: storyboard cuts.
|
|
1842
|
+
h('div', {
|
|
1843
|
+
className: 'cas-track cas-track-video',
|
|
1844
|
+
onMouseDown: seek,
|
|
1845
|
+
title: '视频轨:竖线是分镜切点',
|
|
1846
|
+
},
|
|
1847
|
+
cuts.map((t) => h('span', {
|
|
1848
|
+
key: `c${t}`,
|
|
1849
|
+
className: 'cas-cut',
|
|
1850
|
+
style: { left: `${(t / duration) * 100}%` },
|
|
1851
|
+
})),
|
|
1852
|
+
cuts.length
|
|
1853
|
+
? null
|
|
1854
|
+
: h('span', { className: 'cas-track-note' }, '无分镜切点'),
|
|
1855
|
+
h('span', { className: 'cas-track-label' }, '视频'),
|
|
1856
|
+
),
|
|
1857
|
+
// Sound track: real waveform plus every reported beat.
|
|
1858
|
+
h('div', {
|
|
1859
|
+
className: 'cas-track cas-track-audio',
|
|
1860
|
+
onMouseDown: seek,
|
|
1861
|
+
title: '音频轨:波形是实际配乐,橙色竖线是卡点',
|
|
1862
|
+
},
|
|
1863
|
+
h('canvas', { ref: canvasRef, className: 'cas-wave' }),
|
|
1864
|
+
h('span', { className: 'cas-track-label' }, '音频'),
|
|
1865
|
+
),
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
function StillGallery({ stills, note, missing }) {
|
|
1869
|
+
const [lightbox, setLightbox] = useState('');
|
|
1870
|
+
return h('div', { className: 'cas-card' },
|
|
1871
|
+
h('div', { className: 'cas-card-head' },
|
|
1872
|
+
h('span', { className: 'cas-dot' }),
|
|
1873
|
+
h('span', { className: 'cas-card-title' }, '风格候选'),
|
|
1874
|
+
h('span', { className: 'cas-card-meta' }, `${stills.length} 条参考静帧`),
|
|
1875
|
+
),
|
|
1876
|
+
note ? h('div', { className: 'cas-note' }, note) : null,
|
|
1877
|
+
h('div', { className: 'cas-stills' },
|
|
1878
|
+
stills.map((s) => h('button', {
|
|
1879
|
+
key: s.id,
|
|
1880
|
+
className: 'cas-still',
|
|
1881
|
+
onClick: () => setLightbox(`${API}/still/${s.id}`),
|
|
1882
|
+
title: '点击放大',
|
|
1883
|
+
},
|
|
1884
|
+
h('img', { src: `${API}/still/${s.id}`, alt: s.name || s.id, loading: 'lazy' }),
|
|
1885
|
+
h('span', { className: 'cas-still-cap' },
|
|
1886
|
+
h('span', { className: 'cas-still-id' }, s.id),
|
|
1887
|
+
h('span', { className: 'cas-still-name' }, s.name || ''),
|
|
1888
|
+
h('span', { className: 'cas-still-desc' }, s.desc || ''),
|
|
1889
|
+
),
|
|
1890
|
+
)),
|
|
1891
|
+
),
|
|
1892
|
+
(missing && missing.length)
|
|
1893
|
+
? h('div', { className: 'cas-nostill' }, `没有参考静帧:${missing.join('、')}`)
|
|
1894
|
+
: null,
|
|
1895
|
+
lightbox
|
|
1896
|
+
? h('div', { className: 'cas-lightbox', onClick: () => setLightbox('') },
|
|
1897
|
+
h('img', { src: lightbox, alt: '参考静帧' }))
|
|
1898
|
+
: null,
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/** The storyboard draft, as a proportional timeline plus a per-shot list. */
|
|
1903
|
+
function StoryboardCard({ board }) {
|
|
1904
|
+
const shots = board.shots || [];
|
|
1905
|
+
const shots2 = shots.length ? shots : [];
|
|
1906
|
+
return h('div', { className: 'cas-card' },
|
|
1907
|
+
h('div', { className: 'cas-card-head' },
|
|
1908
|
+
h('span', { className: 'cas-dot' }),
|
|
1909
|
+
h('span', { className: 'cas-card-title' }, `分镜 · ${board.title || ''}`),
|
|
1910
|
+
h('span', { className: 'cas-card-meta' },
|
|
1911
|
+
[
|
|
1912
|
+
board.styleId ? `${board.styleId} ${board.styleName || ''}`.trim() : '',
|
|
1913
|
+
`${shots2.length} 场`,
|
|
1914
|
+
`${board.duration}s`,
|
|
1915
|
+
board.aspect || '',
|
|
1916
|
+
].filter(Boolean).join(' · ')),
|
|
1917
|
+
),
|
|
1918
|
+
h('div', { className: 'cas-sb-track' },
|
|
1919
|
+
shots2.map((shot, i) => h('span', {
|
|
1920
|
+
key: i,
|
|
1921
|
+
className: 'cas-sb-seg',
|
|
1922
|
+
title: `${shot.start}–${shot.end}s|${shot.scene}`,
|
|
1923
|
+
style: {
|
|
1924
|
+
flexGrow: String(Math.max(0.05, Number(shot.end) - Number(shot.start))),
|
|
1925
|
+
background: PALETTE[i % PALETTE.length],
|
|
1926
|
+
},
|
|
1927
|
+
}, h('b', null, String(i + 1))))),
|
|
1928
|
+
h('div', { className: 'cas-sb-list' },
|
|
1929
|
+
shots2.map((shot, i) => h('div', { key: i, className: 'cas-sb-shot' },
|
|
1930
|
+
h('span', { className: 'cas-sb-idx', style: { background: PALETTE[i % PALETTE.length] } }, String(i + 1)),
|
|
1931
|
+
h('span', { className: 'cas-sb-t' }, `${Number(shot.start).toFixed(2)}–${Number(shot.end).toFixed(2)}s`),
|
|
1932
|
+
h('span', { className: 'cas-sb-body' },
|
|
1933
|
+
h('span', { className: 'cas-sb-scene' }, shot.scene || ''),
|
|
1934
|
+
shot.text ? h('span', { className: 'cas-sb-text' }, `字幕「${shot.text}」`) : null,
|
|
1935
|
+
shot.camera ? h('span', { className: 'cas-sb-cam' }, shot.camera) : null,
|
|
1936
|
+
),
|
|
1937
|
+
))),
|
|
1938
|
+
(board.warnings && board.warnings.length)
|
|
1939
|
+
? h('div', { className: 'cas-warn' }, `需要留意:${board.warnings.join(';')}`)
|
|
1940
|
+
: null,
|
|
1941
|
+
h('div', { className: 'cas-sb-foot' }, '这份分镜只是草稿。确认之后再让 Agent 开始写动画;要改就直接说加场、删场、调时长或改文案。'),
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* The finished animation: pick a score and a container, scrub any moment, export.
|
|
1947
|
+
*
|
|
1948
|
+
* Exports are written to the session directory through the Host so the file lands
|
|
1949
|
+
* somewhere the user can find; the card shows that absolute path before and after
|
|
1950
|
+
* the export, and keeps a browser download as a secondary action.
|
|
1951
|
+
*/
|
|
1952
|
+
function AnimationCard({ anim, cwd, uiWorkspace }) {
|
|
1953
|
+
const [scrub, setScrub] = useState(0);
|
|
1954
|
+
const [busy, setBusy] = useState('');
|
|
1955
|
+
const [progress, setProgress] = useState(0);
|
|
1956
|
+
const [note, setNote] = useState('');
|
|
1957
|
+
const [savedPath, setSavedPath] = useState('');
|
|
1958
|
+
const [targetDir, setTargetDir] = useState('');
|
|
1959
|
+
const [customDir, setCustomDir] = useState('');
|
|
1960
|
+
const [aspect, setAspect] = useState('16:9');
|
|
1961
|
+
const [playing, setPlaying] = useState(false);
|
|
1962
|
+
// The rendered score, exposed to the timeline so it can draw the real waveform.
|
|
1963
|
+
const [scoreBuffer, setScoreBuffer] = useState(null);
|
|
1964
|
+
const animFrameRef = useRef(null);
|
|
1965
|
+
const scrubRef = useRef(0);
|
|
1966
|
+
scrubRef.current = scrub;
|
|
1967
|
+
const [music, setMusic] = useState(anim.music || 'none');
|
|
1968
|
+
const [custom, setCustom] = useState(null);
|
|
1969
|
+
const [customName, setCustomName] = useState('');
|
|
1970
|
+
const [stage, setStage] = useState('');
|
|
1971
|
+
const [format, setFormat] = useState(null);
|
|
1972
|
+
const previewRef = useRef(null);
|
|
1973
|
+
const audioCtxRef = useRef(null);
|
|
1974
|
+
const audioSourceRef = useRef(null);
|
|
1975
|
+
const scoreRef = useRef(null);
|
|
1976
|
+
const fileRef = useRef(null);
|
|
1977
|
+
const frameRef = useRef(null);
|
|
1978
|
+
|
|
1979
|
+
// Only offer containers this browser can actually record.
|
|
1980
|
+
const available = useMemo(
|
|
1981
|
+
() => CONTAINERS.filter((c) => c.mimes.some((m) => (
|
|
1982
|
+
typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)
|
|
1983
|
+
))),
|
|
1984
|
+
[],
|
|
1985
|
+
);
|
|
1986
|
+
const defaultFormat = available.some((c) => c.id === 'mp4') ? 'mp4' : (available[0] && available[0].id) || 'webm';
|
|
1987
|
+
const activeFormat = format || defaultFormat;
|
|
1988
|
+
|
|
1989
|
+
const duration = Number(anim.duration) || 8;
|
|
1990
|
+
const fps = Number(anim.fps) || 30;
|
|
1991
|
+
// Cut points come from the storyboard, so the score lands on the edits.
|
|
1992
|
+
const cuts = useMemo(
|
|
1993
|
+
() => (Array.isArray(anim.cuts) ? anim.cuts.map(Number).filter((t) => Number.isFinite(t) && t > 0) : []),
|
|
1994
|
+
[anim.cuts],
|
|
1995
|
+
);
|
|
1996
|
+
// In-shot visual peaks the score must hit as well as the cuts.
|
|
1997
|
+
const cues = useMemo(() => (Array.isArray(anim.cues) ? anim.cues : []), [anim.cues]);
|
|
1998
|
+
// Beats are exported from the animation code, so they are the exact sync points.
|
|
1999
|
+
const beats = useMemo(() => (Array.isArray(anim.beats) ? anim.beats : []), [anim.beats]);
|
|
2000
|
+
|
|
2001
|
+
useEffect(() => () => { if (previewRef.current) previewRef.current.stop(); }, []);
|
|
2002
|
+
|
|
2003
|
+
// Resolve the destination up front so the card can state it before exporting.
|
|
2004
|
+
useEffect(() => {
|
|
2005
|
+
let cancelled = false;
|
|
2006
|
+
const query = cwd ? `?dir=${encodeURIComponent(cwd)}` : '';
|
|
2007
|
+
fetch(`${API}/export-dir${query}`)
|
|
2008
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
2009
|
+
.then((data) => { if (!cancelled && data && data.dir) setTargetDir(data.dir); })
|
|
2010
|
+
.catch(() => {});
|
|
2011
|
+
return () => { cancelled = true; };
|
|
2012
|
+
}, [cwd]);
|
|
2013
|
+
|
|
2014
|
+
const applyScrub = useCallback((value) => {
|
|
2015
|
+
setScrub(value);
|
|
2016
|
+
const win = frameRef.current && frameRef.current.contentWindow;
|
|
2017
|
+
if (win && typeof win.renderAt === 'function') {
|
|
2018
|
+
try { win.renderAt(Number(value)); } catch { /* one bad frame must not break the card */ }
|
|
2019
|
+
}
|
|
2020
|
+
}, []);
|
|
2021
|
+
|
|
2022
|
+
/** Stop the auditioning source without touching the video clock. */
|
|
2023
|
+
const stopAudio = useCallback(() => {
|
|
2024
|
+
if (audioSourceRef.current) {
|
|
2025
|
+
try { audioSourceRef.current.stop(); } catch { /* already ended */ }
|
|
2026
|
+
audioSourceRef.current = null;
|
|
2027
|
+
}
|
|
2028
|
+
if (audioCtxRef.current) {
|
|
2029
|
+
void audioCtxRef.current.close().catch(() => {});
|
|
2030
|
+
audioCtxRef.current = null;
|
|
2031
|
+
}
|
|
2032
|
+
}, []);
|
|
2033
|
+
|
|
2034
|
+
const stopPlayback = useCallback(() => {
|
|
2035
|
+
if (animFrameRef.current) {
|
|
2036
|
+
cancelAnimationFrame(animFrameRef.current);
|
|
2037
|
+
animFrameRef.current = null;
|
|
2038
|
+
}
|
|
2039
|
+
stopAudio();
|
|
2040
|
+
setPlaying(false);
|
|
2041
|
+
}, [stopAudio]);
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Start the score at an offset so picture and sound share one clock.
|
|
2045
|
+
* The buffer is rendered ahead of time, so pressing play is instant.
|
|
2046
|
+
*/
|
|
2047
|
+
const startAudioAt = useCallback(async (offset) => {
|
|
2048
|
+
const buffer = custom || (music && music !== 'none' ? scoreRef.current : null);
|
|
2049
|
+
if (!buffer) return;
|
|
2050
|
+
const ctx = new AudioContext();
|
|
2051
|
+
audioCtxRef.current = ctx;
|
|
2052
|
+
if (ctx.state === 'suspended') {
|
|
2053
|
+
await Promise.race([ctx.resume().catch(() => {}), new Promise((r) => setTimeout(r, 400))]);
|
|
2054
|
+
}
|
|
2055
|
+
if (audioCtxRef.current !== ctx) { void ctx.close(); return; }
|
|
2056
|
+
const source = ctx.createBufferSource();
|
|
2057
|
+
source.buffer = buffer;
|
|
2058
|
+
source.connect(ctx.destination);
|
|
2059
|
+
source.start(0, Math.max(0, Math.min(offset, buffer.duration - 0.01)));
|
|
2060
|
+
audioSourceRef.current = source;
|
|
2061
|
+
}, [music, custom]);
|
|
2062
|
+
|
|
2063
|
+
const togglePlay = useCallback(() => {
|
|
2064
|
+
if (playing) {
|
|
2065
|
+
stopPlayback();
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
let startT = Number(scrubRef.current);
|
|
2069
|
+
if (startT >= duration - 0.05) {
|
|
2070
|
+
startT = 0;
|
|
2071
|
+
applyScrub(0);
|
|
2072
|
+
}
|
|
2073
|
+
setPlaying(true);
|
|
2074
|
+
void startAudioAt(startT);
|
|
2075
|
+
const startWallTime = performance.now() - startT * 1000;
|
|
2076
|
+
const step = (now) => {
|
|
2077
|
+
const curT = (now - startWallTime) / 1000;
|
|
2078
|
+
if (curT >= duration) {
|
|
2079
|
+
applyScrub(duration);
|
|
2080
|
+
stopAudio();
|
|
2081
|
+
setPlaying(false);
|
|
2082
|
+
animFrameRef.current = null;
|
|
2083
|
+
} else {
|
|
2084
|
+
applyScrub(curT);
|
|
2085
|
+
animFrameRef.current = requestAnimationFrame(step);
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
animFrameRef.current = requestAnimationFrame(step);
|
|
2089
|
+
}, [playing, duration, applyScrub, stopPlayback, startAudioAt, stopAudio]);
|
|
2090
|
+
|
|
2091
|
+
useEffect(() => () => {
|
|
2092
|
+
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
2093
|
+
stopAudio();
|
|
2094
|
+
}, [stopAudio]);
|
|
2095
|
+
|
|
2096
|
+
// Render the score once so auditioning is instant and export reuses the buffer.
|
|
2097
|
+
useEffect(() => {
|
|
2098
|
+
if (custom) { scoreRef.current = custom; return undefined; }
|
|
2099
|
+
const hasModelScore = !!anim.scoreUrl;
|
|
2100
|
+
if (!hasModelScore && (!music || music === 'none')) { scoreRef.current = null; return undefined; }
|
|
2101
|
+
let cancelled = false;
|
|
2102
|
+
scoreRef.current = null;
|
|
2103
|
+
|
|
2104
|
+
const build = hasModelScore
|
|
2105
|
+
// Prefer the score the model wrote for this clip; the engine is the fallback.
|
|
2106
|
+
? () => renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats })
|
|
2107
|
+
.then((buf) => buf || renderMusic(music || 'cinematic', { duration, cuts, cues, seed: seedOf(anim.id) }))
|
|
2108
|
+
: () => renderMusic(music, { duration, cuts, cues, seed: seedOf(anim.id) });
|
|
2109
|
+
|
|
2110
|
+
build()
|
|
2111
|
+
.then((buf) => {
|
|
2112
|
+
if (cancelled) return;
|
|
2113
|
+
scoreRef.current = buf;
|
|
2114
|
+
// Hand the buffer to the timeline so it can draw the true waveform.
|
|
2115
|
+
setScoreBuffer(buf || null);
|
|
2116
|
+
})
|
|
2117
|
+
.catch(() => {});
|
|
2118
|
+
return () => { cancelled = true; };
|
|
2119
|
+
}, [music, custom, duration, cuts, cues, beats, anim.scoreUrl]);
|
|
2120
|
+
|
|
2121
|
+
// The first build effect resets the buffer; keep the timeline in step with it.
|
|
2122
|
+
useEffect(() => {
|
|
2123
|
+
if (custom) setScoreBuffer(custom);
|
|
2124
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2125
|
+
}, [custom]);
|
|
2126
|
+
|
|
2127
|
+
const onPickFile = useCallback(async (event) => {
|
|
2128
|
+
const file = event.target.files && event.target.files[0];
|
|
2129
|
+
if (!file) return;
|
|
2130
|
+
try {
|
|
2131
|
+
const buffer = await audioFileToBuffer(file, duration);
|
|
2132
|
+
setCustom(buffer);
|
|
2133
|
+
setCustomName(file.name);
|
|
2134
|
+
setNote(`已载入 ${file.name},导出时会混入`);
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
setNote(`音频读取失败:${error.message}`);
|
|
2137
|
+
}
|
|
2138
|
+
}, [duration]);
|
|
2139
|
+
|
|
2140
|
+
const onPickDirectory = useCallback(async () => {
|
|
2141
|
+
if (uiWorkspace && typeof uiWorkspace.pickDirectory === 'function') {
|
|
2142
|
+
try {
|
|
2143
|
+
const chosen = await uiWorkspace.pickDirectory();
|
|
2144
|
+
if (chosen) {
|
|
2145
|
+
setCustomDir(chosen);
|
|
2146
|
+
setTargetDir(chosen);
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
} catch (e) {
|
|
2150
|
+
console.warn('uiWorkspace.pickDirectory error:', e);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const val = window.prompt('请输入保存视频的目标文件夹路径(绝对路径):', customDir || targetDir || cwd || '');
|
|
2154
|
+
if (val && val.trim()) {
|
|
2155
|
+
setCustomDir(val.trim());
|
|
2156
|
+
setTargetDir(val.trim());
|
|
2157
|
+
}
|
|
2158
|
+
}, [uiWorkspace, customDir, targetDir, cwd]);
|
|
2159
|
+
|
|
2160
|
+
const onExport = useCallback(async () => {
|
|
2161
|
+
if (busy) return;
|
|
2162
|
+
setBusy('export');
|
|
2163
|
+
setProgress(0);
|
|
2164
|
+
setNote('');
|
|
2165
|
+
setSavedPath('');
|
|
2166
|
+
setStage('');
|
|
2167
|
+
try {
|
|
2168
|
+
// The score may still be rendering when export is pressed; wait for it so the
|
|
2169
|
+
// exported audio is the same one the preview played.
|
|
2170
|
+
let score = scoreRef.current;
|
|
2171
|
+
if (!score && !custom && anim.scoreUrl) {
|
|
2172
|
+
setStage('正在合成配乐…');
|
|
2173
|
+
score = await renderModelScore(anim.scoreUrl, { duration, cuts, cues, beats }).catch(() => null);
|
|
2174
|
+
}
|
|
2175
|
+
const { blob, container, hasAudio, audioPeak } = await recordAnimation(frameRef.current, {
|
|
2176
|
+
duration,
|
|
2177
|
+
fps,
|
|
2178
|
+
cuts,
|
|
2179
|
+
container: activeFormat,
|
|
2180
|
+
music: custom ? 'none' : music,
|
|
2181
|
+
musicBuffer: custom || undefined,
|
|
2182
|
+
scoreBuffer: score || undefined,
|
|
2183
|
+
clipId: anim.id,
|
|
2184
|
+
onStage: setStage,
|
|
2185
|
+
onProgress: setProgress,
|
|
2186
|
+
});
|
|
2187
|
+
if (!blob || !blob.size) throw new Error('录制结果为空');
|
|
2188
|
+
|
|
2189
|
+
const wanted = custom || music !== 'none';
|
|
2190
|
+
// A zero peak means the browser never started audio: say so instead of
|
|
2191
|
+
// handing over a silent file as if it were scored.
|
|
2192
|
+
const audioNote = !wanted
|
|
2193
|
+
? '无声'
|
|
2194
|
+
: (hasAudio && audioPeak > 1e-4)
|
|
2195
|
+
? '含配乐'
|
|
2196
|
+
: '⚠ 配乐未进入录制,这段是无声的';
|
|
2197
|
+
|
|
2198
|
+
const fileName = `${anim.title || anim.id || 'animation'}.${container.ext}`;
|
|
2199
|
+
|
|
2200
|
+
// One click does both: download to the browser, and also write into the
|
|
2201
|
+
// chosen directory, so the file is never hidden in only one place.
|
|
2202
|
+
download(blob, fileName);
|
|
2203
|
+
|
|
2204
|
+
setStage('正在写入目录…');
|
|
2205
|
+
try {
|
|
2206
|
+
const query = new URLSearchParams({ name: fileName });
|
|
2207
|
+
const exportDir = customDir || cwd;
|
|
2208
|
+
if (exportDir) query.set('dir', exportDir);
|
|
2209
|
+
const res = await fetch(`${API}/export?${query}`, {
|
|
2210
|
+
method: 'POST',
|
|
2211
|
+
headers: { 'content-type': blob.type || 'application/octet-stream' },
|
|
2212
|
+
body: blob,
|
|
2213
|
+
});
|
|
2214
|
+
const data = await res.json().catch(() => ({}));
|
|
2215
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
2216
|
+
setSavedPath(data.path);
|
|
2217
|
+
setNote(`已下载并保存到 ${data.path}(${(blob.size / 1048576).toFixed(1)} MB · ${container.label} · ${audioNote})`);
|
|
2218
|
+
} catch (error) {
|
|
2219
|
+
// The download already happened, so the render is never lost.
|
|
2220
|
+
setNote(`已下载 ${fileName} · ${container.label} · ${audioNote}(未写入目录:${error.message})`);
|
|
2221
|
+
}
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
setNote(`导出失败:${error.message}`);
|
|
2224
|
+
} finally {
|
|
2225
|
+
setBusy('');
|
|
2226
|
+
setStage('');
|
|
2227
|
+
}
|
|
2228
|
+
}, [busy, duration, fps, music, custom, cuts, cues, anim, activeFormat, customDir, cwd]);
|
|
2229
|
+
|
|
2230
|
+
return h('div', { className: 'cas-card' },
|
|
2231
|
+
h('div', { className: 'cas-card-head' },
|
|
2232
|
+
h('span', { className: 'cas-dot' }),
|
|
2233
|
+
h('span', { className: 'cas-card-title' }, anim.title || '动画'),
|
|
2234
|
+
h('span', { className: 'cas-card-meta' },
|
|
2235
|
+
[
|
|
2236
|
+
anim.styleId ? `${anim.styleId} ${anim.styleName || ''}`.trim() : '',
|
|
2237
|
+
`${duration}s`,
|
|
2238
|
+
`${fps}fps`,
|
|
2239
|
+
beats.length ? `${beats.length} 个卡点` : (cuts.length ? `${cuts.length} 个切点` : ''),
|
|
2240
|
+
].filter(Boolean).join(' · ')),
|
|
2241
|
+
h('span', { className: 'cas-grow' }),
|
|
2242
|
+
h('button', {
|
|
2243
|
+
className: 'cas-btn cas-btn-primary',
|
|
2244
|
+
onClick: onExport,
|
|
2245
|
+
disabled: !!busy,
|
|
2246
|
+
}, busy === 'export' ? (stage || `录制中 ${Math.round(progress * 100)}%`) : '导出视频'),
|
|
2247
|
+
),
|
|
2248
|
+
h('div', { className: 'cas-stage', 'data-aspect': aspect },
|
|
2249
|
+
h('iframe', {
|
|
2250
|
+
ref: frameRef,
|
|
2251
|
+
className: 'cas-frame',
|
|
2252
|
+
title: anim.title || '动画',
|
|
2253
|
+
src: `${API}/animation/${anim.id}`,
|
|
2254
|
+
sandbox: 'allow-scripts allow-same-origin',
|
|
2255
|
+
onLoad: () => {
|
|
2256
|
+
const frame = frameRef.current;
|
|
2257
|
+
const doc = frame && frame.contentDocument;
|
|
2258
|
+
const win = frame && frame.contentWindow;
|
|
2259
|
+
const canvas = doc && doc.querySelector('canvas');
|
|
2260
|
+
if (canvas && canvas.width && canvas.height) {
|
|
2261
|
+
const ratio = canvas.width / canvas.height;
|
|
2262
|
+
setAspect(ratio > 1.2 ? '16:9' : ratio < 0.85 ? '9:16' : '1:1');
|
|
2263
|
+
}
|
|
2264
|
+
if (win && typeof win.renderAt === 'function') {
|
|
2265
|
+
try { win.renderAt(0); } catch { /* ignore */ }
|
|
2266
|
+
}
|
|
2267
|
+
},
|
|
2268
|
+
}),
|
|
2269
|
+
),
|
|
2270
|
+
h(Timeline, {
|
|
2271
|
+
duration,
|
|
2272
|
+
scrub,
|
|
2273
|
+
beats,
|
|
2274
|
+
cuts,
|
|
2275
|
+
cues,
|
|
2276
|
+
score: scoreBuffer,
|
|
2277
|
+
fps,
|
|
2278
|
+
onSeek: (t) => { stopPlayback(); applyScrub(t); },
|
|
2279
|
+
}),
|
|
2280
|
+
h('div', { className: 'cas-bar' },
|
|
2281
|
+
h('button', {
|
|
2282
|
+
className: 'cas-play-btn',
|
|
2283
|
+
title: playing ? '暂停' : '播放',
|
|
2284
|
+
onClick: togglePlay,
|
|
2285
|
+
}, playing ? '⏸' : '▶'),
|
|
2286
|
+
h('input', {
|
|
2287
|
+
className: 'cas-scrub', type: 'range', min: 0, max: duration, step: 0.05,
|
|
2288
|
+
value: scrub,
|
|
2289
|
+
onChange: (e) => {
|
|
2290
|
+
stopPlayback();
|
|
2291
|
+
applyScrub(e.target.value);
|
|
2292
|
+
},
|
|
2293
|
+
}),
|
|
2294
|
+
h('span', { className: 'cas-t' }, `${Number(scrub).toFixed(2)}s / ${duration}s`),
|
|
2295
|
+
h('button', {
|
|
2296
|
+
className: 'cas-play-btn',
|
|
2297
|
+
style: { width: '24px', height: '24px', fontSize: '11px' },
|
|
2298
|
+
title: '从头播放',
|
|
2299
|
+
onClick: () => {
|
|
2300
|
+
stopPlayback();
|
|
2301
|
+
applyScrub(0);
|
|
2302
|
+
},
|
|
2303
|
+
}, '↺'),
|
|
2304
|
+
),
|
|
2305
|
+
h('div', { className: 'cas-score' },
|
|
2306
|
+
h('span', { className: 'cas-score-label' }, '配乐'),
|
|
2307
|
+
custom || (music && music !== 'none')
|
|
2308
|
+
? h('span', { className: 'cas-score-prompt', title: customName || music },
|
|
2309
|
+
customName ? `自定义:${customName}` : music)
|
|
2310
|
+
: h('span', { className: 'cas-score-hint' }, '无配乐'),
|
|
2311
|
+
h('span', { className: 'cas-score-hint' },
|
|
2312
|
+
custom ? '已载入自定义音频'
|
|
2313
|
+
: `${cuts.length} 个切点${cues.length ? ` + ${cues.length} 个画面重点` : ''} 已卡点`),
|
|
2314
|
+
h('span', { className: 'cas-grow' }),
|
|
2315
|
+
h('button', {
|
|
2316
|
+
className: 'cas-btn',
|
|
2317
|
+
onClick: () => {
|
|
2318
|
+
stopPlayback();
|
|
2319
|
+
if (custom || music === 'none') { setCustom(null); setCustomName(''); setMusic(anim.music || ''); return; }
|
|
2320
|
+
setMusic('none');
|
|
2321
|
+
},
|
|
2322
|
+
disabled: !!busy,
|
|
2323
|
+
}, custom || music === 'none' ? '恢复配乐' : '静音'),
|
|
2324
|
+
h('button', {
|
|
2325
|
+
className: 'cas-btn',
|
|
2326
|
+
onClick: () => { if (fileRef.current) fileRef.current.click(); },
|
|
2327
|
+
disabled: !!busy,
|
|
2328
|
+
}, '换音频…'),
|
|
2329
|
+
h('input', {
|
|
2330
|
+
ref: fileRef, type: 'file', accept: 'audio/*',
|
|
2331
|
+
style: { display: 'none' }, onChange: onPickFile,
|
|
2332
|
+
}),
|
|
2333
|
+
),
|
|
2334
|
+
h('div', { className: 'cas-score' },
|
|
2335
|
+
h('span', { className: 'cas-score-label' }, '格式'),
|
|
2336
|
+
available.length > 1
|
|
2337
|
+
? h('select', {
|
|
2338
|
+
className: 'cas-select',
|
|
2339
|
+
value: activeFormat,
|
|
2340
|
+
onChange: (e) => setFormat(e.target.value),
|
|
2341
|
+
}, available.map((c) => h('option', { key: c.id, value: c.id }, c.label)))
|
|
2342
|
+
: h('span', { className: 'cas-score-hint' }, available[0] ? available[0].label : '无可用容器'),
|
|
2343
|
+
h('span', { className: 'cas-score-hint' },
|
|
2344
|
+
available.some((c) => c.id === 'mp4')
|
|
2345
|
+
? 'MP4 为 H.264 + AAC,可直接发微信、剪映、Premiere'
|
|
2346
|
+
: '当前浏览器不支持 MP4 录制,可用 ffmpeg 转码'),
|
|
2347
|
+
),
|
|
2348
|
+
// State the destination before the export so the file is never a mystery.
|
|
2349
|
+
h('div', { className: 'cas-path' },
|
|
2350
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } },
|
|
2351
|
+
h('span', null, savedPath
|
|
2352
|
+
? h('span', { className: 'cas-path-ok' }, `已保存到 ${savedPath}`)
|
|
2353
|
+
: (customDir || targetDir
|
|
2354
|
+
? `导出目录:${customDir || targetDir}`
|
|
2355
|
+
: '导出目录:会话工作目录下的 code-animation/')),
|
|
2356
|
+
h('button', {
|
|
2357
|
+
className: 'cas-btn',
|
|
2358
|
+
style: { fontSize: '11px', padding: '2px 8px', height: '22px' },
|
|
2359
|
+
onClick: onPickDirectory,
|
|
2360
|
+
disabled: !!busy,
|
|
2361
|
+
}, '📂 选择文件夹…'),
|
|
2362
|
+
),
|
|
2363
|
+
),
|
|
2364
|
+
(anim.warnings && anim.warnings.length)
|
|
2365
|
+
? h('div', { className: 'cas-warn' }, `建议检查:${anim.warnings.join(';')}`)
|
|
2366
|
+
: null,
|
|
2367
|
+
note ? h('div', { className: 'cas-note' }, note) : null,
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* The Turn-level creation workspace.
|
|
2373
|
+
*
|
|
2374
|
+
* It renders beneath the closing prose and outside the collapsible Tool group,
|
|
2375
|
+
* so the reference stills, the storyboard draft and the finished animation stay
|
|
2376
|
+
* visible without expanding the process log.
|
|
2377
|
+
*/
|
|
2378
|
+
function CreationTail(props) {
|
|
2379
|
+
const data = props.turn && props.turn.data ? props.turn.data.get(KIND) : undefined;
|
|
2380
|
+
const useSessions = props.useSessions;
|
|
2381
|
+
const sessionId = props.sessionId;
|
|
2382
|
+
// The session's working directory decides where exports land.
|
|
2383
|
+
const cwd = useSessions && sessionId
|
|
2384
|
+
? useSessions((state) => state.byId[sessionId]?.cwd)
|
|
2385
|
+
: undefined;
|
|
2386
|
+
if (!data) return null;
|
|
2387
|
+
|
|
2388
|
+
// The reference stills and the storyboard draft render in their own Tool rows
|
|
2389
|
+
// (see ShowStillsRow and StoryboardRow), which exist as soon as each result
|
|
2390
|
+
// lands. Repeating them here would show the same card twice once the Turn ends,
|
|
2391
|
+
// so this Turn-level seat only carries what has no Tool row of its own.
|
|
2392
|
+
const latestAnim = data.animations[data.animations.length - 1];
|
|
2393
|
+
if (!latestAnim) return null;
|
|
2394
|
+
|
|
2395
|
+
return h('div', { className: 'cas-tail' },
|
|
2396
|
+
h('style', null, CSS),
|
|
2397
|
+
h(AnimationCard, { anim: latestAnim, cwd, uiWorkspace: props.uiWorkspace }),
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* Fold one Turn's creation artifacts out of the session log.
|
|
2403
|
+
*
|
|
2404
|
+
* Everything is narrowed from the persisted call arguments and result text, so a
|
|
2405
|
+
* replayed Session reproduces the same cards with no Host change and no reliance
|
|
2406
|
+
* on live plugin memory.
|
|
2407
|
+
*/
|
|
2408
|
+
const creationTurnDefinition = {
|
|
2409
|
+
kind: KIND,
|
|
2410
|
+
match: (event) => {
|
|
2411
|
+
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' };
|
|
2412
|
+
if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' };
|
|
2413
|
+
// Replacement copies stay model-only; only appended results are transcript material.
|
|
2414
|
+
if (event.type === 'tool/result' && event.surfaceOp === 'append') {
|
|
2415
|
+
return { id: String(event.data.turn), role: 'update' };
|
|
2416
|
+
}
|
|
2417
|
+
return null;
|
|
2418
|
+
},
|
|
2419
|
+
start: (_context, match) => ({
|
|
2420
|
+
turn: match.event.data.turn,
|
|
2421
|
+
calls: new Map(),
|
|
2422
|
+
stills: null,
|
|
2423
|
+
storyboards: [],
|
|
2424
|
+
animations: [],
|
|
2425
|
+
}),
|
|
2426
|
+
update: (context, match) => {
|
|
2427
|
+
const state = context.state;
|
|
2428
|
+
|
|
2429
|
+
if (match.event.type === 'tool/call') {
|
|
2430
|
+
const calls = new Map(state.calls);
|
|
2431
|
+
calls.set(String(match.event.data.callId), {
|
|
2432
|
+
name: match.event.data.name,
|
|
2433
|
+
argsRaw: match.event.data.arguments,
|
|
2434
|
+
});
|
|
2435
|
+
return { ...state, calls };
|
|
2436
|
+
}
|
|
2437
|
+
if (match.event.type !== 'tool/result') return state;
|
|
2438
|
+
|
|
2439
|
+
const message = match.event.data.message;
|
|
2440
|
+
if (message.isError === true) return state;
|
|
2441
|
+
const call = state.calls.get(String(message.source.callId));
|
|
2442
|
+
if (!call) return state;
|
|
2443
|
+
|
|
2444
|
+
const args = parseArgs(call.argsRaw);
|
|
2445
|
+
const text = (message.content || [])
|
|
2446
|
+
.filter((block) => block.type === 'text')
|
|
2447
|
+
.map((block) => block.text)
|
|
2448
|
+
.join('\n');
|
|
2449
|
+
|
|
2450
|
+
if (call.name === 'show_stills') {
|
|
2451
|
+
const items = (Array.isArray(args.ids) ? args.ids : []).map((id) => {
|
|
2452
|
+
const key = String(id).toUpperCase();
|
|
2453
|
+
const match2 = new RegExp(`${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+([^|\\n]+)`).exec(text);
|
|
2454
|
+
return {
|
|
2455
|
+
id: key,
|
|
2456
|
+
name: match2 ? match2[1].trim() : '',
|
|
2457
|
+
desc: (new RegExp(`${key}\\s+[^|\\n]+|([^\\n]+)`).exec(text) || [])[1] || '',
|
|
2458
|
+
};
|
|
2459
|
+
});
|
|
2460
|
+
const missing = (new RegExp('没有参考静帧:([^\\n(]+)').exec(text) || [])[1];
|
|
2461
|
+
return {
|
|
2462
|
+
...state,
|
|
2463
|
+
stills: {
|
|
2464
|
+
items,
|
|
2465
|
+
note: typeof args.note === 'string' ? args.note : '',
|
|
2466
|
+
missing: missing ? missing.split('、').map((s) => s.trim()).filter(Boolean) : [],
|
|
2467
|
+
},
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if (call.name === 'save_storyboard') {
|
|
2472
|
+
const shots = Array.isArray(args.shots) ? args.shots : [];
|
|
2473
|
+
// Shot starts after 0 are the cut points the score will be fitted to.
|
|
2474
|
+
const cuts = shots
|
|
2475
|
+
.map((s) => Number(s.start))
|
|
2476
|
+
.filter((t) => Number.isFinite(t) && t > 0.05)
|
|
2477
|
+
.sort((a, b) => a - b);
|
|
2478
|
+
return {
|
|
2479
|
+
...state,
|
|
2480
|
+
storyboards: [...state.storyboards, {
|
|
2481
|
+
title: String(args.title || ''),
|
|
2482
|
+
styleId: String(args.style_id || ''),
|
|
2483
|
+
styleName: '',
|
|
2484
|
+
duration: Number(args.duration) || 0,
|
|
2485
|
+
aspect: String(args.aspect || ''),
|
|
2486
|
+
music: String(args.music || ''),
|
|
2487
|
+
cuts,
|
|
2488
|
+
shots: shots.map((s, i) => ({
|
|
2489
|
+
index: i + 1,
|
|
2490
|
+
start: Number(s.start) || 0,
|
|
2491
|
+
end: Number(s.end) || 0,
|
|
2492
|
+
scene: String(s.scene || ''),
|
|
2493
|
+
text: String(s.text || ''),
|
|
2494
|
+
camera: String(s.camera || ''),
|
|
2495
|
+
})),
|
|
2496
|
+
warnings: (new RegExp('需要留意:([^\\n]+)').exec(text) || [])[1]
|
|
2497
|
+
? (new RegExp('需要留意:([^\\n]+)').exec(text))[1].split(';').map((s) => s.trim()).filter(Boolean)
|
|
2498
|
+
: [],
|
|
2499
|
+
}],
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
if (call.name === 'save_animation') {
|
|
2504
|
+
const id = (new RegExp(`${API}/animation/([A-Za-z0-9_-]+)`).exec(text) || [])[1]
|
|
2505
|
+
|| String(args.storyboard_id || '').replace(/^sb-/, 'anim-');
|
|
2506
|
+
if (!id) return state;
|
|
2507
|
+
const warn = (new RegExp('^(?:注意|建议检查):(.+)$', 'm').exec(text) || [])[1];
|
|
2508
|
+
// The linked storyboard supplies the cut points, so the score can be fitted
|
|
2509
|
+
// to the real edits rather than to a fixed tempo.
|
|
2510
|
+
const board = state.storyboards.find((b) => b.id === args.storyboard_id)
|
|
2511
|
+
|| state.storyboards[state.storyboards.length - 1];
|
|
2512
|
+
return {
|
|
2513
|
+
...state,
|
|
2514
|
+
animations: [...state.animations, {
|
|
2515
|
+
id,
|
|
2516
|
+
title: String(args.title || ''),
|
|
2517
|
+
styleId: String(args.style_id || ''),
|
|
2518
|
+
styleName: '',
|
|
2519
|
+
duration: Number(args.duration) || 8,
|
|
2520
|
+
fps: Number(args.fps) || 30,
|
|
2521
|
+
music: String(args.music || (board && board.music) || ''),
|
|
2522
|
+
cuts: board ? board.cuts || [] : [],
|
|
2523
|
+
warnings: warn ? warn.split(';').map((s) => s.trim()).filter(Boolean) : [],
|
|
2524
|
+
}],
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
return state;
|
|
2529
|
+
},
|
|
2530
|
+
buildLocationData: (context, scope, previous) => {
|
|
2531
|
+
if (scope !== 'turn' || context.state === undefined) return null;
|
|
2532
|
+
const state = context.state;
|
|
2533
|
+
// Reuse the previous value when nothing changed, so the snapshot does not republish.
|
|
2534
|
+
if (
|
|
2535
|
+
previous && previous.kind === 'turn' && previous.turn === state.turn && previous.key === KIND
|
|
2536
|
+
&& previous.value.stills === state.stills
|
|
2537
|
+
&& previous.value.storyboards === state.storyboards
|
|
2538
|
+
&& previous.value.animations === state.animations
|
|
2539
|
+
) return previous;
|
|
2540
|
+
return {
|
|
2541
|
+
kind: 'turn',
|
|
2542
|
+
turn: state.turn,
|
|
2543
|
+
key: KIND,
|
|
2544
|
+
value: {
|
|
2545
|
+
stills: state.stills,
|
|
2546
|
+
storyboards: state.storyboards,
|
|
2547
|
+
animations: state.animations,
|
|
2548
|
+
},
|
|
2549
|
+
};
|
|
2550
|
+
},
|
|
2551
|
+
};
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* Rebuild a storyboard card model from the Tool result's structured `meta`.
|
|
2555
|
+
*
|
|
2556
|
+
* The Host echoes the validated draft as `meta`, so this path uses the stored
|
|
2557
|
+
* fields instead of re-parsing the rendered prose the way the Turn fold does.
|
|
2558
|
+
*/
|
|
2559
|
+
function boardFromMeta(meta) {
|
|
2560
|
+
if (!meta || !Array.isArray(meta.shots)) return null;
|
|
2561
|
+
const shots = meta.shots;
|
|
2562
|
+
return {
|
|
2563
|
+
id: String(meta.id || ''),
|
|
2564
|
+
title: String(meta.title || ''),
|
|
2565
|
+
styleId: String(meta.styleId || ''),
|
|
2566
|
+
styleName: String(meta.styleName || ''),
|
|
2567
|
+
duration: Number(meta.duration) || 0,
|
|
2568
|
+
aspect: String(meta.aspect || ''),
|
|
2569
|
+
music: String(meta.music || ''),
|
|
2570
|
+
cuts: shots
|
|
2571
|
+
.map((s) => Number(s.start))
|
|
2572
|
+
.filter((t) => Number.isFinite(t) && t > 0.05)
|
|
2573
|
+
.sort((a, b) => a - b),
|
|
2574
|
+
shots: shots.map((s, i) => ({
|
|
2575
|
+
index: Number(s.index) || i + 1,
|
|
2576
|
+
start: Number(s.start) || 0,
|
|
2577
|
+
end: Number(s.end) || 0,
|
|
2578
|
+
scene: String(s.scene || ''),
|
|
2579
|
+
text: String(s.text || ''),
|
|
2580
|
+
camera: String(s.camera || ''),
|
|
2581
|
+
})),
|
|
2582
|
+
warnings: Array.isArray(meta.warnings) ? meta.warnings : [],
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* Inline Tool views for the two cards the flow depends on.
|
|
2588
|
+
*
|
|
2589
|
+
* The Turn-level `turnTail` only publishes on `turn/end`, so during the Turn —
|
|
2590
|
+
* exactly when the user is being asked to choose a style or approve a draft —
|
|
2591
|
+
* those cards are not mounted yet. These views render the same content in the
|
|
2592
|
+
* Tool row that produced it, which exists as soon as its result lands.
|
|
2593
|
+
*/
|
|
2594
|
+
function ShowStillsRow(props) {
|
|
2595
|
+
const { block } = props || {};
|
|
2596
|
+
const meta = block && block.meta;
|
|
2597
|
+
const shown = meta && Array.isArray(meta.shown) ? meta.shown : null;
|
|
2598
|
+
if (!shown || !shown.length) return null;
|
|
2599
|
+
return h('div', { className: 'cas-tail cas-inline' },
|
|
2600
|
+
h('style', null, CSS),
|
|
2601
|
+
h(StillGallery, {
|
|
2602
|
+
// The Host names the field `description`; the gallery renders `desc`.
|
|
2603
|
+
stills: shown.map((item) => ({
|
|
2604
|
+
id: String(item.id || ''),
|
|
2605
|
+
name: String(item.name || ''),
|
|
2606
|
+
desc: String(item.description || ''),
|
|
2607
|
+
})),
|
|
2608
|
+
note: String(meta.note || ''),
|
|
2609
|
+
missing: Array.isArray(meta.missing) ? meta.missing : [],
|
|
2610
|
+
}),
|
|
2611
|
+
);
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function StoryboardRow(props) {
|
|
2615
|
+
const { block } = props || {};
|
|
2616
|
+
const board = boardFromMeta(block && block.meta);
|
|
2617
|
+
if (!board) return null;
|
|
2618
|
+
return h('div', { className: 'cas-tail cas-inline' },
|
|
2619
|
+
h('style', null, CSS),
|
|
2620
|
+
h(StoryboardCard, { board }),
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
return {
|
|
2625
|
+
inject: ['slots', 'uiConversation'],
|
|
2626
|
+
apply(ctx) {
|
|
2627
|
+
ctx.effect(() => ctx.uiConversation.events.register(creationTurnDefinition), 'code-animation-studio:turn-data');
|
|
2628
|
+
|
|
2629
|
+
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
|
2630
|
+
name: 'conversation.chat.turnTail',
|
|
2631
|
+
id: 'code-animation-studio',
|
|
2632
|
+
order: 30,
|
|
2633
|
+
label: '代码动画',
|
|
2634
|
+
}, (props) => h(CreationTail, { ...props, uiWorkspace: ctx.get('uiWorkspace') })));
|
|
2635
|
+
|
|
2636
|
+
// One view per producing Tool, keyed by the wire Tool name.
|
|
2637
|
+
ctx.slots.inject('tool.call.toolview', function* () {
|
|
2638
|
+
yield ctx.slots.register({
|
|
2639
|
+
name: 'tool.call.toolview',
|
|
2640
|
+
key: 'show_stills',
|
|
2641
|
+
}, ShowStillsRow);
|
|
2642
|
+
yield ctx.slots.register({
|
|
2643
|
+
name: 'tool.call.toolview',
|
|
2644
|
+
key: 'save_storyboard',
|
|
2645
|
+
}, StoryboardRow);
|
|
2646
|
+
});
|
|
2647
|
+
},
|
|
2648
|
+
};
|
|
2649
|
+
},
|
|
2650
|
+
});
|