Ethscriptions commited on
Commit
d45e47b
·
verified ·
1 Parent(s): a57fce2

solana-clock: 动画引擎 + 15 个场景 + 播放器

Browse files
.gitattributes CHANGED
@@ -6,3 +6,6 @@ ethereum/covers/cover-portrait.png filter=lfs diff=lfs merge=lfs -text
6
  ethereum/fonts/deyi.woff2 filter=lfs diff=lfs merge=lfs -text
7
  guozu/covers/cover-landscape.png filter=lfs diff=lfs merge=lfs -text
8
  guozu/covers/cover-portrait.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
6
  ethereum/fonts/deyi.woff2 filter=lfs diff=lfs merge=lfs -text
7
  guozu/covers/cover-landscape.png filter=lfs diff=lfs merge=lfs -text
8
  guozu/covers/cover-portrait.png filter=lfs diff=lfs merge=lfs -text
9
+ solana-clock/fonts/sol-sc-900.woff2 filter=lfs diff=lfs merge=lfs -text
10
+ solana-clock/fonts/sol-sc-700.woff2 filter=lfs diff=lfs merge=lfs -text
11
+ solana-clock/fonts/sol-sc-400.woff2 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -21,8 +21,8 @@ pinned: false
21
  |---|------|------|
22
  | 1 | 写计划 / 大纲 / 步骤(本节) | ✅ 完成 |
23
  | 2 | 旁白文案定稿(15 场景 / 72 句)+ TTS 配音(纯语音 370 秒)+ 时长清单 + SOL 真实价格数据(2372 个日线点) | ✅ 完成 |
24
- | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局 + 烧录字幕 + 安全区) | ⏳ 进行中 |
25
- | 4 | 程序化音乐 + 合成音效 + 旁白闪避混音 | ⬜ 待做 |
26
  | 5 | 播放器 UI + 浏览器本地 MP4 导出(24 / 30 / 60 fps,横屏 / 竖屏) | ⬜ 待做 |
27
  | 6 | 抖音 / B站 发布标题、简介、标签(一键复制) | ⬜ 待做 |
28
  | 7 | 横屏 1920×1080 / 竖屏 1080×1920 封面图 | ⬜ 待做 |
 
21
  |---|------|------|
22
  | 1 | 写计划 / 大纲 / 步骤(本节) | ✅ 完成 |
23
  | 2 | 旁白文案定稿(15 场景 / 72 句)+ TTS 配音(纯语音 370 秒)+ 时长清单 + SOL 真实价格数据(2372 个日线点) | ✅ 完成 |
24
+ | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局 + 烧录字幕 + 安全区)+ 播放器页面 + 字体子集;无头浏览器 90 帧截图 + 逐 0.5 秒排版检查通过 | ✅ 完成 |
25
+ | 4 | 程序化音乐 + 合成音效 + 旁白闪避混音 | ⏳ 进行中 |
26
  | 5 | 播放器 UI + 浏览器本地 MP4 导出(24 / 30 / 60 fps,横屏 / 竖屏) | ⬜ 待做 |
27
  | 6 | 抖音 / B站 发布标题、简介、标签(一键复制) | ⬜ 待做 |
28
  | 7 | 横屏 1920×1080 / 竖屏 1080×1920 封面图 | ⬜ 待做 |
solana-clock/css/app.css ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @font-face { font-family: "SolSC"; src: url(../fonts/sol-sc-400.woff2) format("woff2"); font-weight: 100 500; font-display: swap; }
2
+ @font-face { font-family: "SolSC"; src: url(../fonts/sol-sc-700.woff2) format("woff2"); font-weight: 600 800; font-display: swap; }
3
+ @font-face { font-family: "SolSC"; src: url(../fonts/sol-sc-900.woff2) format("woff2"); font-weight: 850 1000; font-display: swap; }
4
+ @font-face { font-family: "SolGrotesk"; src: url(../fonts/sol-grotesk.woff2) format("woff2"); font-weight: 300 700; font-display: swap; }
5
+ @font-face { font-family: "SolMono"; src: url(../fonts/sol-mono.woff2) format("woff2"); font-weight: 100 800; font-display: swap; }
6
+
7
+ :root {
8
+ --bg: #07050d; --panel: #110d1c; --panel2: #171226; --line: rgba(255,255,255,.08);
9
+ --fg: #f4f2ff; --muted: #9a93b8; --purple: #9945FF; --green: #14F195; --pink: #DC1FFF;
10
+ --grad: linear-gradient(90deg, #9945FF, #14F195);
11
+ }
12
+ * { box-sizing: border-box; }
13
+ html, body { margin: 0; background: var(--bg); color: var(--fg); font-family: "SolSC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
14
+ body { min-height: 100vh; background: radial-gradient(1200px 600px at 10% -10%, rgba(153,69,255,.18), transparent 60%), radial-gradient(900px 500px at 110% 10%, rgba(20,241,149,.10), transparent 60%), var(--bg); }
15
+ button { font: inherit; color: inherit; cursor: pointer; }
16
+ .app { max-width: 1600px; margin: 0 auto; padding: 18px 22px 30px; }
17
+
18
+ .top { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
19
+ .brand { display: flex; gap: 14px; align-items: center; }
20
+ .brand h1 { margin: 0; font-size: 22px; font-weight: 900; letter-spacing: .5px; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
21
+ .brand p { margin: 3px 0 0; color: var(--muted); font-size: 13px; }
22
+ .logo { width: 34px; height: 30px; display: grid; gap: 4px; transform: skewX(-18deg); }
23
+ .logo i { display: block; border-radius: 2px; background: var(--grad); }
24
+ .back { color: var(--muted); text-decoration: none; font-size: 13px; white-space: nowrap; border: 1px solid var(--line); padding: 7px 12px; border-radius: 999px; }
25
+ .back:hover { color: var(--fg); border-color: rgba(255,255,255,.25); }
26
+
27
+ .layout { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 18px; align-items: start; }
28
+ @media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } }
29
+
30
+ .stage-wrap { display: flex; justify-content: center; align-items: center; background: #000; border-radius: 16px; border: 1px solid var(--line); overflow: hidden; box-shadow: 0 30px 80px -30px rgba(153,69,255,.45); }
31
+ .stage { position: relative; width: 100%; aspect-ratio: 16 / 9; }
32
+ .stage-wrap.portrait { background: repeating-linear-gradient(45deg, #0a0812 0 12px, #0d0a17 12px 24px); }
33
+ .stage-wrap.portrait .stage { width: auto; height: min(78vh, 980px); aspect-ratio: 9 / 16; }
34
+ .stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
35
+ .stage-wrap:fullscreen { border-radius: 0; border: 0; height: 100vh; }
36
+ .stage-wrap:fullscreen .stage { width: min(100vw, 100vh * 16 / 9); height: auto; }
37
+ .stage-wrap.portrait:fullscreen .stage { height: 100vh; width: auto; }
38
+
39
+ .big-play { position: absolute; inset: 0; margin: auto; width: max-content; height: max-content; display: flex; align-items: center; gap: 12px; padding: 16px 26px 16px 18px; border-radius: 999px; border: 1px solid rgba(255,255,255,.25); background: rgba(10,6,20,.6); backdrop-filter: blur(10px); font-size: 18px; font-weight: 700; transition: transform .2s, opacity .3s; }
40
+ .big-play svg { width: 40px; height: 40px; fill: #fff; background: var(--grad); border-radius: 50%; padding: 8px; }
41
+ .big-play:hover { transform: scale(1.05); }
42
+ .big-play.hide { opacity: 0; pointer-events: none; }
43
+ .loading { position: absolute; left: 50%; bottom: 14%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; font-size: 13px; color: var(--muted); background: rgba(0,0,0,.5); padding: 8px 14px; border-radius: 999px; }
44
+ .loading.hide { display: none; }
45
+ .spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(255,255,255,.2); border-top-color: var(--green); animation: sp 1s linear infinite; }
46
+ @keyframes sp { to { transform: rotate(360deg); } }
47
+
48
+ .controls { display: flex; align-items: center; gap: 12px; margin-top: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 8px 12px; }
49
+ .ctl { width: 38px; height: 38px; border: 0; border-radius: 10px; background: transparent; display: grid; place-items: center; flex: none; }
50
+ .ctl:hover { background: rgba(255,255,255,.07); }
51
+ .ctl svg { width: 24px; height: 24px; fill: currentColor; }
52
+ .time { font-family: "SolMono", ui-monospace, monospace; font-size: 13px; color: var(--muted); white-space: nowrap; min-width: 92px; }
53
+ .time .sep { margin: 0 4px; opacity: .5; }
54
+ .scrub { position: relative; flex: 1; height: 34px; cursor: pointer; touch-action: none; }
55
+ .scrub .track { position: absolute; left: 0; right: 0; top: 15px; height: 4px; border-radius: 4px; background: rgba(255,255,255,.12); overflow: hidden; }
56
+ .scrub .fill { height: 100%; width: 0; background: var(--grad); }
57
+ .scrub:hover .track { height: 6px; top: 14px; }
58
+ .scrub .marks i { position: absolute; top: 12px; width: 2px; height: 10px; background: rgba(255,255,255,.35); border-radius: 1px; }
59
+ .scrub .knob { position: absolute; top: 10px; width: 14px; height: 14px; margin-left: -7px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 4px rgba(20,241,149,.25); left: 0; }
60
+ .scrub .tip { position: absolute; bottom: 34px; transform: translateX(-50%); white-space: nowrap; font-size: 12px; background: #000; border: 1px solid var(--line); padding: 4px 8px; border-radius: 6px; opacity: 0; pointer-events: none; transition: opacity .15s; }
61
+ .scrub:hover .tip { opacity: 1; }
62
+
63
+ .toggles { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 12px; }
64
+ .seg { display: inline-flex; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 4px; gap: 4px; }
65
+ .seg button { border: 0; background: transparent; padding: 8px 14px; border-radius: 9px; color: var(--muted); font-size: 14px; line-height: 1.25; }
66
+ .seg button.on { background: linear-gradient(135deg, rgba(153,69,255,.35), rgba(20,241,149,.22)); color: var(--fg); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12); }
67
+ .seg.small { display: flex; width: 100%; }
68
+ .seg.small button { flex: 1; font-size: 13px; padding: 8px 6px; }
69
+ .seg em { font-style: normal; font-size: 11px; opacity: .7; }
70
+ .chk { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; color: var(--muted); cursor: pointer; user-select: none; }
71
+ .chk input { accent-color: var(--green); width: 16px; height: 16px; }
72
+ .hint { font-size: 12px; color: #5f5878; margin-left: auto; }
73
+ @media (max-width: 700px) { .hint { display: none; } }
74
+
75
+ .side { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; position: sticky; top: 14px; max-height: calc(100vh - 28px); display: flex; flex-direction: column; }
76
+ .tabs { display: flex; border-bottom: 1px solid var(--line); flex: none; }
77
+ .tabs button { flex: 1; border: 0; background: transparent; padding: 13px 4px; color: var(--muted); font-size: 14px; font-weight: 700; border-bottom: 2px solid transparent; }
78
+ .tabs button.on { color: var(--fg); border-bottom-color: var(--green); }
79
+ .panel { display: none; padding: 16px; overflow: auto; }
80
+ .panel.on { display: block; }
81
+ .panel h3 { margin: 0 0 6px; font-size: 16px; }
82
+ .muted { color: var(--muted); font-size: 13px; line-height: 1.6; margin: 0 0 12px; }
83
+ .muted.small { font-size: 12px; margin-top: 10px; }
84
+
85
+ .chapters { list-style: none; margin: 0; padding: 0; counter-reset: c; }
86
+ .chapters li { display: flex; gap: 10px; align-items: center; padding: 9px 10px; border-radius: 10px; cursor: pointer; transition: background .15s; }
87
+ .chapters li:hover { background: rgba(255,255,255,.05); }
88
+ .chapters li.on { background: linear-gradient(90deg, rgba(153,69,255,.25), rgba(20,241,149,.08)); }
89
+ .chapters .n { font-family: "SolMono", monospace; font-size: 11px; color: var(--muted); width: 22px; }
90
+ .chapters .ttl { flex: 1; font-size: 14px; font-weight: 700; }
91
+ .chapters .yr { font-size: 11px; color: var(--green); font-family: "SolMono", monospace; margin-left: 6px; font-weight: 400; }
92
+ .chapters .ts { font-family: "SolMono", monospace; font-size: 12px; color: var(--muted); }
93
+ .chapters .pbar { height: 2px; background: var(--grad); width: 0; border-radius: 2px; margin-top: 4px; }
94
+
95
+ .field { margin-bottom: 12px; }
96
+ .field > span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
97
+ .exp-actions { display: flex; gap: 10px; margin-top: 14px; }
98
+ .primary { flex: 1; border: 0; border-radius: 12px; padding: 12px; font-weight: 900; font-size: 15px; color: #08050f; background: var(--grad); box-shadow: 0 10px 30px -10px rgba(20,241,149,.6); }
99
+ .primary:disabled { opacity: .5; cursor: wait; }
100
+ .ghost { border: 1px solid rgba(255,255,255,.18); background: transparent; border-radius: 10px; padding: 10px 14px; color: var(--fg); }
101
+ .ghost:hover { background: rgba(255,255,255,.06); }
102
+ .ghost.small { padding: 4px 10px; font-size: 12px; margin-left: 8px; border-radius: 8px; }
103
+ .progress { margin-top: 14px; }
104
+ .progress .bar { height: 8px; background: rgba(255,255,255,.08); border-radius: 8px; overflow: hidden; }
105
+ .progress .bar div { height: 100%; width: 0; background: var(--grad); transition: width .2s; }
106
+ .ptext { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-top: 6px; font-family: "SolMono", monospace; }
107
+ .exp-preview { width: 100%; margin-top: 10px; border-radius: 8px; border: 1px solid var(--line); background: #000; }
108
+ .result { margin-top: 14px; padding: 12px; border-radius: 12px; background: rgba(20,241,149,.08); border: 1px solid rgba(20,241,149,.3); font-size: 13px; line-height: 1.6; }
109
+ .result a { color: var(--green); font-weight: 700; }
110
+ .result.err { background: rgba(255,59,92,.08); border-color: rgba(255,59,92,.35); }
111
+
112
+ .pub-block { margin-top: 14px; }
113
+ .pub-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
114
+ .pub-head b { color: var(--fg); font-size: 13px; }
115
+ .pub-text { white-space: pre-wrap; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 13px; line-height: 1.65; max-height: 260px; overflow: auto; }
116
+ .pub-alt { display: flex; flex-direction: column; gap: 6px; }
117
+ .pub-alt .row { display: flex; gap: 8px; align-items: flex-start; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-size: 13px; line-height: 1.5; }
118
+ .pub-alt .row span { flex: 1; }
119
+ .tags { display: flex; flex-wrap: wrap; gap: 6px; }
120
+ .tags span { font-size: 12px; padding: 3px 9px; border-radius: 999px; background: rgba(153,69,255,.18); border: 1px solid rgba(153,69,255,.35); }
121
+ .copy { flex: none; border: 1px solid rgba(255,255,255,.18); background: transparent; border-radius: 8px; padding: 3px 10px; font-size: 12px; color: var(--fg); }
122
+ .copy:hover { background: rgba(20,241,149,.12); border-color: rgba(20,241,149,.5); }
123
+ .count { font-family: "SolMono", monospace; font-size: 11px; color: var(--muted); }
124
+ .count.over { color: #ff6b81; }
125
+
126
+ .covers { display: grid; gap: 14px; }
127
+ .covers figure { margin: 0; }
128
+ .covers canvas { width: 100%; border-radius: 10px; border: 1px solid var(--line); display: block; background: #000; }
129
+ .covers .cover-p { width: 56%; margin: 0 auto; }
130
+ .covers figcaption { font-size: 12px; color: var(--muted); margin-top: 6px; display: flex; align-items: center; justify-content: center; }
131
+
132
+ .foot { margin-top: 22px; font-size: 12px; color: #5f5878; text-align: center; line-height: 1.6; }
133
+ .toast { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%) translateY(20px); background: #111; border: 1px solid rgba(20,241,149,.4); color: var(--fg); padding: 10px 18px; border-radius: 999px; font-size: 14px; opacity: 0; pointer-events: none; transition: all .25s; z-index: 50; }
134
+ .toast.on { opacity: 1; transform: translateX(-50%); }
solana-clock/fonts/sol-grotesk.woff2 ADDED
Binary file (19.4 kB). View file
 
solana-clock/fonts/sol-mono.woff2 ADDED
Binary file (34.5 kB). View file
 
solana-clock/fonts/sol-sc-400.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:710128990e82a6e9b800590829c4aafc04858adb4e5e7d2c759c52d38b5dca93
3
+ size 129180
solana-clock/fonts/sol-sc-700.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7187fcdff06944959bf4237af6c05857fcfee7e2347acf8a90bd6189a7e2c8ef
3
+ size 129048
solana-clock/fonts/sol-sc-900.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d88e49addb97e22470ed9f8b25b2fa7d3c574f3765eeba7fd3c34f679586750
3
+ size 127684
solana-clock/index.html ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <title>SOLANA:给区块链装上时钟的人 · 动画史</title>
7
+ <meta name="description" content="Solana 从 2017 年一个深夜的灵感,到宕机、FTX 暴雷、Meme 狂欢与 ETF 的完整动画史。网页实时播放,可本地导出 B 站横屏 / 抖音竖屏 MP4。">
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='30' fill='%2309060f' stroke='%2314F195' stroke-width='4'/%3E%3Cpath d='M32 12v20l12 8' stroke='%239945FF' stroke-width='6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
9
+ <link rel="stylesheet" href="css/app.css">
10
+ </head>
11
+ <body>
12
+ <div class="app">
13
+ <header class="top">
14
+ <div class="brand">
15
+ <span class="logo" aria-hidden="true"><i></i><i></i><i></i></span>
16
+ <div>
17
+ <h1>SOLANA:给区块链装上时钟的人</h1>
18
+ <p>一部约 6 分半钟的动画史 · 旁白 / 配乐 / 音效 · 全部在你的浏览器里实时生成</p>
19
+ </div>
20
+ </div>
21
+ <a class="back" href="../index.html">← 返回动画工作室</a>
22
+ </header>
23
+
24
+ <main class="layout">
25
+ <section class="stage-col">
26
+ <div class="stage-wrap" id="stageWrap">
27
+ <div class="stage" id="stage">
28
+ <canvas id="view"></canvas>
29
+ <button class="big-play" id="bigPlay" aria-label="播放">
30
+ <svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
31
+ <span id="bigPlayText">点击播放(含声音)</span>
32
+ </button>
33
+ <div class="loading" id="loading"><div class="spin"></div><span id="loadingText">正在准备字体与画面…</span></div>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="controls">
38
+ <button class="ctl" id="btnPlay" title="播放 / 暂停(空格)"><svg viewBox="0 0 24 24" id="icoPlay"><path d="M8 5v14l11-7z"/></svg></button>
39
+ <div class="time"><span id="tCur">0:00</span><span class="sep">/</span><span id="tTot">0:00</span></div>
40
+ <div class="scrub" id="scrub">
41
+ <div class="track"><div class="fill" id="scrubFill"></div></div>
42
+ <div class="marks" id="scrubMarks"></div>
43
+ <div class="knob" id="scrubKnob"></div>
44
+ <div class="tip" id="scrubTip"></div>
45
+ </div>
46
+ <button class="ctl" id="btnMute" title="静音(M)"><svg viewBox="0 0 24 24" id="icoVol"><path d="M4 9v6h4l5 5V4L8 9H4zm12.5 3A4.5 4.5 0 0 0 14 8v8a4.5 4.5 0 0 0 2.5-4z"/></svg></button>
47
+ <button class="ctl" id="btnFull" title="全屏(F)"><svg viewBox="0 0 24 24"><path d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zM5 14h2v3h3v2H5v-5zm12 3v-3h2v5h-5v-2h3z"/></svg></button>
48
+ </div>
49
+
50
+ <div class="toggles">
51
+ <div class="seg" id="segOrient" role="tablist" aria-label="画面方向">
52
+ <button data-v="landscape" class="on">▭ 横屏 16:9 · B站</button>
53
+ <button data-v="portrait">▯ 竖屏 9:16 · 抖音</button>
54
+ </div>
55
+ <label class="chk"><input type="checkbox" id="chkSubs" checked> 字幕</label>
56
+ <label class="chk" id="safeWrap"><input type="checkbox" id="chkSafe"> 显示抖音安全区</label>
57
+ <span class="hint">快捷键:空格 播放 · ←/→ 跳 5 秒 · [ / ] 切章节</span>
58
+ </div>
59
+ </section>
60
+
61
+ <aside class="side">
62
+ <nav class="tabs" id="tabs">
63
+ <button data-tab="chapters" class="on">章节</button>
64
+ <button data-tab="export">导出 MP4</button>
65
+ <button data-tab="publish">发布文案</button>
66
+ <button data-tab="cover">封面</button>
67
+ </nav>
68
+
69
+ <div class="panel on" data-panel="chapters">
70
+ <ol class="chapters" id="chapters"></ol>
71
+ </div>
72
+
73
+ <div class="panel" data-panel="export">
74
+ <h3>在浏览器里直接导出视频</h3>
75
+ <p class="muted">画面逐帧离线渲染,声音一次性合成,全程不上传任何数据。导出时长取决于电脑性能,通常是视频时长的 0.5–3 倍。</p>
76
+ <div class="field">
77
+ <span>画面</span>
78
+ <div class="seg small" id="segExpOrient">
79
+ <button data-v="landscape" class="on">横屏 1920×1080<br><em>B 站</em></button>
80
+ <button data-v="portrait">竖屏 1080×1920<br><em>抖音(已留安全区)</em></button>
81
+ </div>
82
+ </div>
83
+ <div class="field">
84
+ <span>帧率</span>
85
+ <div class="seg small" id="segFps">
86
+ <button data-v="24">24 fps<br><em>电影感</em></button>
87
+ <button data-v="30" class="on">30 fps<br><em>推荐</em></button>
88
+ <button data-v="60">60 fps<br><em>最丝滑</em></button>
89
+ </div>
90
+ </div>
91
+ <div class="field">
92
+ <span>清晰度</span>
93
+ <div class="seg small" id="segQ">
94
+ <button data-v="std">标准<br><em>体积小</em></button>
95
+ <button data-v="high" class="on">高<br><em>推荐</em></button>
96
+ <button data-v="max">极高<br><em>体积大</em></button>
97
+ </div>
98
+ </div>
99
+ <label class="chk"><input type="checkbox" id="expSubs" checked> 烧录字幕</label>
100
+ <div class="exp-actions">
101
+ <button class="primary" id="btnExport">开始导出 MP4</button>
102
+ <button class="ghost" id="btnCancel" hidden>取消</button>
103
+ </div>
104
+ <div class="progress" id="expProg" hidden>
105
+ <div class="bar"><div id="expBar"></div></div>
106
+ <div class="ptext"><span id="expStage">准备中…</span><span id="expPct">0%</span></div>
107
+ <canvas id="expPreview" class="exp-preview"></canvas>
108
+ </div>
109
+ <div class="result" id="expResult" hidden></div>
110
+ <p class="muted small" id="expSupport"></p>
111
+ </div>
112
+
113
+ <div class="panel" data-panel="publish">
114
+ <div class="seg small" id="segPlat">
115
+ <button data-v="douyin" class="on">抖音</button>
116
+ <button data-v="bilibili">B 站</button>
117
+ </div>
118
+ <div id="publishBox"></div>
119
+ </div>
120
+
121
+ <div class="panel" data-panel="cover">
122
+ <p class="muted">封面由同一套画面引擎实时绘制,可直接下载 PNG。</p>
123
+ <div class="covers">
124
+ <figure>
125
+ <canvas id="coverL" class="cover-l"></canvas>
126
+ <figcaption>横屏封面 1920×1080(B 站)<button class="ghost small" data-dl="landscape">下载 PNG</button></figcaption>
127
+ </figure>
128
+ <figure>
129
+ <canvas id="coverP" class="cover-p"></canvas>
130
+ <figcaption>竖屏封面 1080×1920(抖音)<button class="ghost small" data-dl="portrait">下载 PNG</button></figcaption>
131
+ </figure>
132
+ </div>
133
+ </div>
134
+ </aside>
135
+ </main>
136
+
137
+ <footer class="foot">
138
+ 价格数据来自公开行情的每日收盘价快照;画面中标注“示意”的内容为演示性可视化,不代表真实链上数据。本片不构成任何投资建议。
139
+ </footer>
140
+ </div>
141
+ <div class="toast" id="toast"></div>
142
+ <script type="module" src="js/main.js"></script>
143
+ </body>
144
+ </html>
solana-clock/js/chart.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Real SOL price chart renderer (data snapshot from Surf API)
2
+ import { SOL_PRICES, SOL_T0 } from './data-sol.js';
3
+ import { clamp, lerp, glow, text, hexA, fmtUSD, F_MONO, GREEN, PURPLE, RED, WHITE } from './draw.js';
4
+
5
+ export const idx = (date) => clamp(Math.round((Date.parse(date + 'T00:00:00Z') / 1000 - SOL_T0) / 86400), 0, SOL_PRICES.length - 1);
6
+ export const price = (i) => SOL_PRICES[clamp(Math.round(i), 0, SOL_PRICES.length - 1)];
7
+ export const dateOf = (i) => new Date((SOL_T0 + Math.round(i) * 86400) * 1000).toISOString().slice(0, 10);
8
+ export const LAST = SOL_PRICES.length - 1;
9
+
10
+ /** interpolated price at fractional index */
11
+ export function priceF(f) {
12
+ const i = Math.floor(f), k = f - i;
13
+ return lerp(price(i), price(i + 1), k);
14
+ }
15
+
16
+ /**
17
+ * Draw a chart. rect {x,y,w,h}. from/to: day indices. prog: 0..1 drawn fraction.
18
+ * Returns head {x,y,v,i,slope}.
19
+ */
20
+ export function drawChart(ctx, rect, o) {
21
+ const { from, to, prog = 1, log = false, color = GREEN, color2 = PURPLE, alpha = 1, grid = true, yMin, yMax, lw = 5, fill = true, head = true, headLabel = true, dates = true, gridVals } = o;
22
+ const n = to - from;
23
+ let lo = Infinity, hi = -Infinity;
24
+ for (let i = from; i <= to; i++) { const v = price(i); if (v < lo) lo = v; if (v > hi) hi = v; }
25
+ lo = yMin ?? lo * (log ? .8 : .9); hi = yMax ?? hi * 1.08;
26
+ if (!log) lo = yMin ?? Math.max(0, lo - (hi - lo) * .05);
27
+ const Y = v => log ? rect.y + rect.h - (Math.log(v) - Math.log(lo)) / (Math.log(hi) - Math.log(lo)) * rect.h : rect.y + rect.h - (v - lo) / (hi - lo) * rect.h;
28
+ const X = i => rect.x + (i - from) / n * rect.w;
29
+ ctx.save(); ctx.globalAlpha *= alpha;
30
+ if (grid) {
31
+ const vals = gridVals || (log ? [1, 10, 100] : niceTicks(lo, hi));
32
+ ctx.lineWidth = 1;
33
+ for (const v of vals) {
34
+ if (v < lo || v > hi) continue;
35
+ const y = Y(v);
36
+ ctx.strokeStyle = 'rgba(255,255,255,.09)'; ctx.setLineDash([6, 8]); ctx.beginPath(); ctx.moveTo(rect.x, y); ctx.lineTo(rect.x + rect.w, y); ctx.stroke(); ctx.setLineDash([]);
37
+ text(ctx, fmtUSD(v), rect.x - 12, y, { size: 20, fam: F_MONO, weight: 500, color: 'rgba(255,255,255,.45)', align: 'right' });
38
+ }
39
+ if (dates) {
40
+ const y0 = new Date((SOL_T0 + from * 86400) * 1000).getUTCFullYear(), y1 = new Date((SOL_T0 + to * 86400) * 1000).getUTCFullYear();
41
+ for (let yr = y0; yr <= y1 + 1; yr++) {
42
+ const i = idx(yr + '-01-01'); if (i <= from || i >= to) continue;
43
+ const x = X(i);
44
+ ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.beginPath(); ctx.moveTo(x, rect.y); ctx.lineTo(x, rect.y + rect.h); ctx.stroke();
45
+ text(ctx, String(yr), x, rect.y + rect.h + 22, { size: 20, fam: F_MONO, weight: 600, color: 'rgba(255,255,255,.5)', align: 'center' });
46
+ }
47
+ }
48
+ }
49
+ const endF = from + n * clamp(prog);
50
+ if (endF > from) {
51
+ const step = Math.max(1, Math.floor(n / 900));
52
+ ctx.beginPath();
53
+ let first = true, lx = 0, ly = 0;
54
+ for (let i = from; i <= Math.floor(endF); i += step) { const x = X(i), y = Y(price(i)); if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y); lx = x; ly = y; }
55
+ const hv = priceF(endF), hx = X(endF), hy = Y(hv); ctx.lineTo(hx, hy);
56
+ const g = ctx.createLinearGradient(rect.x, 0, rect.x + rect.w, 0); g.addColorStop(0, color2); g.addColorStop(1, color);
57
+ // fill
58
+ if (fill) {
59
+ ctx.save(); ctx.lineTo(hx, rect.y + rect.h); ctx.lineTo(X(from), rect.y + rect.h); ctx.closePath();
60
+ const fg = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h); fg.addColorStop(0, hexA(color, .28)); fg.addColorStop(1, hexA(color, 0));
61
+ ctx.fillStyle = fg; ctx.fill(); ctx.restore();
62
+ // re-trace stroke path
63
+ ctx.beginPath(); first = true;
64
+ for (let i = from; i <= Math.floor(endF); i += step) { const x = X(i), y = Y(price(i)); if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y); }
65
+ ctx.lineTo(hx, hy);
66
+ }
67
+ ctx.lineJoin = 'round'; ctx.lineCap = 'round';
68
+ ctx.strokeStyle = hexA(color, .25); ctx.lineWidth = lw * 3.2; ctx.stroke();
69
+ ctx.strokeStyle = g; ctx.lineWidth = lw; ctx.stroke();
70
+ void lx; void ly;
71
+ const sl = (Y(priceF(Math.min(endF + 3, to))) - Y(priceF(Math.max(endF - 3, from)))) / (X(Math.min(endF + 3, to)) - X(Math.max(endF - 3, from)) || 1);
72
+ if (head && prog > 0) {
73
+ glow(ctx, hx, hy, 60, color, .9);
74
+ ctx.fillStyle = WHITE; ctx.beginPath(); ctx.arc(hx, hy, 8, 0, 7); ctx.fill();
75
+ if (headLabel) {
76
+ const right = hx < rect.x + rect.w * .75;
77
+ text(ctx, fmtUSD(hv), hx + (right ? 22 : -22), hy - 34, { size: 34, fam: F_MONO, weight: 800, color: WHITE, align: right ? 'left' : 'right', stroke: 6, strokeColor: 'rgba(0,0,0,.7)' });
78
+ text(ctx, dateOf(endF), hx + (right ? 22 : -22), hy - 2, { size: 20, fam: F_MONO, weight: 500, color: 'rgba(255,255,255,.6)', align: right ? 'left' : 'right' });
79
+ }
80
+ }
81
+ ctx.restore();
82
+ return { x: hx, y: hy, v: hv, i: endF, slope: sl, X, Y };
83
+ }
84
+ ctx.restore();
85
+ return { x: X(from), y: Y(price(from)), v: price(from), i: from, slope: 0, X, Y };
86
+ }
87
+
88
+ function niceTicks(lo, hi) {
89
+ const span = hi - lo, raw = span / 4, mag = Math.pow(10, Math.floor(Math.log10(raw)));
90
+ const step = [1, 2, 2.5, 5, 10].map(k => k * mag).find(s => span / s <= 5);
91
+ const out = []; for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) out.push(+v.toFixed(4));
92
+ return out;
93
+ }
94
+
95
+ export const RED_C = RED;
solana-clock/js/draw.js ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Drawing toolkit — everything here is stateless so any frame can be rendered at any time.
2
+ export const PURPLE = '#9945FF', GREEN = '#14F195', TEAL = '#00D1FF', PINK = '#DC1FFF', RED = '#FF3B5C', GOLD = '#FFC94A', WHITE = '#F4F2FF';
3
+ export const F_SC = '"SolSC","PingFang SC","Microsoft YaHei","Noto Sans CJK SC",sans-serif';
4
+ export const F_GR = '"SolGrotesk","SolSC","Helvetica Neue",Arial,sans-serif';
5
+ export const F_MONO = '"SolMono","SFMono-Regular",Menlo,Consolas,monospace';
6
+
7
+ export const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
8
+ export const lerp = (a, b, t) => a + (b - a) * t;
9
+ export const inv = (a, b, v) => clamp((v - a) / (b - a));
10
+ export const eOut = t => 1 - Math.pow(1 - clamp(t), 3);
11
+ export const eIn = t => Math.pow(clamp(t), 3);
12
+ export const eIO = t => { t = clamp(t); return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
13
+ export const eBack = t => { t = clamp(t); const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); };
14
+ export const eElastic = t => { t = clamp(t); if (t === 0 || t === 1) return t; return Math.pow(2, -10 * t) * Math.sin((t * 10 - .75) * (2 * Math.PI / 3)) + 1; };
15
+ /** eased progress of time t through window [a, a+d] */
16
+ export const win = (t, a, d = .6, ease = eOut) => ease(inv(a, a + d, t));
17
+ /** fade in at a, fade out at b */
18
+ export const life = (t, a, b, fi = .4, fo = .4) => Math.min(eOut(inv(a, a + fi, t)), 1 - eIn(inv(b - fo, b, t)));
19
+
20
+ export function rng(seed) {
21
+ let a = seed >>> 0;
22
+ return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
23
+ }
24
+ export const hash1 = (n) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
25
+ export function hexStr(seed, len = 8) { const r = rng(seed * 7919 + 13); let s = ''; for (let i = 0; i < len; i++) s += '0123456789abcdef'[Math.floor(r() * 16)]; return s; }
26
+
27
+ export function hexA(hex, a) {
28
+ const n = parseInt(hex.slice(1), 16);
29
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
30
+ }
31
+ export function mix(h1, h2, t) {
32
+ const a = parseInt(h1.slice(1), 16), b = parseInt(h2.slice(1), 16);
33
+ const r = Math.round(lerp(a >> 16 & 255, b >> 16 & 255, t)), g = Math.round(lerp(a >> 8 & 255, b >> 8 & 255, t)), bl = Math.round(lerp(a & 255, b & 255, t));
34
+ return '#' + ((1 << 24) | (r << 16) | (g << 8) | bl).toString(16).slice(1);
35
+ }
36
+
37
+ // ---------- glow sprites (cached per color) ----------
38
+ const glowCache = new Map();
39
+ function glowSprite(color) {
40
+ let c = glowCache.get(color);
41
+ if (c) return c;
42
+ c = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(128, 128) : Object.assign(document.createElement('canvas'), { width: 128, height: 128 });
43
+ const g = c.getContext('2d');
44
+ const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64);
45
+ gr.addColorStop(0, hexA(color, 1)); gr.addColorStop(.18, hexA(color, .55)); gr.addColorStop(.45, hexA(color, .16)); gr.addColorStop(1, hexA(color, 0));
46
+ g.fillStyle = gr; g.fillRect(0, 0, 128, 128);
47
+ glowCache.set(color, c);
48
+ return c;
49
+ }
50
+ export function glow(ctx, x, y, r, color, a = 1) {
51
+ if (a <= 0 || r <= 0) return;
52
+ ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = Math.min(1, a);
53
+ ctx.drawImage(glowSprite(color), x - r, y - r, r * 2, r * 2);
54
+ ctx.restore();
55
+ }
56
+
57
+ // ---------- text ----------
58
+ export function font(size, weight = 700, fam = F_SC) { return `${weight} ${Math.round(size)}px ${fam}`; }
59
+ export function text(ctx, str, x, y, o = {}) {
60
+ const { size = 48, weight = 700, fam = F_SC, color = WHITE, align = 'left', base = 'middle', alpha = 1, shadow = 0, shadowColor = 'rgba(0,0,0,.6)', ls = 0, stroke = 0, strokeColor = 'rgba(0,0,0,.6)', maxW } = o;
61
+ if (alpha <= 0 || !str) return 0;
62
+ ctx.save();
63
+ ctx.globalAlpha *= alpha; ctx.font = font(size, weight, fam); ctx.textAlign = align; ctx.textBaseline = base;
64
+ if ('letterSpacing' in ctx) ctx.letterSpacing = ls + 'px';
65
+ if (maxW) { const w = ctx.measureText(str).width; if (w > maxW) { const k = maxW / w; ctx.translate(x, y); ctx.scale(k, k); x = 0; y = 0; } }
66
+ if (stroke) { ctx.lineJoin = 'round'; ctx.lineWidth = stroke; ctx.strokeStyle = strokeColor; ctx.strokeText(str, x, y); }
67
+ if (shadow) { ctx.shadowBlur = shadow; ctx.shadowColor = shadowColor; }
68
+ ctx.fillStyle = color; ctx.fillText(str, x, y);
69
+ const w = ctx.measureText(str).width;
70
+ ctx.restore();
71
+ return w;
72
+ }
73
+ export function measure(ctx, str, size, weight = 700, fam = F_SC, ls = 0) {
74
+ ctx.save(); ctx.font = font(size, weight, fam); if ('letterSpacing' in ctx) ctx.letterSpacing = ls + 'px';
75
+ const w = ctx.measureText(str).width; ctx.restore(); return w;
76
+ }
77
+ /** Solana gradient text */
78
+ export function gradText(ctx, str, x, y, o = {}) {
79
+ const { size = 80, align = 'left', c1 = PURPLE, c2 = GREEN } = o;
80
+ const w = measure(ctx, str, size, o.weight ?? 900, o.fam ?? F_SC, o.ls ?? 0);
81
+ const x0 = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
82
+ const g = ctx.createLinearGradient(x0, y - size / 2, x0 + w, y + size / 2);
83
+ g.addColorStop(0, c1); g.addColorStop(1, c2);
84
+ return text(ctx, str, x, y, { weight: 900, ...o, color: g });
85
+ }
86
+ /** Glowing text: soft colored halo + crisp core */
87
+ export function glowText(ctx, str, x, y, o = {}) {
88
+ const c = o.glowColor || PURPLE;
89
+ text(ctx, str, x, y, { ...o, shadow: o.blur ?? 30, shadowColor: c });
90
+ return text(ctx, str, x, y, o);
91
+ }
92
+
93
+ // ---------- shapes ----------
94
+ export function rr(ctx, x, y, w, h, r) {
95
+ r = Math.min(r, w / 2, h / 2);
96
+ ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
97
+ }
98
+ export function glass(ctx, x, y, w, h, o = {}) {
99
+ const { r = 22, alpha = 1, border = PURPLE, fill = 'rgba(20,16,44,.62)', bw = 2 } = o;
100
+ if (alpha <= 0) return;
101
+ ctx.save(); ctx.globalAlpha *= alpha;
102
+ rr(ctx, x, y, w, h, r); ctx.fillStyle = fill; ctx.fill();
103
+ const g = ctx.createLinearGradient(x, y, x + w, y + h); g.addColorStop(0, hexA(border, .9)); g.addColorStop(1, hexA(GREEN, .5));
104
+ ctx.lineWidth = bw; ctx.strokeStyle = g; ctx.stroke();
105
+ // top sheen
106
+ const s = ctx.createLinearGradient(0, y, 0, y + h * .5); s.addColorStop(0, 'rgba(255,255,255,.09)'); s.addColorStop(1, 'rgba(255,255,255,0)');
107
+ rr(ctx, x + 2, y + 2, w - 4, h * .5, r); ctx.fillStyle = s; ctx.fill();
108
+ ctx.restore();
109
+ }
110
+ export function pill(ctx, str, x, y, o = {}) {
111
+ const { size = 30, color = GREEN, alpha = 1, align = 'center', fg = '#0b0820', fam = F_SC } = o;
112
+ const w = measure(ctx, str, size, 800, fam) + size * 1.1, h = size * 1.7;
113
+ const x0 = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
114
+ ctx.save(); ctx.globalAlpha *= alpha; rr(ctx, x0, y - h / 2, w, h, h / 2); ctx.fillStyle = color; ctx.fill(); ctx.restore();
115
+ text(ctx, str, x0 + w / 2, y + size * .04, { size, weight: 800, color: fg, align: 'center', alpha, fam });
116
+ return w;
117
+ }
118
+
119
+ /** The Solana mark: three slanted bars. p = assemble progress 0..1 */
120
+ export function solLogo(ctx, cx, cy, size, p = 1, alpha = 1) {
121
+ if (alpha <= 0) return;
122
+ const w = size, h = size * .16, gap = size * .085, k = h * .9;
123
+ const ys = [-h * 1.5 - gap, -h / 2, h / 2 + gap];
124
+ ctx.save(); ctx.globalAlpha *= alpha; ctx.translate(cx, cy);
125
+ const g = ctx.createLinearGradient(-w / 2, size * .35, w / 2, -size * .35); g.addColorStop(0, PINK); g.addColorStop(.5, PURPLE); g.addColorStop(1, GREEN);
126
+ for (let i = 0; i < 3; i++) {
127
+ const pi = eBack(inv(i * .18, .5 + i * .18, p));
128
+ const dir = i === 1 ? 1 : -1;
129
+ const off = (1 - pi) * size * 1.6 * dir;
130
+ ctx.save(); ctx.globalAlpha *= clamp(pi * 1.4); ctx.translate(off, 0);
131
+ const y = ys[i], x0 = -w / 2;
132
+ ctx.beginPath();
133
+ if (i === 1) { ctx.moveTo(x0, y); ctx.lineTo(x0 + w - k, y); ctx.lineTo(x0 + w, y + h); ctx.lineTo(x0 + k, y + h); }
134
+ else { ctx.moveTo(x0 + k, y); ctx.lineTo(x0 + w, y); ctx.lineTo(x0 + w - k, y + h); ctx.lineTo(x0, y + h); }
135
+ ctx.closePath(); ctx.fillStyle = g; ctx.fill();
136
+ ctx.restore();
137
+ }
138
+ ctx.restore();
139
+ }
140
+
141
+ /** clock dial; hand angle in turns */
142
+ export function dial(ctx, cx, cy, r, o = {}) {
143
+ const { alpha = 1, hand = 0, ticks = 60, color = WHITE, accent = GREEN, lw = 3, minute = null } = o;
144
+ if (alpha <= 0) return;
145
+ ctx.save(); ctx.globalAlpha *= alpha; ctx.translate(cx, cy);
146
+ ctx.strokeStyle = hexA(color, .35); ctx.lineWidth = lw; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.stroke();
147
+ for (let i = 0; i < ticks; i++) {
148
+ const a = i / ticks * Math.PI * 2, big = i % 5 === 0;
149
+ ctx.strokeStyle = hexA(color, big ? .8 : .3); ctx.lineWidth = big ? lw * 1.4 : lw * .6;
150
+ ctx.beginPath(); ctx.moveTo(Math.sin(a) * r * (big ? .86 : .92), -Math.cos(a) * r * (big ? .86 : .92)); ctx.lineTo(Math.sin(a) * r * .97, -Math.cos(a) * r * .97); ctx.stroke();
151
+ }
152
+ if (minute !== null) {
153
+ const a = minute * Math.PI * 2; ctx.strokeStyle = hexA(color, .85); ctx.lineWidth = lw * 2.4; ctx.lineCap = 'round';
154
+ ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(Math.sin(a) * r * .55, -Math.cos(a) * r * .55); ctx.stroke();
155
+ }
156
+ const a = hand * Math.PI * 2;
157
+ ctx.strokeStyle = accent; ctx.lineWidth = lw * 1.3; ctx.lineCap = 'round';
158
+ ctx.beginPath(); ctx.moveTo(-Math.sin(a) * r * .12, Math.cos(a) * r * .12); ctx.lineTo(Math.sin(a) * r * .85, -Math.cos(a) * r * .85); ctx.stroke();
159
+ ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(0, 0, lw * 2.4, 0, Math.PI * 2); ctx.fill();
160
+ ctx.restore();
161
+ }
162
+
163
+ /** burst of rays/particles at time since trigger dt */
164
+ export function burst(ctx, x, y, dt, o = {}) {
165
+ const { n = 26, r = 260, color = GREEN, seed = 1, dur = 1.1, size = 6 } = o;
166
+ if (dt < 0 || dt > dur) return;
167
+ const p = dt / dur, R = rng(seed);
168
+ ctx.save(); ctx.globalCompositeOperation = 'lighter';
169
+ for (let i = 0; i < n; i++) {
170
+ const a = R() * Math.PI * 2, sp = .5 + R() * .7, d = eOut(p) * r * sp;
171
+ const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d + p * p * 60;
172
+ ctx.globalAlpha = (1 - p) * .95; ctx.fillStyle = i % 3 === 0 ? WHITE : (i % 2 ? color : PURPLE);
173
+ ctx.beginPath(); ctx.arc(px, py, size * (1 - p * .6) * (.6 + R() * .8), 0, Math.PI * 2); ctx.fill();
174
+ }
175
+ ctx.restore();
176
+ glow(ctx, x, y, r * .8 * (1 - p), color, (1 - p) * .7);
177
+ }
178
+
179
+ /** ring shockwave */
180
+ export function ring(ctx, x, y, dt, o = {}) {
181
+ const { r = 300, color = GREEN, dur = .9, lw = 6 } = o;
182
+ if (dt < 0 || dt > dur) return;
183
+ const p = dt / dur;
184
+ ctx.save(); ctx.globalAlpha = (1 - p); ctx.strokeStyle = color; ctx.lineWidth = lw * (1 - p) + 1;
185
+ ctx.beginPath(); ctx.arc(x, y, eOut(p) * r, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
186
+ }
187
+
188
+ /** split string by fraction for typewriter */
189
+ export const typed = (str, p) => { const a = Array.from(str); return a.slice(0, Math.floor(a.length * clamp(p) + 1e-6)).join(''); };
190
+
191
+ /** fit words into lines by measuring */
192
+ export function wrap(ctx, str, maxW, size, weight = 700, fam = F_SC) {
193
+ ctx.save(); ctx.font = font(size, weight, fam);
194
+ const out = []; let cur = '';
195
+ for (const ch of Array.from(str)) {
196
+ const t = cur + ch;
197
+ if (ctx.measureText(t).width > maxW && cur) {
198
+ // avoid starting a line with punctuation
199
+ if (/[,。;:、?!」』),.!?)]/.test(ch)) { out.push(t); cur = ''; continue; }
200
+ out.push(cur); cur = ch;
201
+ } else cur = t;
202
+ }
203
+ if (cur) out.push(cur);
204
+ ctx.restore(); return out;
205
+ }
206
+
207
+ /** cubic camera shake offset */
208
+ export function shake(t, amp, seed = 3) { return [Math.sin(t * 61 + seed) * amp * hash1(Math.floor(t * 30) + seed), Math.cos(t * 53 + seed * 2) * amp * hash1(Math.floor(t * 30) + seed + 7)]; }
209
+
210
+ export function arrow(ctx, x1, y1, x2, y2, color, lw = 4, head = 16, alpha = 1) {
211
+ if (alpha <= 0) return;
212
+ const a = Math.atan2(y2 - y1, x2 - x1);
213
+ ctx.save(); ctx.globalAlpha *= alpha; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'round';
214
+ ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - Math.cos(a) * head * .6, y2 - Math.sin(a) * head * .6); ctx.stroke();
215
+ ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - Math.cos(a - .45) * head, y2 - Math.sin(a - .45) * head); ctx.lineTo(x2 - Math.cos(a + .45) * head, y2 - Math.sin(a + .45) * head); ctx.closePath(); ctx.fill();
216
+ ctx.restore();
217
+ }
218
+
219
+ export function fmtUSD(v) {
220
+ if (v < 10) return '$' + v.toFixed(2);
221
+ if (v < 1000) return '$' + v.toFixed(v < 100 ? 1 : 0);
222
+ return '$' + Math.round(v).toLocaleString('en-US');
223
+ }
solana-clock/js/engine.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Frame compositor: background, scene, chapter header, subtitles, transitions.
2
+ import { buildTimeline, layout, subChunks } from './timeline.js';
3
+ import { SCENES } from './scenes.js';
4
+ import { clamp, lerp, inv, eOut, eIn, rng, glow, text, gradText, hexA, mix, measure, rr, F_SC, F_GR, F_MONO, PURPLE, GREEN, WHITE, GOLD, wrap } from './draw.js';
5
+
6
+ export const TL = buildTimeline();
7
+
8
+ const MOODS = {
9
+ hook: { top: '#120a2e', bot: '#030209', accent: PURPLE, grid: .0, stars: .9 },
10
+ night: { top: '#0b1030', bot: '#04040c', accent: '#5a6cff', grid: 0, stars: .6 },
11
+ poh: { top: '#0c1a2e', bot: '#02060a', accent: GREEN, grid: .55, stars: .5 },
12
+ name: { top: '#3a1440', bot: '#0a0612', accent: '#ff8a5c', grid: 0, stars: .2 },
13
+ build: { top: '#101a3a', bot: '#03040c', accent: '#00D1FF', grid: .5, stars: .6 },
14
+ mainnet: { top: '#2a0810', bot: '#050205', accent: '#ff3b5c', grid: .3, stars: .3 },
15
+ rise: { top: '#1c0b3a', bot: '#03020a', accent: GREEN, grid: .7, stars: .7 },
16
+ outage: { top: '#2a0a0a', bot: '#060203', accent: '#ff3b5c', grid: .45, stars: .2 },
17
+ ftx: { top: '#0b1220', bot: '#020306', accent: '#7aa0c8', grid: .15, stars: .25 },
18
+ rebuild: { top: '#2a1406', bot: '#070302', accent: '#ffa63d', grid: .35, stars: .5 },
19
+ meme: { top: '#2a0b3a', bot: '#05020a', accent: '#ff5ad1', grid: .6, stars: .7 },
20
+ ath: { top: '#06281f', bot: '#020806', accent: GREEN, grid: .7, stars: .9 },
21
+ wall: { top: '#0d1626', bot: '#030508', accent: GOLD, grid: .35, stars: .3 },
22
+ now: { top: '#140c30', bot: '#030209', accent: PURPLE, grid: .5, stars: .7 },
23
+ outro: { top: '#10082a', bot: '#020106', accent: GREEN, grid: .25, stars: 1 },
24
+ };
25
+
26
+ const STARS = (() => { const r = rng(42); return Array.from({ length: 260 }, () => ({ x: r(), y: r(), s: r() * 1.8 + .4, tw: r() * 6.28, sp: r() * .6 + .1 })); })();
27
+ const subCache = {};
28
+ function chunksFor(orient) {
29
+ if (subCache[orient]) return subCache[orient];
30
+ return (subCache[orient] = TL.lines.flatMap(l => subChunks(l, orient === 'portrait' ? 15 : 25).map(c => ({ ...c, news: l.v === 'news' }))));
31
+ }
32
+
33
+ export function sceneAt(T) {
34
+ const sc = TL.scenes;
35
+ for (let i = sc.length - 1; i >= 0; i--) if (T >= sc[i].start) return sc[i];
36
+ return sc[0];
37
+ }
38
+
39
+ function moodAt(T) {
40
+ const s = sceneAt(T), nx = TL.scenes[s.si + 1];
41
+ const a = MOODS[s.id];
42
+ if (!nx) return a;
43
+ const k = eOut(inv(nx.start - 1.2, nx.start + .3, T));
44
+ if (k <= 0) return a;
45
+ const b = MOODS[nx.id];
46
+ return { top: mix(a.top, b.top, k), bot: mix(a.bot, b.bot, k), accent: mix(a.accent, b.accent, k), grid: lerp(a.grid, b.grid, k), stars: lerp(a.stars, b.stars, k) };
47
+ }
48
+
49
+ function background(ctx, L, T, m) {
50
+ const { W, H } = L;
51
+ const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, m.top); g.addColorStop(1, m.bot);
52
+ ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
53
+ // nebulae
54
+ glow(ctx, W * (.25 + .05 * Math.sin(T * .07)), H * .28, Math.max(W, H) * .45, m.accent, .22);
55
+ glow(ctx, W * (.8 + .04 * Math.cos(T * .05)), H * .62, Math.max(W, H) * .4, PURPLE, .16);
56
+ // stars
57
+ if (m.stars > 0) {
58
+ // batched into 5 brightness buckets (one fill per bucket instead of one per star)
59
+ const B = 5, paths = Array.from({ length: B }, () => new Path2D());
60
+ for (const s of STARS) {
61
+ const x = ((s.x * W - T * s.sp * 6) % W + W) % W, y = s.y * H;
62
+ const tw = .5 + .5 * Math.sin(T * 1.3 + s.tw);
63
+ paths[Math.min(B - 1, Math.floor(tw * B))].rect(x, y, s.s, s.s);
64
+ }
65
+ ctx.save(); ctx.fillStyle = '#fff';
66
+ for (let k = 0; k < B; k++) { ctx.globalAlpha = m.stars * (.35 + .65 * (k + .5) / B) * .8; ctx.fill(paths[k]); }
67
+ ctx.restore();
68
+ }
69
+ // perspective time-grid floor
70
+ if (m.grid > .01) {
71
+ const hy = H * (L.portrait ? .7 : .66), cx = W / 2;
72
+ ctx.save(); ctx.globalAlpha = m.grid * .55;
73
+ const fg = ctx.createLinearGradient(0, hy, 0, H); fg.addColorStop(0, hexA(m.accent, 0)); fg.addColorStop(1, hexA(m.accent, .9));
74
+ ctx.strokeStyle = fg; ctx.lineWidth = 1.5;
75
+ for (let i = -14; i <= 14; i++) { ctx.beginPath(); ctx.moveTo(cx + i * 30, hy); ctx.lineTo(cx + i * W * .22, H); ctx.stroke(); }
76
+ const ph = (T * .45) % 1;
77
+ for (let j = 0; j < 14; j++) {
78
+ const z = (j + 1 - ph) / 14, y = hy + (H - hy) * Math.pow(z, 2.4);
79
+ ctx.globalAlpha = m.grid * .55 * z; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
80
+ }
81
+ ctx.restore();
82
+ glow(ctx, cx, hy, W * .5, m.accent, m.grid * .18);
83
+ }
84
+ }
85
+
86
+ function header(ctx, L, sc, t) {
87
+ if (!sc.year && !sc.chapter) return;
88
+ if (sc.id === 'hook' || sc.id === 'outro') return;
89
+ const a = eOut(inv(.15, .8, t)) * (1 - eIn(inv(sc.dur - .5, sc.dur - .05, t)));
90
+ if (a <= 0) return;
91
+ const { x, y } = L.head;
92
+ const slide = (1 - eOut(inv(.15, .9, t))) * -40;
93
+ ctx.save(); ctx.globalAlpha = a; ctx.translate(slide, 0);
94
+ const ys = L.portrait ? 64 : 58;
95
+ const w = gradText(ctx, sc.year, x, y, { size: ys, fam: F_GR, weight: 700, ls: 2 });
96
+ ctx.fillStyle = hexA(GREEN, .8); ctx.fillRect(x + w + 18, y - 22, 4, 44);
97
+ text(ctx, sc.chapter, x + w + 38, y + 2, { size: L.portrait ? 40 : 36, weight: 800, color: WHITE });
98
+ // chapter index
99
+ text(ctx, `CH.${String(sc.si).padStart(2, '0')} / ${TL.scenes.length - 2}`, x + 2, y + (L.portrait ? 54 : 48), { size: 20, fam: F_MONO, weight: 500, color: hexA(WHITE, .5), ls: 3 });
100
+ ctx.restore();
101
+ }
102
+
103
+ function slotCounter(ctx, L, T, sc) {
104
+ if (sc.id === 'hook' && T < 3) return;
105
+ const slot = Math.floor(T / .4);
106
+ const a = .55;
107
+ const s = 'SLOT ' + String(slot).padStart(6, '0');
108
+ if (L.portrait) text(ctx, s, L.safe.r, L.safe.t + 52, { size: 22, fam: F_MONO, weight: 600, color: hexA(GREEN, a), align: 'right', ls: 2 });
109
+ else text(ctx, s, L.W - 96, 92, { size: 22, fam: F_MONO, weight: 600, color: hexA(GREEN, a), align: 'right', ls: 2 });
110
+ // tick dot pulses every slot
111
+ const ph = (T % .4) / .4;
112
+ const x = L.portrait ? L.safe.r - measure(ctx, s, 22, 600, F_MONO, 2) - 20 : L.W - 96 - measure(ctx, s, 22, 600, F_MONO, 2) - 20;
113
+ const y = L.portrait ? L.safe.t + 52 : 92;
114
+ ctx.save(); ctx.globalAlpha = (1 - ph) * .9; ctx.fillStyle = GREEN; ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); ctx.restore();
115
+ }
116
+
117
+ function subtitles(ctx, L, T) {
118
+ const cs = chunksFor(L.orient);
119
+ const c = cs.find(c => T >= c.start - .05 && T < c.end + .12);
120
+ if (!c) return;
121
+ const a = Math.min(eOut(inv(c.start - .05, c.start + .12, T)), 1 - eIn(inv(c.end, c.end + .12, T)));
122
+ const { cx, y, maxW, size } = L.sub;
123
+ const lines = wrap(ctx, c.text, maxW - 40, size, 800);
124
+ const lh = size * 1.3;
125
+ const y0 = y - (lines.length - 1) * lh;
126
+ ctx.save(); ctx.globalAlpha = a;
127
+ let wmax = 0; for (const l of lines) wmax = Math.max(wmax, measure(ctx, l, size, 800));
128
+ rr(ctx, cx - wmax / 2 - 26, y0 - lh / 2 - 6, wmax + 52, lh * lines.length + 12, 16);
129
+ ctx.fillStyle = 'rgba(6,4,16,.55)'; ctx.fill();
130
+ lines.forEach((l, i) => text(ctx, l, cx, y0 + i * lh + 2, { size, weight: 800, align: 'center', color: c.news ? GOLD : '#fff', stroke: 7, strokeColor: 'rgba(0,0,0,.85)' }));
131
+ ctx.restore();
132
+ }
133
+
134
+ function transition(ctx, L, T) {
135
+ for (const sc of TL.scenes) {
136
+ if (sc.si === 0) continue;
137
+ const d = T - sc.start;
138
+ if (d < -.45 || d > .55) continue;
139
+ const p = inv(-.45, .55, d);
140
+ // diagonal light sweep
141
+ const { W, H } = L, x = lerp(-W * .6, W * 1.6, eIO(p));
142
+ ctx.save(); ctx.globalCompositeOperation = 'lighter';
143
+ const g = ctx.createLinearGradient(x - 420, 0, x + 420, H * .3);
144
+ g.addColorStop(0, 'rgba(153,69,255,0)'); g.addColorStop(.45, 'rgba(153,69,255,.35)'); g.addColorStop(.5, 'rgba(255,255,255,.55)'); g.addColorStop(.55, 'rgba(20,241,149,.35)'); g.addColorStop(1, 'rgba(20,241,149,0)');
145
+ ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
146
+ ctx.restore();
147
+ const f = Math.max(0, 1 - Math.abs(d) / .22) * .32;
148
+ if (f > 0) { ctx.save(); ctx.fillStyle = `rgba(255,255,255,${f})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore(); }
149
+ }
150
+ }
151
+ function eIO(t) { t = clamp(t); return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
152
+
153
+ const vigCache = {};
154
+ function vignette(ctx, L) {
155
+ const { W, H } = L;
156
+ let c = vigCache[L.orient];
157
+ if (!c) {
158
+ const w = W / 4, h = H / 4;
159
+ c = vigCache[L.orient] = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : Object.assign(document.createElement('canvas'), { width: w, height: h });
160
+ const x = c.getContext('2d');
161
+ const g = x.createRadialGradient(w / 2, h / 2, Math.min(w, h) * .35, w / 2, h / 2, Math.max(w, h) * .75);
162
+ g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,.55)');
163
+ x.fillStyle = g; x.fillRect(0, 0, w, h);
164
+ }
165
+ ctx.drawImage(c, 0, 0, W, H);
166
+ }
167
+
168
+ export function sceneState(sc, T) {
169
+ const t = T - sc.start;
170
+ const cue = i => sc.lines[Math.min(i, sc.lines.length - 1)].local;
171
+ const end = i => { const l = sc.lines[Math.min(i, sc.lines.length - 1)]; return l.local + l.dur; };
172
+ const at = (i, sub) => { const l = sc.lines[i]; const k = Math.max(0, l.t.indexOf(sub)); return l.local + l.dur * k / Array.from(l.t).length; };
173
+ let li = -1; sc.lines.forEach((l, i) => { if (t >= l.local - .05) li = i; });
174
+ return { t, T, d: sc.dur, cue, end, at, li, sc };
175
+ }
176
+
177
+ /** Render one frame. ctx must already be scaled so that 1 unit = 1 design pixel. */
178
+ export function renderFrame(ctx, orient, T, opts = {}) {
179
+ const L = layout(orient);
180
+ T = clamp(T, 0, TL.total - 1e-4);
181
+ const sc = sceneAt(T);
182
+ const m = moodAt(T);
183
+ ctx.save();
184
+ background(ctx, L, T, m);
185
+ const S = SCENES[sc.id];
186
+ const st = sceneState(sc, T);
187
+ // render previous scene's tail briefly under the transition for continuity
188
+ ctx.save(); S.draw(ctx, L, st); ctx.restore();
189
+ vignette(ctx, L);
190
+ if (S.post) S.post(ctx, L, st, opts);
191
+ header(ctx, L, sc, st.t);
192
+ slotCounter(ctx, L, T, sc);
193
+ transition(ctx, L, T);
194
+ if (opts.subs !== false) subtitles(ctx, L, T);
195
+ // global fade in/out
196
+ const fi = 1 - inv(0, .6, T), fo = inv(TL.total - 1.6, TL.total - .1, T);
197
+ const k = Math.max(fi, fo);
198
+ if (k > 0) { ctx.fillStyle = `rgba(0,0,0,${k})`; ctx.fillRect(0, 0, L.W, L.H); }
199
+ if (opts.safe && L.portrait) safeGuides(ctx, L);
200
+ ctx.restore();
201
+ }
202
+
203
+ export function safeGuides(ctx, L) {
204
+ const { W, H, safe } = L;
205
+ ctx.save();
206
+ ctx.fillStyle = 'rgba(255,40,80,.18)';
207
+ ctx.fillRect(0, 0, W, safe.t); ctx.fillRect(0, safe.b, W, H - safe.b); ctx.fillRect(safe.r, safe.t, W - safe.r, safe.b - safe.t);
208
+ ctx.setLineDash([14, 10]); ctx.strokeStyle = '#ff4d6d'; ctx.lineWidth = 3; ctx.strokeRect(safe.l, safe.t, safe.r - safe.l, safe.b - safe.t);
209
+ text(ctx, '刘海 / 状态栏 / 顶部标签', W / 2, safe.t / 2, { size: 30, align: 'center', color: '#ffb3c1' });
210
+ text(ctx, '抖音标题文案 / 音乐条 / 底部导航', W / 2, safe.b + (H - safe.b) / 2, { size: 30, align: 'center', color: '#ffb3c1' });
211
+ ctx.translate(safe.r + (W - safe.r) / 2, (safe.t + safe.b) / 2); ctx.rotate(Math.PI / 2);
212
+ text(ctx, '点赞 / 评论 / 收藏 / 分享', 0, 0, { size: 28, align: 'center', color: '#ffb3c1' });
213
+ ctx.restore();
214
+ }
215
+
216
+ /** all SFX events (absolute time) collected from scenes */
217
+ export function allSfx() {
218
+ const ev = [];
219
+ for (const sc of TL.scenes) {
220
+ const S = SCENES[sc.id];
221
+ const st = sceneState(sc, sc.start);
222
+ const list = S.sfx ? S.sfx(st) : [];
223
+ for (const e of list) ev.push({ ...e, t: sc.start + e.t });
224
+ if (sc.si > 0) ev.push({ t: sc.start - .35, k: 'whoosh', v: .55 });
225
+ }
226
+ return ev.sort((a, b) => a.t - b.t);
227
+ }
solana-clock/js/main.js ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Player controller: canvas preview, transport, chapters, orientation, tabs; lazy-loads audio / export / publish / cover.
2
+ import { TL, renderFrame, sceneAt } from './engine.js';
3
+ import { SIZES } from './timeline.js';
4
+
5
+ const $ = (s) => document.querySelector(s);
6
+ const view = $('#view'), stage = $('#stage'), stageWrap = $('#stageWrap');
7
+ const ctx = view.getContext('2d');
8
+ const fmt = (s) => { s = Math.max(0, s); return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; };
9
+
10
+ export const state = { T: 0, playing: false, orient: 'landscape', subs: true, safe: false, muted: false, dirty: true };
11
+ const qs = new URLSearchParams(location.search);
12
+ if (qs.get('o') === 'portrait') state.orient = 'portrait';
13
+ if (qs.has('t')) state.T = parseFloat(qs.get('t')) || 0;
14
+ if (qs.get('safe') === '1') state.safe = true;
15
+
16
+ export function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove('on'), 1800); }
17
+
18
+ // ---------- canvas sizing ----------
19
+ function resize() {
20
+ const r = stage.getBoundingClientRect();
21
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
22
+ const w = Math.max(2, Math.round(r.width * dpr)), h = Math.max(2, Math.round(r.height * dpr));
23
+ if (view.width !== w || view.height !== h) { view.width = w; view.height = h; }
24
+ state.dirty = true;
25
+ }
26
+ new ResizeObserver(resize).observe(stage);
27
+
28
+ function draw() {
29
+ const [W] = SIZES[state.orient];
30
+ ctx.setTransform(view.width / W, 0, 0, view.width / W, 0, 0);
31
+ renderFrame(ctx, state.orient, state.T, { subs: state.subs, safe: state.safe });
32
+ }
33
+
34
+ // ---------- audio (lazy) ----------
35
+ let audioMod = null, mixBuf = null, actx = null, srcNode = null, gainNode = null, clock0 = 0, mixPromise = null;
36
+ async function ensureMix() {
37
+ if (mixBuf) return mixBuf;
38
+ if (!mixPromise) {
39
+ mixPromise = (async () => {
40
+ audioMod = await import('./audio.js');
41
+ setLoading('正在合成配乐、音效与旁白…');
42
+ mixBuf = await audioMod.buildMix(p => setLoading(`正在合成声音… ${Math.round(p * 100)}%`));
43
+ setLoading(null);
44
+ return mixBuf;
45
+ })().catch(e => { console.error(e); setLoading(null); toast('声音加载失败,将静音播放'); mixPromise = null; return null; });
46
+ }
47
+ return mixPromise;
48
+ }
49
+ export const getMix = ensureMix;
50
+
51
+ function stopSrc() { if (srcNode) { try { srcNode.onended = null; srcNode.stop(); } catch { } srcNode.disconnect(); srcNode = null; } }
52
+ function startSrc() {
53
+ stopSrc();
54
+ if (!mixBuf || !actx) return;
55
+ srcNode = actx.createBufferSource(); srcNode.buffer = mixBuf;
56
+ srcNode.connect(gainNode);
57
+ srcNode.start(0, Math.min(state.T, mixBuf.duration - .01));
58
+ clock0 = actx.currentTime - state.T;
59
+ }
60
+
61
+ let wall0 = 0;
62
+ async function play() {
63
+ if (state.T >= TL.total - .05) state.T = 0;
64
+ $('#bigPlay').classList.add('hide');
65
+ if (!actx) {
66
+ const AC = window.AudioContext || window.webkitAudioContext;
67
+ actx = new AC({ sampleRate: 48000, latencyHint: 'playback' });
68
+ gainNode = actx.createGain(); gainNode.gain.value = state.muted ? 0 : 1; gainNode.connect(actx.destination);
69
+ }
70
+ await actx.resume().catch(() => { });
71
+ state.playing = true; setPlayIcon();
72
+ wall0 = performance.now() / 1000 - state.T;
73
+ if (!mixBuf) {
74
+ // run silently until the mix is ready, then attach audio at the current position
75
+ ensureMix().then(b => { if (b && state.playing) startSrc(); });
76
+ } else startSrc();
77
+ }
78
+ function pause() { state.playing = false; stopSrc(); setPlayIcon(); }
79
+ export function seek(T) {
80
+ state.T = Math.max(0, Math.min(TL.total - .01, T)); state.dirty = true;
81
+ wall0 = performance.now() / 1000 - state.T;
82
+ if (state.playing && mixBuf) startSrc();
83
+ }
84
+ function toggle() { state.playing ? pause() : play(); }
85
+ export const pausePlayer = pause;
86
+
87
+ function now() {
88
+ if (srcNode && actx) return actx.currentTime - clock0;
89
+ return performance.now() / 1000 - wall0;
90
+ }
91
+
92
+ function setPlayIcon() {
93
+ $('#icoPlay').innerHTML = state.playing ? '<path d="M7 5h4v14H7zm6 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
94
+ }
95
+
96
+ // ---------- loop ----------
97
+ let lastUI = -1;
98
+ function loop() {
99
+ if (state.playing) {
100
+ state.T = now();
101
+ if (state.T >= TL.total) { state.T = TL.total - .01; pause(); $('#bigPlay').classList.remove('hide'); $('#bigPlayText').textContent = '再看一遍'; }
102
+ state.dirty = true;
103
+ }
104
+ if (state.dirty) { draw(); state.dirty = false; updateUI(); }
105
+ requestAnimationFrame(loop);
106
+ }
107
+
108
+ // ---------- scrubber + chapters ----------
109
+ const scrub = $('#scrub');
110
+ $('#tTot').textContent = fmt(TL.total);
111
+ $('#scrubMarks').innerHTML = TL.scenes.slice(1).map(s => `<i style="left:${s.start / TL.total * 100}%"></i>`).join('');
112
+ const chapEl = $('#chapters');
113
+ chapEl.innerHTML = TL.scenes.map((s, i) => `<li data-i="${i}"><span class="n">${String(i + 1).padStart(2, '0')}</span><div style="flex:1"><div class="ttl">${s.chapter}${s.year ? `<span class="yr">${s.year}</span>` : ''}</div><div class="pbar"></div></div><span class="ts">${fmt(s.start)}</span></li>`).join('');
114
+ chapEl.addEventListener('click', e => { const li = e.target.closest('li'); if (!li) return; seek(TL.scenes[+li.dataset.i].start + .01); if (!state.playing) play(); });
115
+
116
+ function updateUI() {
117
+ const p = state.T / TL.total;
118
+ if (Math.abs(p - lastUI) < 1e-5) return; lastUI = p;
119
+ $('#scrubFill').style.width = `${p * 100}%`;
120
+ $('#scrubKnob').style.left = `${p * 100}%`;
121
+ $('#tCur').textContent = fmt(state.T);
122
+ const cur = sceneAt(state.T);
123
+ chapEl.querySelectorAll('li').forEach((li, i) => {
124
+ const s = TL.scenes[i];
125
+ li.classList.toggle('on', i === cur.si);
126
+ li.querySelector('.pbar').style.width = i === cur.si ? `${(state.T - s.start) / s.dur * 100}%` : '0';
127
+ });
128
+ }
129
+
130
+ function scrubAt(e) { const r = scrub.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * TL.total; }
131
+ let dragging = false;
132
+ scrub.addEventListener('pointerdown', e => { dragging = true; scrub.setPointerCapture(e.pointerId); seek(scrubAt(e)); });
133
+ scrub.addEventListener('pointermove', e => {
134
+ const T = scrubAt(e), r = scrub.getBoundingClientRect();
135
+ const tip = $('#scrubTip'); tip.style.left = `${e.clientX - r.left}px`; tip.textContent = `${fmt(T)} · ${sceneAt(T).chapter}`;
136
+ if (dragging) seek(T);
137
+ });
138
+ scrub.addEventListener('pointerup', () => { dragging = false; });
139
+
140
+ // ---------- buttons ----------
141
+ $('#btnPlay').onclick = toggle;
142
+ $('#bigPlay').onclick = play;
143
+ view.addEventListener('click', toggle);
144
+ $('#btnMute').onclick = () => {
145
+ state.muted = !state.muted; if (gainNode) gainNode.gain.value = state.muted ? 0 : 1;
146
+ $('#icoVol').innerHTML = state.muted ? '<path d="M4 9v6h4l5 5V4L8 9H4zm12.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4z"/>' : '<path d="M4 9v6h4l5 5V4L8 9H4zm12.5 3A4.5 4.5 0 0 0 14 8v8a4.5 4.5 0 0 0 2.5-4z"/>';
147
+ };
148
+ $('#btnFull').onclick = () => { document.fullscreenElement ? document.exitFullscreen() : stageWrap.requestFullscreen?.(); };
149
+
150
+ function setOrient(o) {
151
+ state.orient = o;
152
+ stageWrap.classList.toggle('portrait', o === 'portrait');
153
+ document.querySelectorAll('#segOrient button').forEach(b => b.classList.toggle('on', b.dataset.v === o));
154
+ $('#safeWrap').style.opacity = o === 'portrait' ? 1 : .4;
155
+ resize();
156
+ }
157
+ $('#segOrient').addEventListener('click', e => { const b = e.target.closest('button'); if (b) setOrient(b.dataset.v); });
158
+ $('#chkSubs').onchange = e => { state.subs = e.target.checked; state.dirty = true; };
159
+ $('#chkSafe').checked = state.safe;
160
+ $('#chkSafe').onchange = e => { state.safe = e.target.checked; if (state.safe && state.orient !== 'portrait') setOrient('portrait'); state.dirty = true; };
161
+
162
+ window.addEventListener('keydown', e => {
163
+ if (e.target.closest('input,textarea')) return;
164
+ if (e.code === 'Space') { e.preventDefault(); toggle(); }
165
+ else if (e.code === 'ArrowRight') seek(state.T + 5);
166
+ else if (e.code === 'ArrowLeft') seek(state.T - 5);
167
+ else if (e.key === ']') { const s = TL.scenes[Math.min(TL.scenes.length - 1, sceneAt(state.T).si + 1)]; seek(s.start + .01); }
168
+ else if (e.key === '[') { const c = sceneAt(state.T); const s = state.T - c.start > 2 ? c : TL.scenes[Math.max(0, c.si - 1)]; seek(s.start + .01); }
169
+ else if (e.key === 'm' || e.key === 'M') $('#btnMute').click();
170
+ else if (e.key === 'f' || e.key === 'F') $('#btnFull').click();
171
+ });
172
+
173
+ // ---------- tabs + lazy panels ----------
174
+ const loaded = {};
175
+ async function openTab(name) {
176
+ document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === name));
177
+ document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.dataset.panel === name));
178
+ if (loaded[name]) return; loaded[name] = true;
179
+ try {
180
+ if (name === 'export') (await import('./exporter.js')).initExportPanel({ state, getMix: ensureMix, pausePlayer: pause, toast });
181
+ if (name === 'publish') (await import('./publish.js')).initPublishPanel({ toast });
182
+ if (name === 'cover') (await import('./cover.js')).initCoverPanel({ toast });
183
+ } catch (e) { console.error(e); loaded[name] = false; }
184
+ }
185
+ $('#tabs').addEventListener('click', e => { const b = e.target.closest('button'); if (b) openTab(b.dataset.tab); });
186
+ if (qs.get('tab')) openTab(qs.get('tab'));
187
+
188
+ // ---------- loading ----------
189
+ function setLoading(msg) { const l = $('#loading'); if (!msg) { l.classList.add('hide'); return; } l.classList.remove('hide'); $('#loadingText').textContent = msg; }
190
+
191
+ async function boot() {
192
+ setOrient(state.orient);
193
+ try {
194
+ await Promise.all(['900 40px SolSC', '700 40px SolSC', '400 40px SolSC', '700 40px SolGrotesk', '500 40px SolMono'].map(f => document.fonts.load(f, 'SOL时钟')));
195
+ } catch { }
196
+ await document.fonts.ready;
197
+ setLoading(null);
198
+ state.dirty = true;
199
+ window.__ready = true;
200
+ // warm the audio mix in the background so playback starts with sound
201
+ if (!qs.has('nomix')) setTimeout(() => ensureMix(), 400);
202
+ }
203
+ boot();
204
+ requestAnimationFrame(loop);
205
+
206
+ // test hook: render a specific time synchronously
207
+ window.__render = (T, o) => { if (o) setOrient(o); state.T = T; draw(); return true; };
solana-clock/js/props.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reusable illustrated props (stateless)
2
+ import { clamp, lerp, inv, eOut, eBack, rng, glow, text, gradText, hexA, rr, glass, F_SC, F_GR, F_MONO, PURPLE, GREEN, WHITE, RED, GOLD, measure } from './draw.js';
3
+
4
+ export function monitor(ctx, x, y, w, o = {}) {
5
+ const { color = GREEN, alpha = 1, on = 1, label } = o;
6
+ if (alpha <= 0) return;
7
+ const h = w * .66;
8
+ ctx.save(); ctx.globalAlpha *= alpha; ctx.translate(x, y);
9
+ rr(ctx, -w / 2, -h / 2, w, h, w * .08); ctx.fillStyle = '#0d0b1c'; ctx.fill();
10
+ ctx.lineWidth = Math.max(2, w * .04); ctx.strokeStyle = hexA(color, .5 + .5 * on); ctx.stroke();
11
+ rr(ctx, -w / 2 + w * .08, -h / 2 + w * .08, w - w * .16, h - w * .16, w * .04);
12
+ ctx.fillStyle = hexA(color, .12 + .28 * on); ctx.fill();
13
+ ctx.fillStyle = hexA(color, .6); ctx.fillRect(-w * .12, h / 2, w * .24, w * .1); ctx.fillRect(-w * .25, h / 2 + w * .1, w * .5, w * .04);
14
+ if (label) text(ctx, label, 0, h / 2 + w * .32, { size: w * .2, fam: F_MONO, weight: 600, color: hexA(WHITE, .7), align: 'center' });
15
+ ctx.restore();
16
+ if (on > .5) glow(ctx, x, y, w * .9, color, .25 * on * alpha);
17
+ }
18
+
19
+ export function coin(ctx, x, y, r, o = {}) {
20
+ const { label = '◎', c1 = PURPLE, c2 = GREEN, alpha = 1, spin = 0, fs } = o;
21
+ if (alpha <= 0) return;
22
+ const sx = Math.max(.08, Math.abs(Math.cos(spin)));
23
+ ctx.save(); ctx.globalAlpha *= alpha; ctx.translate(x, y); ctx.scale(sx, 1);
24
+ const g = ctx.createLinearGradient(-r, -r, r, r); g.addColorStop(0, c1); g.addColorStop(1, c2);
25
+ ctx.fillStyle = hexA('#000000', .35); ctx.beginPath(); ctx.arc(r * .08, r * .1, r, 0, 7); ctx.fill();
26
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, 7); ctx.fill();
27
+ ctx.strokeStyle = 'rgba(255,255,255,.55)'; ctx.lineWidth = r * .08; ctx.beginPath(); ctx.arc(0, 0, r * .8, 0, 7); ctx.stroke();
28
+ if (sx > .3) text(ctx, label, 0, r * .04, { size: fs || r * (label.length > 2 ? .5 : .9), weight: 900, fam: F_GR, color: '#fff', align: 'center' });
29
+ ctx.restore();
30
+ }
31
+
32
+ export function stamp(ctx, str, x, y, t, t0, o = {}) {
33
+ const { size = 120, color = RED, rot = -.12, fam = F_SC, glowC } = o;
34
+ const dt = t - t0;
35
+ if (dt < 0) return;
36
+ const p = eOut(inv(0, .28, dt));
37
+ const s = lerp(2.4, 1, p), a = clamp(dt / .12);
38
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.scale(s, s); ctx.globalAlpha *= a;
39
+ const w = measure(ctx, str, size, 900, fam) + size * .5;
40
+ ctx.lineWidth = size * .07; ctx.strokeStyle = color; rr(ctx, -w / 2, -size * .7, w, size * 1.4, size * .18); ctx.stroke();
41
+ text(ctx, str, 0, size * .04, { size, weight: 900, fam, color, align: 'center' });
42
+ ctx.restore();
43
+ if (dt < .5) glow(ctx, x, y, size * 2.5, glowC || color, (1 - dt / .5) * .6);
44
+ }
45
+
46
+ export function bubble(ctx, str, x, y, o = {}) {
47
+ const { size = 34, alpha = 1, color = 'rgba(255,255,255,.92)', fg = '#1a1030', tail = 'left' } = o;
48
+ if (alpha <= 0) return;
49
+ const w = measure(ctx, str, size, 800) + size * 1.2, h = size * 1.8;
50
+ ctx.save(); ctx.globalAlpha *= alpha;
51
+ rr(ctx, x - w / 2, y - h / 2, w, h, h * .4); ctx.fillStyle = color; ctx.fill();
52
+ ctx.beginPath(); const tx = tail === 'left' ? x - w / 2 + h * .5 : x + w / 2 - h * .5;
53
+ ctx.moveTo(tx - 12, y + h / 2 - 2); ctx.lineTo(tx + (tail === 'left' ? -18 : 18), y + h / 2 + 20); ctx.lineTo(tx + 14, y + h / 2 - 2); ctx.fill();
54
+ ctx.restore();
55
+ text(ctx, str, x, y + 2, { size, weight: 800, color: fg, align: 'center', alpha });
56
+ }
57
+
58
+ export function nodesIn(seed, n, rect, minD = 80) {
59
+ const r = rng(seed), out = [];
60
+ let guard = 0;
61
+ while (out.length < n && guard++ < 4000) {
62
+ const p = { x: rect.x + r() * rect.w, y: rect.y + r() * rect.h, s: r(), ph: r() * 6.28 };
63
+ if (out.every(q => Math.hypot(q.x - p.x, q.y - p.y) > minD)) out.push(p);
64
+ }
65
+ return out;
66
+ }
67
+ export function linksFor(nodes, k = 3) {
68
+ const L = [];
69
+ nodes.forEach((a, i) => {
70
+ const near = nodes.map((b, j) => ({ j, d: Math.hypot(a.x - b.x, a.y - b.y) })).filter(o => o.j !== i).sort((p, q) => p.d - q.d).slice(0, k);
71
+ for (const o of near) if (i < o.j || !L.some(l => l[0] === o.j && l[1] === i)) L.push([i, o.j]);
72
+ });
73
+ return L;
74
+ }
75
+
76
+ export function rocket(ctx, x, y, s, o = {}) {
77
+ const { flame = 0, t = 0, alpha = 1 } = o;
78
+ ctx.save(); ctx.globalAlpha *= alpha; ctx.translate(x, y); ctx.scale(s, s);
79
+ if (flame > 0) {
80
+ for (let i = 0; i < 3; i++) {
81
+ const fl = (60 + 40 * Math.sin(t * 40 + i * 2)) * flame;
82
+ const g = ctx.createLinearGradient(0, 70, 0, 70 + fl * (1.2 - i * .25));
83
+ g.addColorStop(0, i === 2 ? '#fff' : i === 1 ? GOLD : '#ff6a3d'); g.addColorStop(1, 'rgba(255,80,40,0)');
84
+ ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(-22 + i * 7, 68); ctx.quadraticCurveTo(0, 70 + fl * (1.4 - i * .3), 22 - i * 7, 68); ctx.fill();
85
+ }
86
+ glow(ctx, 0, 90, 120 * flame, '#ff8a3d', .8);
87
+ }
88
+ const g = ctx.createLinearGradient(-30, 0, 30, 0); g.addColorStop(0, '#d8d4ee'); g.addColorStop(.5, '#ffffff'); g.addColorStop(1, '#9d97c4');
89
+ ctx.fillStyle = g;
90
+ ctx.beginPath(); ctx.moveTo(0, -110); ctx.bezierCurveTo(38, -70, 34, 20, 26, 70); ctx.lineTo(-26, 70); ctx.bezierCurveTo(-34, 20, -38, -70, 0, -110); ctx.fill();
91
+ ctx.fillStyle = PURPLE; ctx.beginPath(); ctx.moveTo(-26, 30); ctx.lineTo(-52, 80); ctx.lineTo(-24, 66); ctx.fill(); ctx.beginPath(); ctx.moveTo(26, 30); ctx.lineTo(52, 80); ctx.lineTo(24, 66); ctx.fill();
92
+ ctx.fillStyle = GREEN; ctx.beginPath(); ctx.arc(0, -30, 13, 0, 7); ctx.fill();
93
+ ctx.strokeStyle = '#2a2050'; ctx.lineWidth = 4; ctx.stroke();
94
+ ctx.restore();
95
+ }
96
+
97
+ export function skyline(ctx, x0, base, w, h, seed, o = {}) {
98
+ const { alpha = 1, lit = .5, color = '#0a0b1e', t = 0, win = GOLD } = o;
99
+ const r = rng(seed);
100
+ ctx.save(); ctx.globalAlpha *= alpha;
101
+ let x = x0;
102
+ while (x < x0 + w) {
103
+ const bw = 40 + r() * 90, bh = h * (.3 + r() * .7);
104
+ ctx.fillStyle = color; ctx.fillRect(x, base - bh, bw - 4, bh);
105
+ for (let wy = base - bh + 12; wy < base - 10; wy += 22) for (let wx = x + 8; wx < x + bw - 14; wx += 16) {
106
+ const v = r(); if (v < lit * (.7 + .3 * Math.sin(t * .3 + v * 20))) { ctx.fillStyle = hexA(win, .35 + .5 * v); ctx.fillRect(wx, wy, 7, 10); }
107
+ }
108
+ x += bw;
109
+ }
110
+ ctx.restore();
111
+ }
112
+
113
+ /** text that resolves from random glyphs */
114
+ export function scramble(target, p, seed = 1) {
115
+ const g = '!<>-_/[]{}=+*^?#ABCDEFGHJKLMNOPQRSTUVWXYZ0123456789';
116
+ const arr = Array.from(target);
117
+ return arr.map((ch, i) => {
118
+ const k = clamp(p * (arr.length + 3) - i);
119
+ if (k >= 1) return ch;
120
+ if (k <= 0) return '';
121
+ return g[Math.floor((Math.sin(seed * 99 + i * 13 + p * 400) * .5 + .5) * g.length) % g.length];
122
+ }).join('');
123
+ }
124
+
125
+ export function avatar(ctx, x, y, r, initials, c1, c2, alpha = 1) {
126
+ if (alpha <= 0) return;
127
+ ctx.save(); ctx.globalAlpha *= alpha;
128
+ const g = ctx.createLinearGradient(x - r, y - r, x + r, y + r); g.addColorStop(0, c1); g.addColorStop(1, c2);
129
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill();
130
+ // silhouette
131
+ ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.clip();
132
+ ctx.fillStyle = 'rgba(10,6,30,.45)'; ctx.beginPath(); ctx.arc(x, y - r * .18, r * .36, 0, 7); ctx.fill();
133
+ ctx.beginPath(); ctx.ellipse(x, y + r * .78, r * .7, r * .55, 0, 0, 7); ctx.fill();
134
+ ctx.restore();
135
+ ctx.restore();
136
+ text(ctx, initials, x, y + r * .02, { size: r * .55, weight: 900, fam: F_GR, color: '#fff', align: 'center', alpha, stroke: 4, strokeColor: 'rgba(0,0,0,.35)' });
137
+ }
138
+
139
+ export function personCard(ctx, x, y, w, o) {
140
+ const { name, role, sub, ini, c1 = PURPLE, c2 = GREEN, a = 1 } = o;
141
+ if (a <= 0) return;
142
+ const h = w * 1.18;
143
+ ctx.save(); ctx.globalAlpha *= a; ctx.translate(x, y + (1 - a) * 60);
144
+ glass(ctx, -w / 2, -h / 2, w, h, { r: 26 });
145
+ avatar(ctx, 0, -h * .16, w * .26, ini, c1, c2);
146
+ text(ctx, name, 0, h * .2, { size: w * .1, weight: 800, fam: F_GR, color: WHITE, align: 'center', maxW: w * .9 });
147
+ text(ctx, role, 0, h * .31, { size: w * .085, weight: 700, color: GREEN, align: 'center', maxW: w * .9 });
148
+ if (sub) text(ctx, sub, 0, h * .41, { size: w * .07, weight: 500, color: 'rgba(255,255,255,.6)', align: 'center', maxW: w * .9 });
149
+ ctx.restore();
150
+ }
151
+
152
+ export function tower(ctx, x, base, w, h, label, o = {}) {
153
+ const { color = '#1b2440', edge = '#6f8cc0', alpha = 1, lit = .6, seed = 3 } = o;
154
+ if (alpha <= 0) return;
155
+ ctx.save(); ctx.globalAlpha *= alpha;
156
+ const g = ctx.createLinearGradient(x - w / 2, 0, x + w / 2, 0); g.addColorStop(0, color); g.addColorStop(1, '#0a0e1c');
157
+ ctx.fillStyle = g; ctx.fillRect(x - w / 2, base - h, w, h);
158
+ ctx.strokeStyle = hexA(edge, .7); ctx.lineWidth = 3; ctx.strokeRect(x - w / 2, base - h, w, h);
159
+ const r = rng(seed);
160
+ for (let yy = base - h + 70; yy < base - 14; yy += 26) for (let xx = x - w / 2 + 14; xx < x + w / 2 - 18; xx += 22) if (r() < lit) { ctx.fillStyle = hexA('#bfe3ff', .25 + r() * .5); ctx.fillRect(xx, yy, 10, 12); }
161
+ ctx.fillStyle = '#0c1020'; ctx.fillRect(x - w / 2 + 10, base - h + 10, w - 20, 50);
162
+ text(ctx, label, x, base - h + 36, { size: Math.min(44, w * .3), weight: 900, fam: F_GR, color: '#e9f1ff', align: 'center', maxW: w - 30 });
163
+ ctx.restore();
164
+ }
165
+
166
+ /** stateless falling debris: pieces of rect fall after t0 */
167
+ export function crumble(ctx, x, base, w, h, label, dt, o = {}) {
168
+ const { seed = 9, cols = 4, rows = 8 } = o;
169
+ const r = rng(seed);
170
+ const pw = w / cols, ph = h / rows;
171
+ ctx.save();
172
+ for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
173
+ const delay = (rows - j) * .05 + r() * .25, vx = (r() - .5) * 260, spin = (r() - .5) * 6;
174
+ const tt = Math.max(0, dt - delay);
175
+ const px = x - w / 2 + i * pw + vx * tt, py = base - h + j * ph + 900 * tt * tt;
176
+ if (py > base + 400) continue;
177
+ ctx.save(); ctx.translate(px + pw / 2, py + ph / 2); ctx.rotate(spin * tt);
178
+ ctx.globalAlpha = clamp(1 - tt * .8);
179
+ ctx.fillStyle = (i + j) % 2 ? '#1b2440' : '#141b31'; ctx.fillRect(-pw / 2, -ph / 2, pw - 2, ph - 2);
180
+ ctx.strokeStyle = 'rgba(111,140,192,.6)'; ctx.lineWidth = 2; ctx.strokeRect(-pw / 2, -ph / 2, pw - 2, ph - 2);
181
+ ctx.restore();
182
+ }
183
+ ctx.restore();
184
+ void label;
185
+ }
186
+
187
+ export function snow(ctx, L, T, n = 120, a = .7) {
188
+ const r = rng(77);
189
+ ctx.save(); ctx.fillStyle = '#e8f2ff';
190
+ for (let i = 0; i < n; i++) {
191
+ const sx = r(), sp = 40 + r() * 80, sz = 1.5 + r() * 3.5, ph = r() * 100;
192
+ const y = ((ph * 37 + T * sp) % (L.H + 40)) - 20, x = (sx * L.W + Math.sin(T * .8 + ph) * 30 + L.W) % L.W;
193
+ ctx.globalAlpha = a * (.4 + .6 * r()); ctx.beginPath(); ctx.arc(x, y, sz, 0, 7); ctx.fill();
194
+ }
195
+ ctx.restore();
196
+ }
197
+
198
+ export function rain(ctx, L, T, n = 160, a = .4, color = '#9fb4ff') {
199
+ const r = rng(51);
200
+ ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.6;
201
+ for (let i = 0; i < n; i++) {
202
+ const sx = r(), sp = 900 + r() * 700, ph = r() * 10, len = 20 + r() * 30;
203
+ const y = ((ph * 300 + T * sp) % (L.H + 100)) - 50, x = (sx * (L.W + 300) - y * .25);
204
+ ctx.globalAlpha = a * (.3 + .7 * r()); ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - len * .25, y + len); ctx.stroke();
205
+ }
206
+ ctx.restore();
207
+ }
208
+
209
+ export function bigNum(ctx, str, x, y, size, o = {}) {
210
+ return gradText(ctx, str, x, y, { size, fam: F_GR, weight: 700, align: 'center', ...o });
211
+ }
212
+
213
+ export { GOLD, RED, GREEN, PURPLE, WHITE, F_SC, F_GR, F_MONO };
solana-clock/js/scenes-a.js ADDED
@@ -0,0 +1,746 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Scenes 1–7: hook, night, poh, name, build, mainnet, rise
2
+ import { clamp, lerp, inv, eOut, eIn, eIO, eBack, eElastic, win, life, rng, hash1, hexStr, glow, text, gradText, glowText, glass, pill, rr, solLogo, dial, burst, ring, typed, arrow, shake, measure, hexA, fmtUSD, F_SC, F_GR, F_MONO, PURPLE, GREEN, TEAL, PINK, RED, GOLD, WHITE } from './draw.js';
3
+ import { monitor, coin, stamp, bubble, nodesIn, linksFor, rocket, skyline, scramble, personCard, rain, bigNum } from './props.js';
4
+ import { drawChart, idx, LAST } from './chart.js';
5
+
6
+ const BTC_2020_03 = [['03-05', 9083], ['03-06', 9152], ['03-07', 9098], ['03-08', 8756], ['03-09', 7908], ['03-10', 8031], ['03-11', 7832], ['03-12', 6537], ['03-13', 5696], ['03-14', 5409], ['03-15', 5424], ['03-16', 4665]]; // Surf market-price BTC daily
7
+
8
+ function chip(ctx, x, y, w, h, big, small, a, color = GREEN) {
9
+ if (a <= 0) return;
10
+ ctx.save(); ctx.globalAlpha *= a; ctx.translate(x, y); const s = lerp(.6, 1, eBack(a)); ctx.scale(s, s);
11
+ glass(ctx, -w / 2, -h / 2, w, h, { r: 24 });
12
+ gradText(ctx, big, 0, -h * .14, { size: h * .38, fam: F_GR, weight: 700, align: 'center', c2: color });
13
+ text(ctx, small, 0, h * .27, { size: h * .17, weight: 700, color: 'rgba(255,255,255,.75)', align: 'center' });
14
+ ctx.restore();
15
+ }
16
+
17
+ function speedLines(ctx, L, t, a, n = 46, color = WHITE) {
18
+ if (a <= 0) return;
19
+ const r = rng(5);
20
+ ctx.save(); ctx.globalCompositeOperation = 'lighter';
21
+ for (let i = 0; i < n; i++) {
22
+ const y = r() * L.H, sp = 1800 + r() * 2600, len = 120 + r() * 380, ph = r();
23
+ const x = ((ph * (L.W + 800) + t * sp) % (L.W + 800)) - 400;
24
+ const g = ctx.createLinearGradient(x - len, 0, x, 0); g.addColorStop(0, hexA(color, 0)); g.addColorStop(1, hexA(i % 3 ? GREEN : PURPLE, .7 * a));
25
+ ctx.strokeStyle = g; ctx.lineWidth = 1 + r() * 3; ctx.beginPath(); ctx.moveTo(x - len, y); ctx.lineTo(x, y); ctx.stroke();
26
+ }
27
+ ctx.restore();
28
+ }
29
+
30
+ // ---------------------------------------------------------------- 1. HOOK
31
+ const hook = {
32
+ draw(ctx, L, s) {
33
+ const { t } = s, st = L.stage, P = L.portrait;
34
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3);
35
+ const m = Math.min(st.w, st.h);
36
+ const toC = eIO(inv(c1 - .2, c1 + .6, t));
37
+ const dx = P ? st.cx : lerp(st.cx - 430, st.cx, toC);
38
+ const dy = P ? lerp(st.cy - 170, st.cy - 40, toC) : st.cy;
39
+ const R = m * (P ? .33 : .38) * (1 + .1 * toC);
40
+ const aDial = win(t, .2, 1.2) * (1 - .7 * win(t, c2 - .2, .6)) * (1 - win(t, c3 - .2, .5));
41
+ const steps = Math.floor(Math.min(t, c1) / .4);
42
+ const hand = steps / 60 + (t > c1 ? Math.pow(t - c1, 2) * .55 : 0);
43
+ const ph = (t % .4) / .4;
44
+ glow(ctx, dx, dy, R * 1.5, PURPLE, (.25 + .3 * (1 - ph) * (t < c1 ? 1 : .3)) * aDial);
45
+ dial(ctx, dx, dy, R, { alpha: aDial, hand, minute: .1 + hand / 60, lw: 4 });
46
+ if (t < c1 && aDial > 0) ring(ctx, dx, dy, t - steps * .4, { r: R * 1.35, color: GREEN, dur: .4, lw: 3 });
47
+ // centre readout: 400ms
48
+ if (t < c1 + .4) {
49
+ const a = win(t, .6, .8) * (1 - win(t, c1, .4));
50
+ text(ctx, '400', dx, dy - 8, { size: R * .38, fam: F_GR, weight: 700, color: WHITE, align: 'center', alpha: a });
51
+ text(ctx, 'ms / slot', dx, dy + R * .26, { size: R * .1, fam: F_MONO, weight: 600, color: GREEN, align: 'center', alpha: a, ls: 3 });
52
+ }
53
+ // stat chips
54
+ const chips = [['数千笔', '每秒处理交易', s.at(0, '每秒')], ['400ms', '出一个区块', s.at(0, '四百')], ['<$0.01', '单笔手续费', s.at(0, '手续费')]];
55
+ chips.forEach(([b, sm, at], i) => {
56
+ const a = win(t, at, .45, eOut) * (1 - win(t, c1 - .15, .35));
57
+ const fly = win(t, c1 - .15, .5, eIn) * (i === 1 ? 0 : 1);
58
+ let x, y, w, h;
59
+ if (P) { w = 270; h = 150; x = st.cx + (i - 1) * 290; y = st.cy + 300; }
60
+ else { w = 470; h = 160; x = st.cx + 380; y = st.cy + (i - 1) * 200; }
61
+ chip(ctx, x + fly * 900, y, w, h, b, sm, a);
62
+ });
63
+ // c1: why so fast?
64
+ speedLines(ctx, L, t, win(t, c1 - .1, .3) * (1 - win(t, c2 - .2, .5)));
65
+ const aQ1 = win(t, c1, .35, eBack) * (1 - win(t, c2 - .1, .3));
66
+ if (aQ1 > 0) {
67
+ ctx.save(); ctx.translate(dx, dy); const sc = lerp(1.6, 1, eOut(inv(c1, c1 + .35, t))); ctx.scale(sc, sc);
68
+ glowText(ctx, '凭什么这么快?', 0, 0, { size: P ? 96 : 120, weight: 900, align: 'center', alpha: clamp(aQ1), glowColor: GREEN, blur: 40 });
69
+ ctx.restore();
70
+ }
71
+ // c2: the crash & the comeback, drawn with the real price history
72
+ const aC = win(t, c2 - .1, .4) * (1 - win(t, c3 - .3, .4));
73
+ if (aC > 0) {
74
+ const rect = P ? { x: st.x + 40, y: st.y + 260, w: st.w - 80, h: 460 } : { x: st.x + 80, y: st.y + 170, w: st.w - 160, h: 470 };
75
+ const prog = eIO(inv(c2, s.end(2) + .2, t));
76
+ drawChart(ctx, rect, { from: 0, to: LAST, prog, log: true, alpha: aC, grid: false, dates: false, headLabel: false, lw: 4, color: GREEN, color2: PINK, yMin: .4, yMax: 400 });
77
+ const a1 = win(t, s.at(2, '摔'), .3) * aC, a2 = win(t, s.at(2, '还能'), .3) * aC;
78
+ const ty = P ? st.y + 120 : st.y + 70;
79
+ if (P) {
80
+ glowText(ctx, '摔得那么惨?', st.cx, ty, { size: 74, weight: 900, color: RED, align: 'center', alpha: a1, glowColor: RED });
81
+ glowText(ctx, '还能爬回来?', st.cx, rect.y + rect.h + 110, { size: 74, weight: 900, color: GREEN, align: 'center', alpha: a2, glowColor: GREEN });
82
+ } else {
83
+ glowText(ctx, '摔得那么惨?', st.cx - 330, ty, { size: 78, weight: 900, color: RED, align: 'center', alpha: a1, glowColor: RED });
84
+ glowText(ctx, '还能爬回来?', st.cx + 330, ty, { size: 78, weight: 900, color: GREEN, align: 'center', alpha: a2, glowColor: GREEN });
85
+ }
86
+ }
87
+ // c3: logo + title
88
+ if (t > c3 - .2) {
89
+ const p = inv(c3, c3 + 1.1, t);
90
+ const ly = st.cy - (P ? 120 : 90), ls = P ? 300 : 260;
91
+ glow(ctx, st.cx, ly, ls * 1.6, PURPLE, .5 * p);
92
+ solLogo(ctx, st.cx, ly, ls, p);
93
+ burst(ctx, st.cx, ly, t - (c3 + .75), { r: 520, n: 60, seed: 3 });
94
+ ring(ctx, st.cx, ly, t - (c3 + .75), { r: 700, dur: 1.1 });
95
+ const a = win(t, c3 + .8, .6);
96
+ const word = scramble('SOLANA', inv(c3 + .8, c3 + 1.6, t), 4);
97
+ gradText(ctx, word, st.cx, ly + ls * .72 + 20, { size: P ? 120 : 132, fam: F_GR, weight: 700, align: 'center', ls: P ? 16 : 26, alpha: a });
98
+ text(ctx, '给 区 块 链 装 上 时 钟 的 人', st.cx, ly + ls * .72 + (P ? 130 : 135), { size: P ? 40 : 42, weight: 700, color: 'rgba(255,255,255,.85)', align: 'center', alpha: win(t, c3 + 1.5, .6), ls: 4 });
99
+ }
100
+ },
101
+ sfx(s) {
102
+ const ev = [], c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3);
103
+ for (let k = 1; k * .4 < c1; k++) ev.push({ t: k * .4, k: 'tick', v: k % 5 === 0 ? 1 : .55 });
104
+ ev.push({ t: s.at(0, '每秒'), k: 'pop' }, { t: s.at(0, '四百'), k: 'pop' }, { t: s.at(0, '手续费'), k: 'pop' });
105
+ ev.push({ t: c1 - .3, k: 'whoosh', v: 1 }, { t: c1, k: 'hit', v: .8 });
106
+ ev.push({ t: s.at(2, '摔') , k: 'drop' }, { t: s.at(2, '还能'), k: 'rise' });
107
+ ev.push({ t: c3 - 1.2, k: 'riser', v: 1 }, { t: c3 + .75, k: 'impact', v: 1 }, { t: c3 + .8, k: 'shimmer' });
108
+ return ev;
109
+ },
110
+ };
111
+
112
+ // ---------------------------------------------------------------- 2. NIGHT 2017
113
+ function person(ctx, x, deskY, s, rim = PURPLE) {
114
+ ctx.save(); ctx.translate(x, deskY); ctx.scale(s, s);
115
+ ctx.fillStyle = '#05040c';
116
+ ctx.beginPath(); ctx.moveTo(-150, 0); ctx.bezierCurveTo(-150, -120, -110, -175, -60, -185); ctx.lineTo(60, -185); ctx.bezierCurveTo(110, -175, 150, -120, 150, 0); ctx.fill();
117
+ ctx.fillRect(-28, -215, 56, 40);
118
+ ctx.beginPath(); ctx.ellipse(0, -265, 62, 72, 0, 0, 7); ctx.fill();
119
+ // rim light
120
+ ctx.strokeStyle = hexA(rim, .75); ctx.lineWidth = 4;
121
+ ctx.beginPath(); ctx.ellipse(0, -265, 62, 72, 0, -1.2, .6); ctx.stroke();
122
+ ctx.beginPath(); ctx.moveTo(60, -185); ctx.bezierCurveTo(110, -175, 150, -120, 150, 0); ctx.stroke();
123
+ ctx.restore();
124
+ }
125
+ function cup(ctx, x, y, kind, a) {
126
+ if (a <= 0) return;
127
+ ctx.save(); ctx.translate(x, y); const s = eBack(a); ctx.scale(s, s); ctx.globalAlpha *= clamp(a * 2);
128
+ if (kind === 'beer') {
129
+ const g = ctx.createLinearGradient(0, -110, 0, 0); g.addColorStop(0, '#fff6d8'); g.addColorStop(.18, GOLD); g.addColorStop(1, '#d88a12');
130
+ ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(-30, -110); ctx.lineTo(30, -110); ctx.lineTo(26, 0); ctx.lineTo(-26, 0); ctx.fill();
131
+ ctx.fillStyle = '#fffdf2'; ctx.beginPath(); ctx.ellipse(0, -112, 33, 12, 0, 0, 7); ctx.fill();
132
+ ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.lineWidth = 3; ctx.strokeRect(-30, -110, 60, 110);
133
+ glow(ctx, 0, -55, 80, GOLD, .35);
134
+ } else {
135
+ ctx.fillStyle = '#efe9ff'; rr(ctx, -30, -64, 60, 64, 10); ctx.fill();
136
+ ctx.strokeStyle = '#efe9ff'; ctx.lineWidth = 8; ctx.beginPath(); ctx.arc(34, -34, 15, -1.3, 1.3); ctx.stroke();
137
+ ctx.fillStyle = '#6b3b1d'; ctx.beginPath(); ctx.ellipse(0, -62, 26, 7, 0, 0, 7); ctx.fill();
138
+ ctx.restore(); ctx.save(); ctx.translate(x, y); ctx.globalAlpha *= a * .5; ctx.strokeStyle = '#fff'; ctx.lineWidth = 3;
139
+ for (let i = 0; i < 2; i++) { ctx.beginPath(); ctx.moveTo(-8 + i * 16, -78); ctx.bezierCurveTo(-20 + i * 16, -100, 4 + i * 16, -110, -8 + i * 16, -135); ctx.stroke(); }
140
+ }
141
+ ctx.restore();
142
+ }
143
+ const night = {
144
+ draw(ctx, L, s) {
145
+ const { t, T } = s, st = L.stage, P = L.portrait;
146
+ const a0 = win(t, 0, 1);
147
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4);
148
+ // window
149
+ const wx = P ? st.x + 20 : st.cx - 480, wy = P ? st.y - 10 : st.y - 10, ww = P ? st.w - 40 : 960, wh = P ? 400 : 420;
150
+ ctx.save(); ctx.globalAlpha = a0;
151
+ rr(ctx, wx, wy, ww, wh, 18); ctx.save(); ctx.clip();
152
+ const sg = ctx.createLinearGradient(0, wy, 0, wy + wh); sg.addColorStop(0, '#0a1440'); sg.addColorStop(1, '#2a1a5e'); ctx.fillStyle = sg; ctx.fillRect(wx, wy, ww, wh);
153
+ glow(ctx, wx + ww * .78, wy + 90, 120, '#cfd8ff', .6); ctx.fillStyle = '#eef0ff'; ctx.beginPath(); ctx.arc(wx + ww * .78, wy + 90, 34, 0, 7); ctx.fill();
154
+ skyline(ctx, wx - 10, wy + wh, ww + 20, wh * .62, 21, { lit: .35, t: T, color: '#090a1d' });
155
+ ctx.restore();
156
+ ctx.strokeStyle = '#1d1838'; ctx.lineWidth = 16; rr(ctx, wx, wy, ww, wh, 18); ctx.stroke();
157
+ ctx.fillStyle = '#1d1838'; ctx.fillRect(wx + ww / 2 - 6, wy, 12, wh); ctx.fillRect(wx, wy + wh * .45, ww, 10);
158
+ ctx.restore();
159
+ // desk
160
+ const deskY = P ? st.y + st.h - 150 : st.y + st.h - 90;
161
+ ctx.save(); ctx.globalAlpha = a0;
162
+ const dg = ctx.createLinearGradient(0, deskY, 0, deskY + 200); dg.addColorStop(0, '#2b1e3e'); dg.addColorStop(1, '#0a0612');
163
+ ctx.fillStyle = dg; ctx.fillRect(0, deskY, L.W, 260);
164
+ ctx.fillStyle = 'rgba(153,69,255,.35)'; ctx.fillRect(0, deskY, L.W, 3);
165
+ ctx.restore();
166
+ // lamp
167
+ const lx = P ? st.x + st.w - 60 : st.cx + 640;
168
+ ctx.save(); ctx.globalAlpha = a0;
169
+ ctx.strokeStyle = '#3a2f5e'; ctx.lineWidth = 10; ctx.beginPath(); ctx.moveTo(lx, deskY); ctx.lineTo(lx + 20, deskY - 200); ctx.lineTo(lx - 70, deskY - 300); ctx.stroke();
170
+ ctx.fillStyle = '#4a3c78'; ctx.beginPath(); ctx.moveTo(lx - 120, deskY - 270); ctx.lineTo(lx - 30, deskY - 330); ctx.lineTo(lx - 10, deskY - 290); ctx.closePath(); ctx.fill();
171
+ const cg = ctx.createLinearGradient(lx - 70, deskY - 290, lx - 180, deskY); cg.addColorStop(0, 'rgba(255,214,140,.35)'); cg.addColorStop(1, 'rgba(255,214,140,0)');
172
+ ctx.fillStyle = cg; ctx.beginPath(); ctx.moveTo(lx - 110, deskY - 280); ctx.lineTo(lx - 30, deskY - 320); ctx.lineTo(lx + 60, deskY); ctx.lineTo(lx - 330, deskY); ctx.fill();
173
+ ctx.restore();
174
+ glow(ctx, lx - 90, deskY - 280, 120, GOLD, .5 * a0);
175
+ // laptop (screen faces the person and us)
176
+ const px = P ? st.cx - 140 : st.cx - 170, lpx = P ? st.cx + 110 : st.cx + 140;
177
+ const sw = P ? 250 : 300, sh = sw * .64;
178
+ ctx.save(); ctx.globalAlpha = a0;
179
+ rr(ctx, lpx - sw / 2, deskY - sh - 18, sw, sh, 12); ctx.fillStyle = '#121022'; ctx.fill(); ctx.strokeStyle = '#3a2f6e'; ctx.lineWidth = 4; ctx.stroke();
180
+ ctx.save(); rr(ctx, lpx - sw / 2 + 10, deskY - sh - 8, sw - 20, sh - 20, 6); ctx.clip();
181
+ ctx.fillStyle = '#0b1424'; ctx.fillRect(lpx - sw / 2, deskY - sh - 18, sw, sh);
182
+ const r = rng(8);
183
+ for (let i = 0; i < 14; i++) {
184
+ const yy = deskY - sh - 2 + i * 14 - ((t * 22) % 14);
185
+ const len = (.2 + r() * .7) * (sw - 50);
186
+ ctx.fillStyle = [GREEN, PURPLE, TEAL, '#ffffff'][i % 4]; ctx.globalAlpha = .65 * a0;
187
+ ctx.fillRect(lpx - sw / 2 + 22 + (i % 3) * 12, yy, len, 5);
188
+ }
189
+ ctx.restore();
190
+ ctx.fillStyle = '#2a2448'; ctx.fillRect(lpx - sw / 2 - 20, deskY - 18, sw + 40, 18);
191
+ ctx.restore();
192
+ glow(ctx, lpx, deskY - sh / 2, sw, TEAL, .35 * a0);
193
+ person(ctx, px, deskY + 20, P ? 1.05 : 1.15);
194
+ // wall clock
195
+ const ckx = P ? st.x + 80 : st.cx - 650, cky = P ? st.y + 470 : st.y + 120;
196
+ dial(ctx, ckx, cky, 62, { alpha: a0 * .9, hand: Math.floor(T) / 60, minute: .62, ticks: 12, lw: 3, accent: RED });
197
+ text(ctx, '2017.11 深夜', ckx, cky + 96, { size: 24, fam: F_SC, weight: 700, color: 'rgba(255,255,255,.65)', align: 'center', alpha: a0 });
198
+ // c1: name card
199
+ const ac = win(t, c1, .5) * (1 - win(t, c4 - .3, .4));
200
+ if (ac > 0) {
201
+ const cw = P ? 520 : 560, ch = 200, cx = P ? st.cx : st.x + 300, cy = P ? st.y + 180 : st.cy + 40;
202
+ ctx.save(); ctx.globalAlpha = ac; ctx.translate((1 - ac) * -60, 0);
203
+ glass(ctx, cx - cw / 2, cy - ch / 2, cw, ch, {});
204
+ text(ctx, 'Anatoly Yakovenko', cx, cy - 50, { size: 46, fam: F_GR, weight: 700, color: WHITE, align: 'center', maxW: cw - 40 });
205
+ text(ctx, '阿纳托利 · 亚科文科', cx, cy + 8, { size: 32, weight: 700, color: GREEN, align: 'center' });
206
+ text(ctx, 'Solana 创始人 · 前高通工程师', cx, cy + 58, { size: 26, weight: 500, color: 'rgba(255,255,255,.7)', align: 'center' });
207
+ ctx.restore();
208
+ }
209
+ // c2: skill tags rising from the laptop
210
+ const tags = [['压缩算法', -1], ['分布式系统', 0], ['高通 · 十几年', 1]];
211
+ tags.forEach(([tg, k], i) => {
212
+ const a = win(t, c2 + i * .45, .5) * (1 - win(t, c3, .5));
213
+ if (a <= 0) return;
214
+ const x = lpx + k * (P ? 150 : 200), y = deskY - sh - 80 - i * 70 - eOut(a) * 40;
215
+ pill(ctx, tg, x, y, { size: P ? 26 : 28, color: [GREEN, TEAL, PURPLE][i], fg: i === 2 ? '#fff' : '#0b0820', alpha: a });
216
+ });
217
+ // c3: two coffees + one beer
218
+ const cupsX = P ? [lpx + 150, lpx + 210, lpx + 280] : [lpx + 230, lpx + 310, lpx + 400];
219
+ const tc = [s.at(3, '两杯'), s.at(3, '两杯') + .35, s.at(3, '啤酒')];
220
+ ['coffee', 'coffee', 'beer'].forEach((k, i) => cup(ctx, cupsX[i], deskY + 2, k, win(t, tc[i], .45, x => x)));
221
+ // c4: the idea — a clock for the blockchain
222
+ const ai = win(t, c4 - .1, .5);
223
+ if (ai > 0) {
224
+ ctx.save(); ctx.fillStyle = `rgba(4,2,12,${.55 * ai})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore();
225
+ const ix = st.cx, iy = P ? st.cy - 120 : st.cy - 60, ir = P ? 190 : 170;
226
+ glow(ctx, ix, iy, ir * 2.4, GREEN, .5 * ai);
227
+ dial(ctx, ix, iy, ir * eBack(ai), { alpha: ai, hand: t * .8, minute: t * .05, lw: 6, accent: GREEN });
228
+ burst(ctx, ix, iy, t - c4, { r: 480, n: 50, seed: 9, color: GOLD });
229
+ ring(ctx, ix, iy, t - c4 - .1, { r: 600, color: GOLD });
230
+ gradText(ctx, '给区块链装上时钟?', ix, iy + ir + (P ? 110 : 90), { size: P ? 78 : 88, align: 'center', alpha: win(t, c4 + .3, .5), c1: GOLD, c2: GREEN });
231
+ }
232
+ },
233
+ sfx(s) {
234
+ return [
235
+ { t: s.cue(1), k: 'pop', v: .7 },
236
+ { t: s.cue(2), k: 'pop', v: .5 }, { t: s.cue(2) + .45, k: 'pop', v: .5 }, { t: s.cue(2) + .9, k: 'pop', v: .5 },
237
+ { t: s.at(3, '两杯'), k: 'clink' }, { t: s.at(3, '两杯') + .35, k: 'clink' }, { t: s.at(3, '啤酒'), k: 'clink', v: 1.1 },
238
+ { t: s.cue(4) - .1, k: 'idea', v: 1 }, { t: s.cue(4), k: 'shimmer' },
239
+ ];
240
+ },
241
+ };
242
+
243
+ // ---------------------------------------------------------------- 3. PROOF OF HISTORY
244
+ const pohCache = {};
245
+ const poh = {
246
+ draw(ctx, L, s) {
247
+ const { t } = s, st = L.stage, P = L.portrait;
248
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4), c5 = s.cue(5), c6 = s.cue(6);
249
+ const N = 8, rx = P ? st.w * .4 : st.w * .4, ry = P ? st.h * .4 : st.h * .4;
250
+ const nodes = Array.from({ length: N }, (_, i) => { const a = i / N * Math.PI * 2 - Math.PI / 2; return { x: st.cx + Math.cos(a) * rx, y: st.cy + Math.sin(a) * ry, sp: .3 + hash1(i) * 1.6, off: hash1(i + 9) }; });
251
+ const dim = 1 - .75 * win(t, c3 - .2, .6) * (1 - win(t, c6, .6));
252
+ const sync = win(t, c6, 1.2, eIO);
253
+ // c2: chaotic messaging between every pair
254
+ const aMsg = win(t, c2, .5) * (1 - win(t, c3 - .2, .5));
255
+ if (aMsg > 0) {
256
+ ctx.save();
257
+ for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) {
258
+ const A = nodes[i], B = nodes[j];
259
+ ctx.strokeStyle = `rgba(255,90,120,${.18 * aMsg})`; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke();
260
+ for (let k = 0; k < 2; k++) {
261
+ const p = ((t - c2) * (.5 + hash1(i * 9 + j + k * 50)) + hash1(i + j * 3 + k)) % 1;
262
+ const fw = (i + j + k) % 2 ? p : 1 - p;
263
+ const x = lerp(A.x, B.x, fw), y = lerp(A.y, B.y, fw);
264
+ ctx.fillStyle = `rgba(255,120,150,${aMsg})`; ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill();
265
+ }
266
+ }
267
+ ctx.restore();
268
+ const n = Math.floor(Math.pow(Math.max(0, t - c2), 2) * 180);
269
+ text(ctx, '确认中…', st.cx, st.cy - 30, { size: P ? 60 : 64, weight: 900, color: '#ff9fb2', align: 'center', alpha: aMsg });
270
+ text(ctx, `消息往返 × ${n.toLocaleString()}`, st.cx, st.cy + 40, { size: 30, fam: F_MONO, weight: 600, color: 'rgba(255,255,255,.75)', align: 'center', alpha: aMsg });
271
+ }
272
+ // c6: synced links from the PoH core to every node
273
+ const core = { x: st.cx, y: P ? st.cy - 150 : st.cy - 110 };
274
+ if (sync > 0) {
275
+ ctx.save();
276
+ for (const n of nodes) {
277
+ ctx.strokeStyle = hexA(GREEN, .35 * sync); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(core.x, core.y); ctx.lineTo(n.x, n.y); ctx.stroke();
278
+ const p = ((t - c6) * 1.4) % 1; ctx.fillStyle = hexA(GREEN, sync); ctx.beginPath(); ctx.arc(lerp(core.x, n.x, p), lerp(core.y, n.y, p), 6, 0, 7); ctx.fill();
279
+ }
280
+ ctx.restore();
281
+ }
282
+ // nodes with their own clocks
283
+ nodes.forEach((n, i) => {
284
+ const a = win(t, .2 + i * .12, .5) * dim;
285
+ if (a <= 0) return;
286
+ const own = n.off + t * n.sp * .3, shared = t * .25;
287
+ const hand = lerp(own, shared, sync);
288
+ monitor(ctx, n.x, n.y + 30, P ? 92 : 100, { color: sync > .5 ? GREEN : (aMsg > .3 ? RED : TEAL), alpha: a, on: .7 });
289
+ dial(ctx, n.x, n.y - 46, 30, { alpha: a, hand, ticks: 12, lw: 2, accent: sync > .5 ? GREEN : GOLD });
290
+ const aq = win(t, c1 + i * .08, .3, eBack) * (1 - win(t, c3 - .2, .4));
291
+ if (aq > 0) text(ctx, '?', n.x + 42, n.y - 72, { size: 56, weight: 900, fam: F_GR, color: GOLD, align: 'center', alpha: clamp(aq) });
292
+ });
293
+ // c3+: SHA-256 core and the chain of hashes
294
+ const aCore = win(t, c3, .7) ;
295
+ if (aCore > 0) {
296
+ const R = P ? 120 : 110;
297
+ glow(ctx, core.x, core.y, R * 3, GREEN, .45 * aCore);
298
+ ctx.save(); ctx.translate(core.x, core.y); ctx.rotate(t * .6); ctx.globalAlpha = aCore;
299
+ for (let k = 0; k < 2; k++) {
300
+ ctx.beginPath(); for (let i = 0; i < 6; i++) { const a = i / 6 * Math.PI * 2; ctx[i ? 'lineTo' : 'moveTo'](Math.cos(a) * R * (1 - k * .22), Math.sin(a) * R * (1 - k * .22)); } ctx.closePath();
301
+ ctx.strokeStyle = k ? PURPLE : GREEN; ctx.lineWidth = k ? 3 : 6; ctx.stroke();
302
+ }
303
+ ctx.restore();
304
+ ctx.save(); ctx.globalAlpha = aCore; ctx.fillStyle = 'rgba(6,20,16,.85)'; ctx.beginPath(); ctx.arc(core.x, core.y, R * .66, 0, 7); ctx.fill(); ctx.restore();
305
+ text(ctx, 'SHA-256', core.x, core.y, { size: P ? 36 : 34, fam: F_MONO, weight: 800, color: GREEN, align: 'center', alpha: aCore });
306
+ text(ctx, '历史证明 · Proof of History', core.x, core.y - R - 46, { size: P ? 34 : 34, weight: 800, color: WHITE, align: 'center', alpha: win(t, c3 + .4, .5) * (1 - win(t, c6, .5)) });
307
+ }
308
+ // chain
309
+ const aCh = win(t, c4 - .3, .6);
310
+ if (aCh > 0) {
311
+ const per = .55, gap = P ? 196 : 214, bw = P ? 170 : 186, bh = 70;
312
+ const cyR = P ? st.cy + 140 : st.cy + 150;
313
+ const born = Math.floor((t - (c4 - .3)) / per);
314
+ const speed = gap / per;
315
+ ctx.save(); ctx.globalAlpha = aCh;
316
+ // feeding line from core
317
+ ctx.strokeStyle = hexA(GREEN, .5); ctx.lineWidth = 3; ctx.setLineDash([8, 8]); ctx.beginPath(); ctx.moveTo(core.x, core.y + 110); ctx.lineTo(core.x, cyR - bh / 2); ctx.stroke(); ctx.setLineDash([]);
318
+ for (let b = Math.max(0, born - 12); b <= born; b++) {
319
+ const tb = (c4 - .3) + b * per;
320
+ const x = core.x - (t - tb) * speed;
321
+ if (x < -bw) continue;
322
+ const pop = eBack(inv(tb, tb + .25, t));
323
+ const isTx = t > c5 && txAt(b, c5, c4 - .3, per);
324
+ ctx.save(); ctx.translate(x, cyR); ctx.scale(pop, pop);
325
+ rr(ctx, -bw / 2, -bh / 2, bw, bh, 14); ctx.fillStyle = isTx ? 'rgba(60,44,8,.9)' : 'rgba(8,30,24,.9)'; ctx.fill();
326
+ ctx.strokeStyle = isTx ? GOLD : GREEN; ctx.lineWidth = 3; ctx.stroke();
327
+ text(ctx, hexStr(b + 17, 8), 0, -6, { size: 25, fam: F_MONO, weight: 700, color: isTx ? GOLD : GREEN, align: 'center' });
328
+ text(ctx, '#' + (1048576 + b).toLocaleString(), 0, 22, { size: 16, fam: F_MONO, weight: 500, color: 'rgba(255,255,255,.55)', align: 'center' });
329
+ if (isTx) { pill(ctx, 'TX ⏱', 0, -bh / 2 - 22, { size: 18, color: GOLD, fam: F_MONO }); }
330
+ ctx.restore();
331
+ if (b > 0) arrow(ctx, x - bw / 2 - 2, cyR, x - gap + bw / 2 + 4, cyR, hexA(GREEN, .7), 3, 12, clamp(pop));
332
+ }
333
+ ctx.restore();
334
+ const af = win(t, c4, .5) * (1 - win(t, c6 - .2, .4));
335
+ text(ctx, 'hash(n) = SHA256( hash(n−1) )', st.cx, cyR + (P ? 110 : 100), { size: P ? 30 : 34, fam: F_MONO, weight: 700, color: 'rgba(255,255,255,.85)', align: 'center', alpha: af });
336
+ // c5: falling transactions
337
+ if (t > c5 - .8) {
338
+ for (let k = 0; k < 10; k++) {
339
+ const ts = c5 + k * .9; if (t < ts - .7 || t > ts + .1) continue;
340
+ const p = eIn(inv(ts - .7, ts, t));
341
+ const x = core.x, y = lerp(st.y - 40, cyR - bh / 2 - 30, p);
342
+ ctx.save(); ctx.globalAlpha = 1 - inv(ts, ts + .1, t);
343
+ rr(ctx, x - 40, y - 26, 80, 52, 6); ctx.fillStyle = '#fff3d0'; ctx.fill();
344
+ ctx.strokeStyle = '#b57f12'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x - 40, y - 26); ctx.lineTo(x, y + 4); ctx.lineTo(x + 40, y - 26); ctx.stroke();
345
+ ctx.restore();
346
+ }
347
+ }
348
+ }
349
+ const ast = win(t, s.at(5, '时间戳'), .5) * (1 - win(t, c6 - .2, .4));
350
+ if (ast > 0) gradText(ctx, '自带时间戳', st.cx, P ? st.y + st.h - 10 : st.y + st.h + 20, { size: 56, align: 'center', alpha: ast, c1: GOLD, c2: GREEN });
351
+ // c6: verdict
352
+ const av = win(t, c6 + .2, .5);
353
+ if (av > 0) {
354
+ speedLines(ctx, L, t, av * .7, 30);
355
+ gradText(ctx, '不用对表 · 各干各的', st.cx, P ? st.y + st.h - 10 : st.y + st.h + 20, { size: P ? 64 : 70, align: 'center', alpha: av });
356
+ }
357
+ void pohCache;
358
+ },
359
+ sfx(s) {
360
+ const ev = [];
361
+ for (let i = 0; i < 8; i++) ev.push({ t: .2 + i * .12, k: 'blip', v: .35, p: i });
362
+ ev.push({ t: s.cue(1), k: 'question' });
363
+ ev.push({ t: s.cue(2), k: 'chatter', d: s.cue(3) - s.cue(2) });
364
+ ev.push({ t: s.cue(3), k: 'power', v: 1 });
365
+ for (let b = 0; b * .55 < s.cue(6) + 2 - s.cue(4); b++) ev.push({ t: s.cue(4) - .3 + b * .55, k: 'hashtick', v: .45, p: b });
366
+ for (let k = 0; k < 6; k++) ev.push({ t: s.cue(5) + k * .9, k: 'stamp', v: .5 });
367
+ ev.push({ t: s.cue(6), k: 'whoosh' }, { t: s.cue(6) + .2, k: 'shimmer' });
368
+ return ev;
369
+ },
370
+ };
371
+ function txAt(b, c5, start, per) {
372
+ // bead b is "born" at start + b*per; a TX lands on it if a drop time coincides
373
+ const tb = start + b * per;
374
+ for (let k = 0; k < 10; k++) { const ts = c5 + k * .9; if (ts >= tb && ts < tb + per) return true; }
375
+ return false;
376
+ }
377
+
378
+ // ---------------------------------------------------------------- 4. NAME 2018
379
+ const nameScene = {
380
+ draw(ctx, L, s) {
381
+ const { t, T } = s, st = L.stage, P = L.portrait, { W, H } = L;
382
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3);
383
+ const a0 = win(t, 0, 1.2);
384
+ const hy = H * (P ? .6 : .64);
385
+ // retro sunset
386
+ ctx.save(); ctx.globalAlpha = a0;
387
+ const sky = ctx.createLinearGradient(0, 0, 0, hy); sky.addColorStop(0, '#120a35'); sky.addColorStop(.55, '#6b1f6e'); sky.addColorStop(1, '#ff7a59');
388
+ ctx.fillStyle = sky; ctx.fillRect(0, 0, W, hy);
389
+ const sr = P ? 260 : 250, sx = W / 2 - (P ? 46 : 0), sy = hy - sr * .25 + Math.sin(T * .2) * 4;
390
+ glow(ctx, sx, sy, sr * 2.6, '#ff8a5c', .55);
391
+ ctx.save(); ctx.beginPath(); ctx.arc(sx, sy, sr, 0, 7); ctx.clip();
392
+ const sg = ctx.createLinearGradient(0, sy - sr, 0, sy + sr); sg.addColorStop(0, '#ffe36b'); sg.addColorStop(.6, '#ff5e8a'); sg.addColorStop(1, '#b02cff');
393
+ ctx.fillStyle = sg; ctx.fillRect(sx - sr, sy - sr, sr * 2, sr * 2);
394
+ ctx.fillStyle = '#6b1f6e';
395
+ for (let i = 0; i < 7; i++) { const yy = sy + i * sr * .14 - ((T * 12) % (sr * .14)); const hh = 3 + i * 2.2; ctx.fillRect(sx - sr, yy, sr * 2, hh); }
396
+ ctx.restore();
397
+ const sea = ctx.createLinearGradient(0, hy, 0, H); sea.addColorStop(0, '#3a1050'); sea.addColorStop(1, '#07030f');
398
+ ctx.fillStyle = sea; ctx.fillRect(0, hy, W, H - hy);
399
+ for (let i = 0; i < 22; i++) {
400
+ const yy = hy + Math.pow(i / 22, 1.6) * (H - hy), w2 = (40 + i * 26) * (.6 + .4 * Math.sin(T * 1.5 + i));
401
+ ctx.fillStyle = `rgba(255,170,120,${.5 - i * .02})`; ctx.fillRect(sx - w2 / 2 + Math.sin(T + i) * 12, yy, w2, 3 + i * .3);
402
+ }
403
+ // waves
404
+ ctx.strokeStyle = 'rgba(255,255,255,.12)'; ctx.lineWidth = 2;
405
+ for (let k = 0; k < 5; k++) { ctx.beginPath(); for (let x = 0; x <= W; x += 20) { const y = hy + 40 + k * 70 + Math.sin(x * .01 + T * (1 + k * .2) + k) * (6 + k * 3); x ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); }
406
+ ctx.restore();
407
+ // palms
408
+ palm(ctx, P ? 40 : 140, hy + 40, P ? .9 : 1.1, T, a0);
409
+ palm(ctx, P ? W - 210 : W - 160, hy + 60, P ? .75 : 1, T + 2, a0, true);
410
+ // c0: LOOM → name clash
411
+ const ty = P ? st.y + 120 : st.y + 110;
412
+ const aL = win(t, .3, .5) * (1 - win(t, c1 + .1, .4));
413
+ if (aL > 0) {
414
+ const sh = t > s.at(0, '撞') && t < s.at(0, '撞') + .4 ? shake(t, 10) : [0, 0];
415
+ text(ctx, 'LOOM', st.cx + sh[0], ty + sh[1], { size: P ? 150 : 170, fam: F_GR, weight: 700, color: WHITE, align: 'center', alpha: aL, ls: 20, shadow: 30, shadowColor: PURPLE });
416
+ const ag = win(t, s.at(0, '撞'), .4) * aL;
417
+ text(ctx, 'LOOM NETWORK', st.cx + (1 - ag) * 400 + 30, ty + 18, { size: P ? 64 : 80, fam: F_GR, weight: 700, color: 'rgba(255,90,120,.55)', align: 'center', alpha: ag, ls: 6 });
418
+ stamp(ctx, '撞名!', st.cx + (P ? 170 : 330), ty - 70, t, s.at(0, '撞') + .2, { size: P ? 64 : 70, color: RED });
419
+ }
420
+ // c1: becomes SOLANA
421
+ const aS = win(t, c1 + .1, .3);
422
+ if (aS > 0) {
423
+ const word = scramble('SOLANA', inv(c1 + .1, c1 + 1.1, t), 7);
424
+ glow(ctx, st.cx, ty, 400, PURPLE, .4 * aS);
425
+ gradText(ctx, word, st.cx, ty, { size: P ? 140 : 170, fam: F_GR, weight: 700, align: 'center', ls: P ? 14 : 22, alpha: aS, c1: '#ffe36b', c2: GREEN });
426
+ pill(ctx, '2018 · 正式更名', st.cx, ty + (P ? 110 : 120), { size: 28, alpha: win(t, c1 + 1, .4) * (1 - win(t, c2, .4)), color: GOLD });
427
+ }
428
+ // c2: map card
429
+ const am = win(t, c2, .5) * (1 - win(t, c3 + 1.5, .5));
430
+ if (am > 0) {
431
+ const cw = P ? 560 : 520, ch = P ? 420 : 380, cx = P ? st.cx : st.x + 330, cy = P ? st.cy + 210 : st.cy + 110;
432
+ ctx.save(); ctx.globalAlpha = am; ctx.translate(0, (1 - am) * 60);
433
+ glass(ctx, cx - cw / 2, cy - ch / 2, cw, ch, { fill: 'rgba(12,10,30,.82)' });
434
+ ctx.save(); rr(ctx, cx - cw / 2 + 14, cy - ch / 2 + 14, cw - 28, ch - 90, 14); ctx.clip();
435
+ ctx.fillStyle = '#123b66'; ctx.fillRect(cx - cw / 2, cy - ch / 2, cw, ch);
436
+ ctx.fillStyle = '#2c2440'; ctx.beginPath(); ctx.moveTo(cx + cw / 2, cy - ch / 2);
437
+ for (let i = 0; i <= 20; i++) { const yy = cy - ch / 2 + i / 20 * (ch - 76); ctx.lineTo(cx - 40 + Math.sin(i * .7) * 26 + i * 6, yy); }
438
+ ctx.lineTo(cx + cw / 2, cy + ch / 2); ctx.fill();
439
+ ctx.restore();
440
+ const pinT = s.at(2, 'Solana Beach');
441
+ const pins = [['Solana Beach', -.28, pinT, GREEN], ['San Diego 圣地亚哥', .22, c2 + .3, '#ffffff']];
442
+ pins.forEach(([nm, fy, pt, col], i) => {
443
+ const pa = win(t, pt, .5, eBack); if (pa <= 0) return;
444
+ const x = cx - 10 + (i ? 60 : 0) + Math.sin(fy * 8) * 10, y = cy + fy * (ch - 90) - 40 - (1 - pa) * 80;
445
+ ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y - 30, 16, Math.PI, 0); ctx.lineTo(x, y); ctx.closePath(); ctx.fill();
446
+ if (i === 0) glow(ctx, x, y - 30, 70, GREEN, .7 * pa);
447
+ text(ctx, nm, x + 28, y - 30, { size: i ? 24 : 32, fam: F_GR, weight: 700, color: col, alpha: pa, stroke: 5, strokeColor: 'rgba(0,0,0,.6)' });
448
+ });
449
+ text(ctx, '加州 · 圣地亚哥以北的海滨小镇', cx, cy + ch / 2 - 38, { size: 28, weight: 700, color: 'rgba(255,255,255,.85)', align: 'center' });
450
+ ctx.restore();
451
+ }
452
+ // c3: surfer + code
453
+ const aSf = win(t, c3, .5);
454
+ if (aSf > 0) {
455
+ const p = inv(c3, s.d, t);
456
+ const x = lerp(P ? -100 : st.x + 500, P ? W * .75 : W - 200, p), y = hy + (P ? 120 : 90) + Math.sin(T * 2.4) * 10;
457
+ ctx.save(); ctx.globalAlpha = aSf;
458
+ ctx.fillStyle = 'rgba(255,255,255,.75)'; ctx.beginPath(); ctx.moveTo(x - 160, y + 30); ctx.quadraticCurveTo(x - 30, y - 70, x + 70, y + 30); ctx.fill();
459
+ surfer(ctx, x, y + 6);
460
+ ctx.restore();
461
+ const codes = ['</>', '{ }', 'fn()', '0x'];
462
+ codes.forEach((c, i) => {
463
+ const a = win(t, c3 + .4 + i * .3, .5) * aSf; const fy = ((t - c3) * 30 + i * 40) % 120;
464
+ text(ctx, c, x - 80 + i * 70, y - 140 - fy, { size: 34, fam: F_MONO, weight: 700, color: [GREEN, TEAL, GOLD, PURPLE][i], align: 'center', alpha: a * (1 - fy / 120) });
465
+ });
466
+ }
467
+ },
468
+ sfx(s) {
469
+ return [
470
+ { t: .3, k: 'waves', d: s.d - .3 }, { t: .35, k: 'hit', v: .6 },
471
+ { t: s.at(0, '撞') + .2, k: 'stamp', v: 1 },
472
+ { t: s.cue(1) + .1, k: 'scramble', d: 1 }, { t: s.cue(1) + 1.1, k: 'shimmer' },
473
+ { t: s.at(2, 'Solana Beach'), k: 'pop' }, { t: s.cue(2) + .3, k: 'pop', v: .5 },
474
+ { t: s.cue(3), k: 'whoosh', v: .6 },
475
+ ];
476
+ },
477
+ };
478
+ function palm(ctx, x, y, s, T, a, flip) {
479
+ ctx.save(); ctx.globalAlpha = a; ctx.translate(x, y); ctx.scale(flip ? -s : s, s); ctx.fillStyle = '#12051c'; ctx.strokeStyle = '#12051c';
480
+ ctx.lineWidth = 16; ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(30, -180, 90, -330); ctx.stroke();
481
+ for (let i = 0; i < 6; i++) {
482
+ const a0 = -Math.PI / 2 + (i - 2.5) * .55 + Math.sin(T * 1.2 + i) * .04;
483
+ ctx.save(); ctx.translate(90, -330); ctx.rotate(a0 + Math.PI / 2);
484
+ ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(60, -30, 150, 30); ctx.quadraticCurveTo(70, 0, 0, 10); ctx.fill();
485
+ ctx.restore();
486
+ }
487
+ ctx.restore();
488
+ }
489
+ function surfer(ctx, x, y) {
490
+ ctx.save(); ctx.translate(x, y); ctx.fillStyle = '#1a0a24';
491
+ ctx.beginPath(); ctx.ellipse(0, 0, 70, 9, -.08, 0, 7); ctx.fill();
492
+ ctx.lineWidth = 9; ctx.strokeStyle = '#1a0a24'; ctx.lineCap = 'round';
493
+ ctx.beginPath(); ctx.moveTo(-22, -4); ctx.lineTo(-8, -40); ctx.lineTo(16, -4); ctx.stroke();
494
+ ctx.beginPath(); ctx.moveTo(-8, -40); ctx.lineTo(0, -80); ctx.stroke();
495
+ ctx.beginPath(); ctx.moveTo(-34, -70); ctx.lineTo(0, -70); ctx.lineTo(36, -62); ctx.stroke();
496
+ ctx.beginPath(); ctx.arc(2, -96, 13, 0, 7); ctx.fill();
497
+ ctx.restore();
498
+ }
499
+
500
+ // ---------------------------------------------------------------- 5. BUILD 2018-19
501
+ const TEAM = [
502
+ { ini: 'AY', name: 'Anatoly Yakovenko', role: 'CEO · 创始人', sub: '前高通', c1: PURPLE, c2: GREEN, k: [0, null] },
503
+ { ini: 'GF', name: 'Greg Fitzgerald', role: '联合创始人 · CTO', sub: '前高通', c1: '#00D1FF', c2: PURPLE, k: [0, 'Greg'] },
504
+ { ini: 'SA', name: 'Stephen Akridge', role: '联合创始人', sub: '前高通', c1: GOLD, c2: PINK, k: [0, 'Stephen'] },
505
+ { ini: 'RG', name: 'Raj Gokal', role: '联合创始人 · COO', sub: '产品与增长', c1: GREEN, c2: '#00D1FF', k: [1, 'Raj'] },
506
+ ];
507
+ const build = {
508
+ draw(ctx, L, s) {
509
+ const { t } = s, st = L.stage, P = L.portrait;
510
+ const c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4);
511
+ const out = win(t, c2 - .2, .6, eIn);
512
+ TEAM.forEach((m, i) => {
513
+ const at = m.k[1] ? s.at(m.k[0], m.k[1]) : .3;
514
+ const a = win(t, at, .55) * (1 - out);
515
+ let x, y, w;
516
+ if (P) { w = 360; x = st.cx + (i % 2 ? 195 : -195); y = st.cy + (i < 2 ? -225 : 225); }
517
+ else { w = 360; x = st.cx + (i - 1.5) * 400; y = st.cy - 10; }
518
+ personCard(ctx, x, y - out * 200, w, { ...m, a });
519
+ });
520
+ // testnet network
521
+ const rect = P ? { x: st.x + 30, y: st.y + 160, w: st.w - 60, h: st.h - 260 } : { x: st.x + 40, y: st.y + 60, w: st.w - 80, h: st.h - 120 };
522
+ const key = L.orient;
523
+ build._n = build._n || {};
524
+ const nodes = build._n[key] || (build._n[key] = nodesIn(11, P ? 34 : 46, rect, P ? 95 : 120));
525
+ build._l = build._l || {};
526
+ const links = build._l[key] || (build._l[key] = linksFor(nodes, 3));
527
+ const aN = win(t, c2 - .1, .5) * (1 - .6 * win(t, c3, .5)) * (1 - win(t, c4, .5));
528
+ if (aN > 0) {
529
+ ctx.save();
530
+ links.forEach(([i, j], k) => {
531
+ const ti = Math.max(onT(i, c2), onT(j, c2)); const a = win(t, ti, .3) * aN; if (a <= 0) return;
532
+ const A = nodes[i], B = nodes[j];
533
+ ctx.strokeStyle = hexA(TEAL, .35 * a); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke();
534
+ const p = ((t * .8) + hash1(k)) % 1; ctx.fillStyle = hexA(GREEN, a); ctx.beginPath(); ctx.arc(lerp(A.x, B.x, p), lerp(A.y, B.y, p), 3.5, 0, 7); ctx.fill();
535
+ });
536
+ nodes.forEach((n, i) => {
537
+ const a = win(t, onT(i, c2), .3, eBack) * aN; if (a <= 0) return;
538
+ glow(ctx, n.x, n.y, 40, GREEN, .5 * clamp(a));
539
+ ctx.fillStyle = WHITE; ctx.globalAlpha = clamp(a); ctx.beginPath(); ctx.arc(n.x, n.y, 6 + n.s * 4, 0, 7); ctx.fill(); ctx.globalAlpha = 1;
540
+ });
541
+ ctx.restore();
542
+ const lit = nodes.filter((_, i) => t > onT(i, c2)).length;
543
+ pill(ctx, `TESTNET · 2018 · 节点 ${lit}`, st.cx, P ? st.y + 60 : st.y + 10, { size: 30, alpha: aN, color: TEAL, fam: F_GR });
544
+ }
545
+ // Series A
546
+ const aF = win(t, s.at(3, '两千万') - .3, .5) * (1 - win(t, c4, .5));
547
+ if (aF > 0) {
548
+ // coin rain
549
+ for (let k = 0; k < 26; k++) {
550
+ const t0 = c3 + k * .14, dt = t - t0; if (dt < 0) continue;
551
+ const x = st.x + hash1(k * 3) * st.w, y = st.y - 80 + dt * dt * 900 + dt * 200;
552
+ if (y > L.H + 80) continue;
553
+ coin(ctx, x, y, 34, { label: '$', c1: GOLD, c2: '#ff9f2e', spin: dt * 6 + k, alpha: aF });
554
+ }
555
+ const p = eOut(inv(s.at(3, '两千万'), s.at(3, '两千万') + 1.3, t));
556
+ const v = Math.round(20000000 * p / 10000) * 10000;
557
+ ctx.save(); ctx.globalAlpha = aF; glow(ctx, st.cx, st.cy, 500, GOLD, .35);
558
+ bigNum(ctx, '$' + v.toLocaleString('en-US'), st.cx, st.cy - 30, P ? 108 : 150, { c1: GOLD, c2: GREEN });
559
+ text(ctx, '2019 · A 轮融资', st.cx, st.cy + (P ? 60 : 80), { size: P ? 40 : 44, weight: 800, color: WHITE, align: 'center' });
560
+ text(ctx, 'Multicoin Capital 领投', st.cx, st.cy + (P ? 120 : 140), { size: P ? 34 : 36, weight: 700, fam: F_GR, color: GOLD, align: 'center' });
561
+ ctx.restore();
562
+ }
563
+ // waiting rocket
564
+ const aR = win(t, c4 - .1, .6);
565
+ if (aR > 0) {
566
+ const ry = st.y + st.h - (P ? 150 : 110);
567
+ ctx.save(); ctx.globalAlpha = aR; ctx.fillStyle = '#231a40'; ctx.fillRect(st.cx - 160, ry + 70, 320, 18); ctx.fillRect(st.cx + 70, ry - 160, 16, 240); ctx.restore();
568
+ rocket(ctx, st.cx, ry - 10, P ? 1.6 : 1.5, { alpha: aR, flame: .15 + .1 * Math.sin(t * 9), t });
569
+ const blink = Math.sin(t * 6) > 0 ? 1 : .35;
570
+ gradText(ctx, 'MAINNET ?', st.cx, P ? st.y + 120 : st.y + 90, { size: P ? 96 : 110, fam: F_GR, weight: 700, align: 'center', alpha: aR * blink, ls: 8 });
571
+ }
572
+ },
573
+ sfx(s) {
574
+ const ev = TEAM.map(m => ({ t: m.k[1] ? s.at(m.k[0], m.k[1]) : .3, k: 'card' }));
575
+ for (let i = 0; i < 12; i++) ev.push({ t: s.cue(2) + i * .12, k: 'blip', v: .3, p: i });
576
+ ev.push({ t: s.at(3, '两千万') - .2, k: 'coins', d: 1.6 }, { t: s.at(3, '两千万') + 1.3, k: 'cash' });
577
+ ev.push({ t: s.cue(4), k: 'rumble', d: s.d - s.cue(4) });
578
+ return ev;
579
+ },
580
+ };
581
+ const onT = (i, c2) => c2 + hash1(i * 7 + 1) * 1.8;
582
+
583
+ // ---------------------------------------------------------------- 6. MAINNET 2020
584
+ const mainnet = {
585
+ draw(ctx, L, s) {
586
+ const { t, T } = s, st = L.stage, P = L.portrait, { W, H } = L;
587
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3);
588
+ const launch = s.at(2, 'Solana');
589
+ const storm = 1 - win(t, launch + .5, 1.5);
590
+ // storm + lightning
591
+ rain(ctx, L, T, 180, .45 * storm, '#ff9fb0');
592
+ const flashes = [c1 + .4, s.at(1, '暴跌') + .1, s.at(1, '腰斩'), c2 - .4];
593
+ for (const f of flashes) { const d = t - f; if (d > 0 && d < .35) { ctx.fillStyle = `rgba(255,220,230,${(1 - d / .35) * .35 * storm})`; ctx.fillRect(0, 0, W, H); } }
594
+ // rocket on pad until launch, then lift-off
595
+ const lp = Math.max(0, t - launch);
596
+ const ry = st.y + st.h - (P ? 150 : 110) - lp * lp * 160;
597
+ const shk = t > launch - .8 && t < launch + 1.2 ? shake(t, 6) : [0, 0];
598
+ if (ry > -300) {
599
+ const pad = 1 - win(t, launch + 1, .5);
600
+ ctx.save(); ctx.globalAlpha = pad; ctx.fillStyle = '#231a40'; ctx.fillRect(st.cx - 160, st.y + st.h - (P ? 150 : 110) + 60, 320, 18); ctx.restore();
601
+ // smoke
602
+ if (t > launch - .8) {
603
+ for (let k = 0; k < 40; k++) {
604
+ const t0 = launch - .8 + k * .06, d = t - t0; if (d < 0 || d > 3) continue;
605
+ const dir = hash1(k) > .5 ? 1 : -1;
606
+ const x = st.cx + dir * d * (120 + hash1(k + 5) * 260), y = st.y + st.h - (P ? 90 : 50) - d * 30;
607
+ ctx.save(); ctx.globalAlpha = (1 - d / 3) * .5; ctx.fillStyle = '#d9cfe8'; ctx.beginPath(); ctx.arc(x, y, 30 + d * 50, 0, 7); ctx.fill(); ctx.restore();
608
+ }
609
+ }
610
+ rocket(ctx, st.cx + shk[0], ry + shk[1], P ? 1.6 : 1.5, { flame: t > launch - .8 ? 1 : .1, t });
611
+ }
612
+ // c1: 312 crash chart (BTC, Surf daily data)
613
+ const aC = win(t, c1, .5) * (1 - win(t, c2 + .2, .5));
614
+ if (aC > 0) {
615
+ const rect = P ? { x: st.x + 60, y: st.y + 140, w: st.w - 100, h: 360 } : { x: st.x + 120, y: st.y + 40, w: 900, h: 420 };
616
+ const p = inv(c1 + .2, s.at(1, '腰斩') + .6, t);
617
+ ctx.save(); ctx.globalAlpha = aC;
618
+ glass(ctx, rect.x - 40, rect.y - 70, rect.w + 80, rect.h + 130, { border: RED, fill: 'rgba(30,6,12,.75)' });
619
+ text(ctx, 'BTC · 2020 年 3 月(日线)', rect.x, rect.y - 30, { size: 26, weight: 700, color: 'rgba(255,255,255,.8)' });
620
+ const hi = 9300, lo = 4400, n = BTC_2020_03.length - 1;
621
+ const X = i => rect.x + i / n * rect.w, Y = v => rect.y + (hi - v) / (hi - lo) * rect.h;
622
+ ctx.beginPath(); const upto = p * n;
623
+ for (let i = 0; i <= Math.floor(upto); i++) { const v = BTC_2020_03[i][1]; i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v)); }
624
+ const fi = Math.floor(upto); let hx = X(0), hy2 = Y(BTC_2020_03[0][1]);
625
+ if (fi < n) { const v = lerp(BTC_2020_03[fi][1], BTC_2020_03[fi + 1][1], upto - fi); hx = X(upto); hy2 = Y(v); ctx.lineTo(hx, hy2); } else { hx = X(n); hy2 = Y(BTC_2020_03[n][1]); }
626
+ ctx.strokeStyle = RED; ctx.lineWidth = 6; ctx.lineJoin = 'round'; ctx.stroke();
627
+ glow(ctx, hx, hy2, 50, RED, .9);
628
+ BTC_2020_03.forEach(([d], i) => { if (i % 2 === 0 || i === n) text(ctx, d.replace('03-', '3/'), X(i), rect.y + rect.h + 30, { size: 18, fam: F_MONO, color: 'rgba(255,255,255,.5)', align: 'center' }); });
629
+ ctx.restore();
630
+ stamp(ctx, '3.12', P ? st.cx + 250 : rect.x + rect.w + 230, P ? st.y + 600 : st.y + 170, t, s.at(1, '312') - .2, { size: P ? 90 : 120, color: RED, rot: -.1 });
631
+ const ak = win(t, s.at(1, '腰斩'), .4) * aC;
632
+ text(ctx, '≈ −50%', P ? st.cx : rect.x + rect.w + 230, P ? st.y + 740 : st.y + 360, { size: P ? 80 : 96, fam: F_GR, weight: 700, color: RED, align: 'center', alpha: ak, shadow: 30, shadowColor: RED });
633
+ }
634
+ // c2: calendar flip 12 → 16
635
+ const aK = win(t, c2 - .1, .4) * (1 - win(t, launch + .8, .5));
636
+ if (aK > 0) {
637
+ const day = 12 + Math.min(4, Math.floor(inv(c2, s.at(2, '3 月 16') + .2, t) * 4.999));
638
+ const x = P ? st.cx : st.cx - 480, y = P ? st.y + 230 : st.cy - 60;
639
+ ctx.save(); ctx.globalAlpha = aK;
640
+ rr(ctx, x - 130, y - 150, 260, 300, 26); ctx.fillStyle = '#f4f0ff'; ctx.fill();
641
+ rr(ctx, x - 130, y - 150, 260, 80, 26); ctx.fillStyle = day === 16 ? GREEN : RED; ctx.fill(); ctx.fillRect(x - 130, y - 100, 260, 30);
642
+ text(ctx, '2020 · 3 月', x, y - 112, { size: 32, weight: 800, color: '#fff', align: 'center' });
643
+ text(ctx, String(day), x, y + 50, { size: 150, fam: F_GR, weight: 700, color: '#1a1030', align: 'center' });
644
+ ctx.restore();
645
+ }
646
+ // c3: genesis block
647
+ const aG = win(t, c3 - .3, .7);
648
+ if (aG > 0) {
649
+ const gx = st.cx, gy = P ? st.cy - 40 : st.cy - 20, R = P ? 150 : 140;
650
+ glow(ctx, gx, gy, R * 3, GREEN, .5 * aG);
651
+ cube(ctx, gx, gy, R * eBack(aG), t);
652
+ ring(ctx, gx, gy, t - c3, { r: 520, color: GREEN, dur: 1.2 });
653
+ gradText(ctx, 'MAINNET BETA', gx, gy - R - (P ? 130 : 110), { size: P ? 80 : 92, fam: F_GR, weight: 700, align: 'center', alpha: aG, ls: 6 });
654
+ text(ctx, '2020.03.16 · 创世区块 Block 0', gx, gy - R - (P ? 60 : 44), { size: P ? 34 : 36, weight: 800, color: WHITE, align: 'center', alpha: aG });
655
+ // chain begins
656
+ for (let b = 0; b < 8; b++) {
657
+ const tb = c3 + .6 + b * .45, a = win(t, tb, .3, eBack); if (a <= 0) continue;
658
+ const dir = b % 2 ? 1 : -1, k = Math.floor(b / 2) + 1;
659
+ const bx = gx + dir * (R + 30 + k * (P ? 90 : 120)), by = gy + R + (P ? 90 : 60);
660
+ ctx.save(); ctx.globalAlpha = clamp(a); rr(ctx, bx - 36, by - 24, 72, 48, 10); ctx.fillStyle = 'rgba(8,30,24,.9)'; ctx.fill(); ctx.strokeStyle = GREEN; ctx.lineWidth = 3; ctx.stroke(); ctx.restore();
661
+ text(ctx, String(b + 1), bx, by + 2, { size: 22, fam: F_MONO, weight: 700, color: GREEN, align: 'center', alpha: clamp(a) });
662
+ }
663
+ }
664
+ },
665
+ sfx(s) {
666
+ const launch = s.at(2, 'Solana');
667
+ return [
668
+ { t: 0, k: 'rainloop', d: launch + 1.5 },
669
+ { t: s.cue(1) + .4, k: 'thunder' }, { t: s.at(1, '暴跌') + .1, k: 'thunder', v: .8 }, { t: s.at(1, '腰斩'), k: 'thunder' },
670
+ { t: s.cue(1) + .2, k: 'drop', v: .8 }, { t: s.at(1, '312') - .2, k: 'stamp', v: 1 },
671
+ { t: s.cue(2), k: 'flip' }, { t: s.cue(2) + .35, k: 'flip' }, { t: s.cue(2) + .7, k: 'flip' }, { t: s.at(2, '3 月 16') - .1, k: 'flip' },
672
+ { t: launch - .8, k: 'rocket', d: 3.5 }, { t: s.cue(3), k: 'impact', v: .8 }, { t: s.cue(3) + .1, k: 'shimmer' },
673
+ ];
674
+ },
675
+ };
676
+ function cube(ctx, x, y, R, t) {
677
+ const a = t * .5;
678
+ const pts = []; for (let i = 0; i < 8; i++) { const px = i & 1 ? 1 : -1, py = i & 2 ? 1 : -1, pz = i & 4 ? 1 : -1; const rx = px * Math.cos(a) - pz * Math.sin(a), rz = px * Math.sin(a) + pz * Math.cos(a); const ry = py * Math.cos(.5) - rz * Math.sin(.5), rz2 = py * Math.sin(.5) + rz * Math.cos(.5); pts.push([x + rx * R * .6, y + ry * R * .6, rz2]); }
679
+ const faces = [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]];
680
+ const sorted = faces.map(f => ({ f, z: f.reduce((s, i) => s + pts[i][2], 0) })).sort((p, q) => p.z - q.z);
681
+ ctx.save();
682
+ sorted.forEach(({ f }, k) => {
683
+ ctx.beginPath(); f.forEach((i, j) => ctx[j ? 'lineTo' : 'moveTo'](pts[i][0], pts[i][1])); ctx.closePath();
684
+ ctx.fillStyle = `rgba(${20 + k * 10},${120 + k * 18},${110 + k * 10},.35)`; ctx.fill(); ctx.strokeStyle = k > 2 ? GREEN : hexA(GREEN, .5); ctx.lineWidth = 3; ctx.stroke();
685
+ });
686
+ ctx.restore();
687
+ text(ctx, '0', x, y, { size: R * .5, fam: F_GR, weight: 700, color: WHITE, align: 'center', shadow: 20, shadowColor: GREEN });
688
+ }
689
+
690
+ // ---------------------------------------------------------------- 7. RISE 2021
691
+ const ECO = [['Serum', '订单簿交易所', '#00D1FF'], ['Raydium', '自动做市商 AMM', PURPLE], ['Phantom', '钱包', '#ab9ff2'], ['Magic Eden', 'NFT 市场', PINK]];
692
+ const rise = {
693
+ draw(ctx, L, s) {
694
+ const { t } = s, st = L.stage, P = L.portrait;
695
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4);
696
+ const rect = P ? { x: st.x + 90, y: st.y + 400, w: st.w - 120, h: st.h - 430 } : { x: st.x + 110, y: st.y + 30, w: st.w - 140, h: st.h - 70 };
697
+ const prog = .1 * eOut(inv(.4, c3, t)) + .9 * eIO(inv(c3, s.end(3) + .4, t));
698
+ const head = drawChart(ctx, rect, { from: idx('2021-01-01'), to: idx('2021-11-07'), prog, alpha: win(t, .2, .6), yMin: 0, yMax: 280, gridVals: [50, 100, 150, 200, 250] });
699
+ // eco bubbles
700
+ ECO.forEach(([nm, cat, col], i) => {
701
+ const a = win(t, s.at(1, nm.split(' ')[0]), .5, eBack) * (1 - .55 * win(t, c3, .6));
702
+ if (a <= 0) return;
703
+ const fl = Math.sin(t * 1.3 + i * 1.7) * 10;
704
+ let x, y, r;
705
+ if (P) { r = 96; x = st.cx + (i % 2 ? 210 : -210); y = st.y + 70 + (i < 2 ? 0 : 200) + fl; }
706
+ else { r = 105; x = rect.x + 170 + (i % 2) * 290 + (i > 1 ? 120 : 0); y = rect.y + 120 + (i > 1 ? 250 : 0) + fl; }
707
+ ctx.save(); ctx.globalAlpha = clamp(a); ctx.translate(x, y); ctx.scale(a, a);
708
+ glow(ctx, 0, 0, r * 1.8, col, .4);
709
+ ctx.fillStyle = 'rgba(14,10,32,.85)'; ctx.beginPath(); ctx.arc(0, 0, r, 0, 7); ctx.fill();
710
+ ctx.strokeStyle = col; ctx.lineWidth = 5; ctx.stroke();
711
+ text(ctx, nm, 0, -12, { size: r * .3, fam: F_GR, weight: 700, color: WHITE, align: 'center', maxW: r * 1.7 });
712
+ text(ctx, cat, 0, r * .3, { size: r * .18, weight: 700, color: col, align: 'center', maxW: r * 1.7 });
713
+ ctx.restore();
714
+ });
715
+ // users/devs flowing in
716
+ const aU = win(t, c2 - .2, .5) * (1 - win(t, c3 + .5, .6));
717
+ if (aU > 0) {
718
+ ctx.save(); ctx.globalCompositeOperation = 'lighter';
719
+ for (let k = 0; k < 120; k++) {
720
+ const ang = hash1(k) * Math.PI * 2, sp = .4 + hash1(k + 3) * .6, ph = (hash1(k + 7) + (t - c2) * sp) % 1;
721
+ const R0 = Math.max(L.W, L.H) * .7;
722
+ const x = st.cx + Math.cos(ang) * R0 * (1 - ph), y = st.cy + Math.sin(ang) * R0 * (1 - ph);
723
+ ctx.fillStyle = k % 2 ? GREEN : PURPLE; ctx.globalAlpha = aU * ph; ctx.beginPath(); ctx.arc(x, y, 4, 0, 7); ctx.fill();
724
+ }
725
+ ctx.restore();
726
+ gradText(ctx, '开发者 + 用户 涌入', st.cx, P ? st.y + 340 : st.y + st.h - 120, { size: P ? 50 : 56, align: 'center', alpha: aU });
727
+ }
728
+ // ×170
729
+ if (t > c4 - .2) {
730
+ const x = P ? st.cx - 40 : rect.x + rect.w * .38, y = P ? st.y + 230 : rect.y + rect.h * .35;
731
+ stamp(ctx, '×170', x, y, t, c4, { size: P ? 120 : 150, color: GREEN, fam: F_GR, rot: -.08 });
732
+ text(ctx, '$1.51 → $258.93(日收盘)', x, y + (P ? 120 : 140), { size: P ? 32 : 36, fam: F_SC, weight: 800, color: WHITE, align: 'center', alpha: win(t, c4 + .3, .4) });
733
+ burst(ctx, head.x, head.y, t - c4, { r: 300, n: 40, seed: 8 });
734
+ }
735
+ },
736
+ sfx(s) {
737
+ const ev = [{ t: .2, k: 'riser', v: .5 }];
738
+ ECO.forEach(([nm]) => ev.push({ t: s.at(1, nm.split(' ')[0]), k: 'bubble' }));
739
+ ev.push({ t: s.cue(2) - .2, k: 'swarm', d: 2.5 });
740
+ ev.push({ t: s.cue(3), k: 'climb', d: s.end(3) + .4 - s.cue(3) });
741
+ ev.push({ t: s.cue(4), k: 'stamp', v: 1 }, { t: s.cue(4), k: 'impact', v: .7 });
742
+ return ev;
743
+ },
744
+ };
745
+
746
+ export const SCENES_A = { hook, night, poh, name: nameScene, build, mainnet, rise };
solana-clock/js/scenes-b.js ADDED
@@ -0,0 +1,788 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Scenes 8–15: outage, ftx, rebuild, meme, ath, wall, now, outro
2
+ import { clamp, lerp, inv, eOut, eIn, eIO, eBack, win, rng, hash1, hexStr, glow, text, gradText, glowText, glass, pill, rr, solLogo, dial, burst, ring, shake, measure, hexA, fmtUSD, F_SC, F_GR, F_MONO, PURPLE, GREEN, TEAL, PINK, RED, GOLD, WHITE } from './draw.js';
3
+ import { monitor, coin, stamp, bubble, nodesIn, linksFor, tower, crumble, snow, bigNum } from './props.js';
4
+ import { drawChart, idx, LAST, price } from './chart.js';
5
+
6
+ // ---------------------------------------------------------------- 8. OUTAGE
7
+ const outage = {
8
+ draw(ctx, L, s) {
9
+ const { t, T } = s, st = L.stage, P = L.portrait;
10
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4);
11
+ const rect = P ? { x: st.x + 40, y: st.y + 40, w: st.w - 80, h: st.h - 160 } : { x: st.x + 60, y: st.y + 20, w: st.w - 120, h: st.h - 80 };
12
+ outage._n = outage._n || {}; outage._l = outage._l || {};
13
+ const nodes = outage._n[L.orient] || (outage._n[L.orient] = nodesIn(23, P ? 26 : 36, rect, P ? 110 : 140));
14
+ const links = outage._l[L.orient] || (outage._l[L.orient] = linksFor(nodes, 3));
15
+ const flood = win(t, c1 + .3, 2.2, eIn);
16
+ const dead = win(t, c2 - .2, .4);
17
+ const aNet = win(t, 0, .6) * (1 - .8 * win(t, c3 - .2, .5));
18
+ if (aNet > 0) {
19
+ ctx.save();
20
+ links.forEach(([i, j], k) => {
21
+ const A = nodes[i], B = nodes[j];
22
+ ctx.strokeStyle = dead > .5 ? `rgba(90,90,110,${.25 * aNet})` : hexA(flood > .3 ? RED : TEAL, .35 * aNet); ctx.lineWidth = 2;
23
+ ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke();
24
+ if (dead < .5) {
25
+ const np = 1 + Math.floor(flood * 6);
26
+ for (let q = 0; q < np; q++) {
27
+ const p = ((t * (.7 + flood * 2.5)) + hash1(k * 7 + q)) % 1;
28
+ ctx.fillStyle = q > 0 ? hexA(RED, aNet) : hexA(GREEN, aNet); ctx.beginPath(); ctx.arc(lerp(A.x, B.x, p), lerp(A.y, B.y, p), q > 0 ? 5 : 4, 0, 7); ctx.fill();
29
+ }
30
+ }
31
+ });
32
+ nodes.forEach((n, i) => {
33
+ const off = dead > .5 && hash1(i * 3) < dead * 1.1;
34
+ const col = off ? '#3a3a4a' : flood > .4 ? RED : GREEN;
35
+ if (!off) glow(ctx, n.x, n.y, 40, col, .45 * aNet);
36
+ ctx.fillStyle = col; ctx.globalAlpha = aNet; ctx.beginPath(); ctx.arc(n.x, n.y, 8, 0, 7); ctx.fill(); ctx.globalAlpha = 1;
37
+ });
38
+ ctx.restore();
39
+ if (flood > .1 && dead < .5) {
40
+ for (let k = 0; k < 14; k++) {
41
+ const a = win(t, c1 + .4 + k * .18, .2) * (1 - dead) * aNet; if (a <= 0) continue;
42
+ const x = rect.x + hash1(k * 11) * rect.w, y = rect.y + hash1(k * 13) * rect.h;
43
+ pill(ctx, 'BOT', x, y, { size: 22, color: RED, fg: '#fff', alpha: a, fam: F_MONO });
44
+ }
45
+ text(ctx, `垃圾交易 ${(Math.floor(flood * 400000)).toLocaleString()} / 秒`, st.cx, P ? st.y + st.h - 100 : st.y + st.h - 40, { size: P ? 36 : 40, fam: F_SC, weight: 900, color: RED, align: 'center', alpha: flood, stroke: 6, strokeColor: 'rgba(0,0,0,.7)' });
46
+ }
47
+ }
48
+ // c2: breaking news + 17h timer
49
+ const aB = win(t, c2 - .1, .4) * (1 - win(t, c3 - .1, .4));
50
+ if (aB > 0) {
51
+ ctx.save(); ctx.fillStyle = `rgba(0,0,0,${.45 * aB})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore();
52
+ const hrs = 17 * eOut(inv(c2, s.end(2), t));
53
+ const hh = Math.floor(hrs), mm = Math.floor((hrs - hh) * 60), ss = Math.floor(((hrs - hh) * 60 - mm) * 60);
54
+ const tstr = `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
55
+ glowText(ctx, 'OFFLINE', st.cx, st.cy - (P ? 160 : 120), { size: P ? 120 : 150, fam: F_GR, weight: 700, color: RED, align: 'center', alpha: aB * (Math.sin(t * 10) > -.3 ? 1 : .5), glowColor: RED, ls: 10 });
56
+ text(ctx, tstr, st.cx, st.cy + (P ? 10 : 40), { size: P ? 110 : 130, fam: F_MONO, weight: 800, color: WHITE, align: 'center', alpha: aB });
57
+ newsBar(ctx, L, '突发', 'Solana 主网中断约 17 小时 · 验证者协调重启', aB, t);
58
+ }
59
+ // c3: outage markers on a timeline
60
+ const aT = win(t, c3 - .1, .5) * (1 - win(t, c4 + .2, .4));
61
+ if (aT > 0) {
62
+ const marks = [['2021.09', 0], ['2022.01', .3], ['2022.05', .45], ['2022.06', .6], ['2022.10', .78], ['2023.02', .95]];
63
+ const x0 = P ? st.x + 40 : st.x + 80, x1 = P ? st.x + st.w - 40 : st.x + st.w - 80, y = st.cy;
64
+ ctx.save(); ctx.globalAlpha = aT;
65
+ ctx.strokeStyle = 'rgba(255,255,255,.4)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
66
+ ctx.restore();
67
+ marks.forEach(([d, f], i) => {
68
+ const a = win(t, c3 + .2 + i * .35, .3, eBack) * aT; if (a <= 0) return;
69
+ const x = lerp(x0, x1, f), up = i % 2 ? -1 : 1;
70
+ glow(ctx, x, y, 70, RED, .6 * clamp(a));
71
+ ctx.save(); ctx.translate(x, y); ctx.scale(a, a); ctx.strokeStyle = RED; ctx.lineWidth = 9; ctx.lineCap = 'round';
72
+ ctx.beginPath(); ctx.moveTo(-18, -18); ctx.lineTo(18, 18); ctx.moveTo(18, -18); ctx.lineTo(-18, 18); ctx.stroke(); ctx.restore();
73
+ text(ctx, d, x, y + up * 64, { size: P ? 26 : 30, fam: F_MONO, weight: 700, color: WHITE, align: 'center', alpha: clamp(a) });
74
+ });
75
+ text(ctx, '停摆 / 严重降级 · 示意', st.cx, y + (P ? 170 : 150), { size: 26, weight: 700, color: 'rgba(255,255,255,.55)', align: 'center', alpha: aT });
76
+ }
77
+ // c4: the running joke
78
+ const aJ = win(t, c4 - .1, .5);
79
+ if (aJ > 0) {
80
+ const sh = shake(t, 4 * aJ);
81
+ glowText(ctx, '又宕机了?', st.cx + sh[0], st.cy - (P ? 60 : 40) + sh[1], { size: P ? 120 : 150, weight: 900, color: WHITE, align: 'center', alpha: aJ, glowColor: RED });
82
+ const bs = [['哈哈哈哈', -1, -1], ['重启大师', 1, -1], ['又双叒叕', -1, 1], ['熟悉的味道', 1, 1], ['关机下班', 0, 1.6]];
83
+ bs.forEach(([str, dx, dy], i) => {
84
+ const a = win(t, c4 + .2 + i * .25, .3, eBack); if (a <= 0) return;
85
+ const x = st.cx + dx * (P ? 230 : 480) + Math.sin(t * 2 + i) * 8, y = st.cy - 40 + dy * (P ? 210 : 190);
86
+ bubble(ctx, str, x, y, { size: P ? 32 : 36, alpha: clamp(a), tail: dx > 0 ? 'right' : 'left' });
87
+ });
88
+ }
89
+ },
90
+ post(ctx, L, s) {
91
+ // RGB-split glitch slices while the network floods
92
+ const { t } = s;
93
+ const g = win(t, s.cue(1) + .6, 1.5) * (1 - win(t, s.cue(2) + .3, .4)) + (s.t > s.cue(4) && s.t < s.cue(4) + .25 ? 1 : 0);
94
+ if (g <= 0) return;
95
+ const cv = ctx.canvas, sc = cv.width / L.W;
96
+ ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0);
97
+ const n = 7, f = Math.floor(t * 18);
98
+ for (let i = 0; i < n; i++) {
99
+ if (hash1(f * 31 + i) > .55 * g + .1) continue;
100
+ const y = hash1(f * 17 + i * 3) * cv.height, h = (10 + hash1(f + i * 5) * 60) * sc, dx = (hash1(f * 7 + i) - .5) * 120 * sc * g;
101
+ ctx.drawImage(cv, 0, y, cv.width, h, dx, y, cv.width, h);
102
+ }
103
+ ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = .12 * g; ctx.fillStyle = RED; ctx.fillRect(0, 0, cv.width, cv.height);
104
+ ctx.restore();
105
+ },
106
+ sfx(s) {
107
+ return [
108
+ { t: 0, k: 'hum', d: s.cue(1) + .5 },
109
+ { t: s.cue(1) + .3, k: 'glitchloop', d: s.cue(2) - s.cue(1) },
110
+ { t: s.cue(2) - .2, k: 'powerdown', v: 1 }, { t: s.cue(2) + .1, k: 'alarm', d: 2.4 },
111
+ ...[0, 1, 2, 3, 4, 5].map(i => ({ t: s.cue(3) + .2 + i * .35, k: 'error', v: .6 })),
112
+ { t: s.cue(4), k: 'glitch', v: .8 }, ...[0, 1, 2, 3, 4].map(i => ({ t: s.cue(4) + .2 + i * .25, k: 'pop', v: .5 })),
113
+ ];
114
+ },
115
+ };
116
+ function newsBar(ctx, L, tag, msg, a, t) {
117
+ const P = L.portrait;
118
+ const y = P ? L.safe.b - 230 : L.H - 230, x = P ? L.safe.l : 0, w = P ? L.safe.r - L.safe.l : L.W;
119
+ ctx.save(); ctx.globalAlpha = a;
120
+ ctx.fillStyle = 'rgba(10,10,20,.92)'; ctx.fillRect(x, y, w, 70);
121
+ ctx.fillStyle = RED; ctx.fillRect(x, y, P ? 120 : 190, 70);
122
+ text(ctx, tag, x + (P ? 60 : 95), y + 36, { size: 36, weight: 900, color: '#fff', align: 'center' });
123
+ ctx.save(); ctx.beginPath(); ctx.rect(x + (P ? 130 : 210), y, w - (P ? 140 : 220), 70); ctx.clip();
124
+ const mw = measure(ctx, msg, 34, 800);
125
+ const avail = w - (P ? 140 : 220);
126
+ const off = mw > avail ? ((t * 140) % (mw + 200)) : 0;
127
+ text(ctx, msg, x + (P ? 140 : 230) - off, y + 36, { size: 34, weight: 800, color: '#fff' });
128
+ if (mw > avail) text(ctx, msg, x + (P ? 140 : 230) - off + mw + 200, y + 36, { size: 34, weight: 800, color: '#fff' });
129
+ ctx.restore();
130
+ ctx.fillStyle = GOLD; ctx.fillRect(x, y + 70, w, 5);
131
+ ctx.restore();
132
+ }
133
+
134
+ // ---------------------------------------------------------------- 9. FTX
135
+ const ftx = {
136
+ draw(ctx, L, s) {
137
+ const { t, T } = s, st = L.stage, P = L.portrait;
138
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4), c5 = s.cue(5);
139
+ snow(ctx, L, T, 140, .6);
140
+ const base = st.y + st.h - (P ? 40 : 10);
141
+ const fall = s.at(3, '大厦');
142
+ const aTw = win(t, c1 - .4, .7) * (1 - win(t, c4, .5));
143
+ const tw = P ? [{ x: st.cx - 170, w: 220, h: 560, l: 'FTX', sd: 3 }, { x: st.cx + 170, w: 200, h: 440, l: 'Alameda', sd: 5 }] : [{ x: st.cx - 520, w: 240, h: 580, l: 'FTX', sd: 3 }, { x: st.cx - 200, w: 220, h: 460, l: 'Alameda', sd: 5 }];
144
+ if (aTw > 0) {
145
+ tw.forEach((b, i) => {
146
+ if (i === 1 && t < c2 - .2) return;
147
+ const a = (i ? win(t, s.at(2, 'Alameda') - .2, .5) : 1) * aTw;
148
+ const rise = 1 - win(t, i ? s.at(2, 'Alameda') - .2 : c1 - .4, .9);
149
+ if (t < fall) {
150
+ const sh = t > c1 && t < fall ? shake(t, i === 0 ? 3 : 1, i) : [0, 0];
151
+ tower(ctx, b.x + sh[0], base + rise * 300, b.w, b.h, b.l, { alpha: a, seed: b.sd, lit: .55 });
152
+ // cracks
153
+ const cr = win(t, c1 + .5, 1.5);
154
+ if (i === 0 && cr > 0) { ctx.save(); ctx.strokeStyle = `rgba(255,255,255,${.8 * cr})`; ctx.lineWidth = 3; ctx.beginPath(); let x = b.x - 10, y = base - b.h + 60; ctx.moveTo(x, y); const r = rng(4); for (let k = 0; k < 12 * cr; k++) { x += (r() - .5) * 50; y += 40; ctx.lineTo(x, y); } ctx.stroke(); ctx.restore(); }
155
+ } else {
156
+ crumble(ctx, b.x, base, b.w, b.h, b.l, t - fall - i * .25, { seed: 9 + i });
157
+ }
158
+ });
159
+ if (t > fall) for (let i = 0; i < 2; i++) { const d = t - fall - i * .25; if (d > 0 && d < 2.5) { ctx.save(); ctx.globalAlpha = (1 - d / 2.5) * .55; ctx.fillStyle = '#b8c4d8'; ctx.beginPath(); ctx.ellipse(tw[i].x, base, 140 + d * 200, 50 + d * 60, 0, 0, 7); ctx.fill(); ctx.restore(); } }
160
+ }
161
+ // news
162
+ const aN = win(t, c1 - .1, .4) * (1 - win(t, c2 + .4, .4));
163
+ if (aN > 0) newsBar(ctx, L, '突发', '曾经的头部加密交易所 FTX 申请破产保护(2022.11)', aN, t);
164
+ // SOL linked with chains to the towers
165
+ const aS = win(t, c2 + .2, .6) * (1 - win(t, c4, .5));
166
+ const sx = P ? st.cx : st.cx + 320, sy = P ? st.y + 150 : st.cy - 60;
167
+ if (aS > 0) {
168
+ const tension = t < fall ? 0 : 1;
169
+ tw.forEach((b, i) => {
170
+ ctx.save(); ctx.globalAlpha = aS * (1 - tension * win(t, fall, .4)); ctx.strokeStyle = '#8aa0c0'; ctx.lineWidth = 5; ctx.setLineDash([16, 10]); ctx.lineDashOffset = -t * 40;
171
+ ctx.beginPath(); ctx.moveTo(b.x, base - b.h + 30); ctx.quadraticCurveTo((b.x + sx) / 2, sy + 120, sx, sy); ctx.stroke(); ctx.restore();
172
+ void i;
173
+ });
174
+ const drop = t > fall ? Math.min(1, (t - fall) * .5) : 0;
175
+ coin(ctx, sx, sy + drop * 60, P ? 90 : 110, { label: 'SOL', c1: PURPLE, c2: GREEN, alpha: aS, spin: t * 1.4, fs: 40 });
176
+ text(ctx, '大量 SOL 持仓', sx, sy + (P ? 140 : 160), { size: 32, weight: 800, color: WHITE, align: 'center', alpha: aS });
177
+ }
178
+ // crash chart
179
+ const aC = win(t, fall + .2, .5);
180
+ if (aC > 0) {
181
+ const rect = P ? { x: st.x + 90, y: st.y + 300, w: st.w - 120, h: st.h - 380 } : { x: st.x + 120, y: st.y + 50, w: st.w - 160, h: st.h - 120 };
182
+ const prog = eIO(inv(fall + .2, c4 - .1, t));
183
+ ctx.save(); ctx.fillStyle = `rgba(4,6,14,${.55 * aC})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore();
184
+ drawChart(ctx, rect, { from: idx('2022-11-01'), to: idx('2022-12-30'), prog, color: RED, color2: '#ff9a3d', alpha: aC * (1 - .7 * win(t, c5, .5)), yMin: 0, yMax: 40, gridVals: [10, 20, 30, 40] });
185
+ // −96%
186
+ if (t > c4 - .1) {
187
+ const sh = shake(t, 10 * (1 - inv(c4, c4 + .8, t)));
188
+ stamp(ctx, '−96%', st.cx + sh[0], (P ? st.y + 180 : st.cy - 40) + sh[1], t, c4, { size: P ? 140 : 190, color: RED, fam: F_GR, rot: -.06 });
189
+ text(ctx, '$258.93 → $9.69(日收盘)', st.cx, (P ? st.y + 330 : st.cy + 140), { size: P ? 34 : 40, weight: 800, color: WHITE, align: 'center', alpha: win(t, c4 + .3, .4) * (1 - win(t, c5, .4)) });
190
+ }
191
+ }
192
+ // c5: "Solana is dead"
193
+ const aD = win(t, c5 - .1, .6);
194
+ if (aD > 0) {
195
+ ctx.save(); ctx.fillStyle = `rgba(2,3,8,${.6 * aD})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore();
196
+ const gx = st.cx, gy = P ? st.cy + 140 : st.cy + 120, gw = P ? 360 : 380, gh = P ? 440 : 420;
197
+ ctx.save(); ctx.globalAlpha = aD; ctx.translate(0, (1 - eOut(aD)) * 200);
198
+ ctx.fillStyle = '#4a4f62'; ctx.beginPath(); ctx.moveTo(gx - gw / 2, gy + gh / 2); ctx.lineTo(gx - gw / 2, gy - gh / 2 + gw / 2); ctx.arc(gx, gy - gh / 2 + gw / 2, gw / 2, Math.PI, 0); ctx.lineTo(gx + gw / 2, gy + gh / 2); ctx.closePath(); ctx.fill();
199
+ ctx.strokeStyle = '#6c7390'; ctx.lineWidth = 6; ctx.stroke();
200
+ text(ctx, 'R.I.P.', gx, gy - gh / 2 + 120, { size: 64, fam: F_GR, weight: 700, color: '#cfd5e6', align: 'center' });
201
+ text(ctx, 'SOLANA', gx, gy + 10, { size: 54, fam: F_GR, weight: 700, color: '#cfd5e6', align: 'center', ls: 6 });
202
+ text(ctx, '2020 – 2022 ?', gx, gy + 80, { size: 34, fam: F_MONO, weight: 600, color: '#aab1c6', align: 'center' });
203
+ ctx.fillStyle = '#1a1d28'; ctx.fillRect(gx - gw, gy + gh / 2 - 4, gw * 2, 30);
204
+ ctx.restore();
205
+ const hl = ['“Solana 已死”', '“归零倒计时”', '“生态大逃亡”'];
206
+ hl.forEach((h, i) => {
207
+ const a = win(t, c5 + .3 + i * .3, .4) * aD; if (a <= 0) return;
208
+ const x = P ? st.cx + (i - 1) * 40 : st.cx + (i - 1) * 540, y = P ? st.y + 120 + i * 120 : st.y + 60 + (i % 2) * 80;
209
+ text(ctx, h, x, y, { size: P ? 42 : 46, weight: 900, color: '#9aa3bb', align: 'center', alpha: a * .9 });
210
+ });
211
+ }
212
+ },
213
+ sfx(s) {
214
+ const fall = s.at(3, '大厦');
215
+ return [
216
+ { t: 0, k: 'wind', d: s.d }, { t: s.cue(1) - .4, k: 'rumble', d: 1.5 }, { t: s.cue(1), k: 'news', v: 1 },
217
+ { t: s.cue(1) + .5, k: 'crack', v: .8 }, { t: s.at(2, 'Alameda') - .2, k: 'rumble', d: 1 },
218
+ { t: fall, k: 'collapse', v: 1 }, { t: fall + .25, k: 'crash', v: .8 }, { t: fall + .3, k: 'drop', v: 1 },
219
+ { t: s.cue(4), k: 'impact', v: 1 }, { t: s.cue(5) - .1, k: 'bell', v: .7 },
220
+ ];
221
+ },
222
+ };
223
+
224
+ // ---------------------------------------------------------------- 10. REBUILD 2023
225
+ const rebuild = {
226
+ draw(ctx, L, s) {
227
+ const { t, T } = s, st = L.stage, P = L.portrait;
228
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4), c5 = s.cue(5);
229
+ // c0: embers converge into the logo
230
+ const a0 = 1 - win(t, c1 - .1, .5);
231
+ if (a0 > 0) {
232
+ const lx = st.cx, ly = st.cy - 30, size = P ? 380 : 360;
233
+ const conv = eIO(inv(.2, c1 - .6, t));
234
+ ctx.save(); ctx.globalCompositeOperation = 'lighter';
235
+ for (let k = 0; k < 260; k++) {
236
+ const r1 = hash1(k), r2 = hash1(k + 500), r3 = hash1(k + 900);
237
+ const bar = Math.floor(r3 * 3), yy = [-size * .32, 0, size * .32][bar], xx = (r1 - .5) * size * .9 + (bar === 1 ? -1 : 1) * (r2 - .5) * 20;
238
+ const sx = st.cx + (hash1(k + 77) - .5) * L.W * 1.1, sy = L.H + 50 - hash1(k + 33) * 300 - ((t * 120 * (.5 + r2)) % 200);
239
+ const x = lerp(sx, lx + xx, conv), y = lerp(sy, ly + yy, conv);
240
+ ctx.globalAlpha = a0 * (.4 + .6 * r2); ctx.fillStyle = r1 > .5 ? '#ffb347' : '#ff6a3d'; ctx.beginPath(); ctx.arc(x, y, 3 + r2 * 3, 0, 7); ctx.fill();
241
+ }
242
+ ctx.restore();
243
+ glow(ctx, lx, ly, size * 1.4, '#ff8a3d', .5 * conv * a0);
244
+ solLogo(ctx, lx, ly, size, conv > .9 ? 1 : 0, a0 * win(t, c1 - 1, .6));
245
+ // wings
246
+ const wa = win(t, c1 - .9, .6) * a0;
247
+ if (wa > 0) for (const d of [-1, 1]) {
248
+ ctx.save(); ctx.globalAlpha = wa * .8; ctx.translate(lx + d * size * .55, ly); ctx.scale(d, 1);
249
+ for (let f = 0; f < 6; f++) { const a = -.3 - f * .22 + Math.sin(T * 3) * .05; ctx.rotate(0); ctx.strokeStyle = f % 2 ? GOLD : '#ff6a3d'; ctx.lineWidth = 8 - f; ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(size * .3, -size * .2 + f * 10, Math.cos(a) * size * (.7 - f * .06), Math.sin(a) * size * (.7 - f * .06)); ctx.stroke(); }
250
+ ctx.restore();
251
+ }
252
+ }
253
+ // c1: Serum → OpenBook fork
254
+ const aF = win(t, c1 - .1, .5) * (1 - win(t, c2 - .1, .4));
255
+ if (aF > 0) {
256
+ const y = st.cy, x0 = P ? st.x + 90 : st.cx - 600, xm = P ? st.cx - 60 : st.cx - 150, x1 = P ? st.x + st.w - 120 : st.cx + 520;
257
+ const p = eIO(inv(c1, c1 + 1.6, t));
258
+ ctx.save(); ctx.globalAlpha = aF; ctx.lineCap = 'round';
259
+ ctx.strokeStyle = '#7a7a90'; ctx.lineWidth = 12; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(xm, y); ctx.stroke();
260
+ ctx.strokeStyle = 'rgba(255,90,90,.6)'; ctx.setLineDash([10, 14]); ctx.beginPath(); ctx.moveTo(xm, y); ctx.lineTo(lerp(xm, x1, p), y + (P ? 220 : 160) * p); ctx.stroke(); ctx.setLineDash([]);
261
+ const g = ctx.createLinearGradient(xm, 0, x1, 0); g.addColorStop(0, PURPLE); g.addColorStop(1, GREEN);
262
+ ctx.strokeStyle = g; ctx.beginPath(); ctx.moveTo(xm, y); ctx.bezierCurveTo(xm + 120, y, lerp(xm, x1, p) - 100, y - (P ? 220 : 160) * p, lerp(xm, x1, p), y - (P ? 220 : 160) * p); ctx.stroke();
263
+ ctx.restore();
264
+ node(ctx, x0, y, 'Serum', '#9a9ab0', aF);
265
+ if (p > .95) { node(ctx, x1, y - (P ? 220 : 160), 'OpenBook', GREEN, aF); text(ctx, '已停用', x1, y + (P ? 220 : 160) + 70, { size: 28, weight: 700, color: 'rgba(255,120,120,.8)', align: 'center', alpha: aF }); }
266
+ pill(ctx, '社区分叉 Fork', xm, y + 80, { size: 30, color: GOLD, alpha: aF });
267
+ }
268
+ // c2: BONK christmas
269
+ const aK = win(t, c2 - .1, .5) * (1 - win(t, c3 - .1, .4));
270
+ if (aK > 0) {
271
+ // string lights
272
+ ctx.save(); ctx.globalAlpha = aK;
273
+ for (let row = 0; row < 2; row++) {
274
+ const y0 = (P ? st.y + 30 : st.y - 10) + row * 60;
275
+ ctx.strokeStyle = '#2b4a2b'; ctx.lineWidth = 3; ctx.beginPath();
276
+ for (let x = 0; x <= L.W; x += 20) { const y = y0 + Math.sin(x / L.W * Math.PI * 3 + row) * 22; x ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke();
277
+ for (let k = 0; k < 18; k++) { const x = (k + .5) / 18 * L.W, y = y0 + Math.sin(x / L.W * Math.PI * 3 + row) * 22 + 10; const on = Math.sin(T * 4 + k * 1.7 + row) > 0; const col = [RED, GOLD, GREEN, TEAL][(k + row) % 4]; if (on) glow(ctx, x, y, 26, col, .9); ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, 7, 0, 7); ctx.fill(); }
278
+ }
279
+ ctx.restore();
280
+ for (let k = 0; k < 30; k++) {
281
+ const t0 = c2 + k * .13, d = t - t0; if (d < 0) continue;
282
+ const x = st.x + hash1(k * 5) * st.w, y = st.y - 60 + d * d * 500 + d * 150;
283
+ if (y > L.H + 60) continue;
284
+ coin(ctx, x, y, 38, { label: 'BONK', c1: '#ffb13b', c2: '#ff7a00', spin: d * 5 + k, alpha: aK, fs: 16 });
285
+ }
286
+ const bx = st.cx, by = st.cy;
287
+ glow(ctx, bx, by, 300, '#ff9f2e', .5 * aK);
288
+ dog(ctx, bx, by, P ? 1.1 : 1.15, aK, T);
289
+ text(ctx, '2022.12.25 · BONK 空投', bx, by + (P ? 230 : 220), { size: P ? 42 : 46, weight: 900, color: '#ffcf6b', align: 'center', alpha: aK, stroke: 6, strokeColor: 'rgba(0,0,0,.6)' });
290
+ }
291
+ // c3: Firedancer
292
+ const aD = win(t, c3 - .1, .5) * (1 - win(t, c4 - .1, .4));
293
+ if (aD > 0) {
294
+ const fx = st.cx, fy = st.cy + (P ? 140 : 160);
295
+ flames(ctx, fx, fy, P ? 1.25 : 1.3, T, aD);
296
+ const v = Math.round(1000000 * eOut(inv(s.at(3, '百万') - .6, s.at(3, '百万') + .6, t)));
297
+ bigNum(ctx, v.toLocaleString('en-US'), fx, fy - (P ? 380 : 360), P ? 110 : 130, { c1: GOLD, c2: '#ff5a3d', alpha: aD });
298
+ text(ctx, 'TPS · 测试演示', fx, fy - (P ? 290 : 270), { size: 34, weight: 800, color: WHITE, align: 'center', alpha: aD });
299
+ text(ctx, 'Firedancer', fx, fy + (P ? 100 : 80), { size: P ? 64 : 70, fam: F_GR, weight: 700, color: '#ffd28a', align: 'center', alpha: aD, shadow: 30, shadowColor: '#ff6a3d' });
300
+ text(ctx, 'Jump 团队打造的第二个验证者客户端', fx, fy + (P ? 170 : 145), { size: 30, weight: 700, color: 'rgba(255,255,255,.8)', align: 'center', alpha: aD });
301
+ }
302
+ // c4: three cards
303
+ const aC = win(t, c4 - .1, .4) * (1 - win(t, c5 - .1, .4));
304
+ if (aC > 0) {
305
+ const cards = [['状态压缩', '100 万个 NFT', '≈ $110', s.at(4, '状态压缩')], ['Helium', '整个网络迁入', 'Solana', s.at(4, 'Helium')], ['Saga', 'Solana 手机', '2023', s.at(4, 'Saga')]];
306
+ cards.forEach(([a1, a2, a3, at], i) => {
307
+ const a = win(t, at, .45) * aC; if (a <= 0) return;
308
+ let x, y, w = P ? 520 : 470, h = P ? 220 : 380;
309
+ if (P) { x = st.cx; y = st.y + 120 + i * 260; } else { x = st.cx + (i - 1) * 520; y = st.cy; }
310
+ ctx.save(); ctx.globalAlpha = a; ctx.translate(x, y + (1 - a) * 80);
311
+ glass(ctx, -w / 2, -h / 2, w, h, { border: '#ffa63d' });
312
+ if (i === 2) phone(ctx, P ? -170 : 0, P ? 0 : -60, P ? .6 : .9, t);
313
+ else if (i === 0) { for (let k = 0; k < 16; k++) { const gx = (k % 4 - 1.5) * (P ? 34 : 44) + (P ? -170 : 0), gy = (Math.floor(k / 4) - 1.5) * (P ? 34 : 44) + (P ? 0 : -60); ctx.fillStyle = [PURPLE, GREEN, TEAL, PINK][k % 4]; ctx.globalAlpha = a * (.5 + .5 * Math.sin(t * 3 + k)); rr(ctx, gx - 14, gy - 14, 28, 28, 6); ctx.fill(); } ctx.globalAlpha = a; }
314
+ else { ctx.strokeStyle = TEAL; ctx.lineWidth = 4; for (let k = 1; k <= 3; k++) { ctx.globalAlpha = a * (1 - ((t * .8 + k / 3) % 1)); ctx.beginPath(); ctx.arc(P ? -170 : 0, P ? 0 : -60, ((t * .8 + k / 3) % 1) * 80, 0, 7); ctx.stroke(); } ctx.globalAlpha = a; ctx.fillStyle = TEAL; ctx.beginPath(); ctx.arc(P ? -170 : 0, P ? 0 : -60, 14, 0, 7); ctx.fill(); }
315
+ const tx = P ? 60 : 0, ty0 = P ? -60 : 80;
316
+ text(ctx, a1, tx, ty0, { size: P ? 44 : 46, weight: 900, fam: F_SC, color: WHITE, align: 'center' });
317
+ text(ctx, a2, tx, ty0 + 52, { size: 30, weight: 700, color: 'rgba(255,255,255,.75)', align: 'center' });
318
+ gradText(ctx, a3, tx, ty0 + 108, { size: 42, fam: F_GR, weight: 700, align: 'center', c1: GOLD, c2: GREEN });
319
+ ctx.restore();
320
+ });
321
+ }
322
+ // c5: 2023 chart ×10
323
+ const aR = win(t, c5 - .1, .5);
324
+ if (aR > 0) {
325
+ const rect = P ? { x: st.x + 90, y: st.y + 330, w: st.w - 120, h: st.h - 400 } : { x: st.x + 120, y: st.y + 40, w: st.w - 160, h: st.h - 110 };
326
+ const prog = eIO(inv(c5, s.end(5) + .2, t));
327
+ const head = drawChart(ctx, rect, { from: idx('2023-01-01'), to: idx('2023-12-31'), prog, alpha: aR, color: '#ffb347', color2: GREEN, yMin: 0, yMax: 130, gridVals: [25, 50, 75, 100, 125] });
328
+ if (t > s.end(5) - .4) {
329
+ stamp(ctx, '×10', P ? st.cx : rect.x + rect.w * .3, P ? st.y + 170 : rect.y + rect.h * .3, t, s.end(5) - .4, { size: P ? 130 : 150, color: GREEN, fam: F_GR });
330
+ burst(ctx, head.x, head.y, t - s.end(5) + .4, { r: 300, seed: 12 });
331
+ }
332
+ }
333
+ },
334
+ sfx(s) {
335
+ return [
336
+ { t: .2, k: 'riser', v: .8 }, { t: s.cue(1) - .9, k: 'phoenix', v: 1 }, { t: s.cue(1) - .6, k: 'shimmer' },
337
+ { t: s.cue(1) + .2, k: 'whoosh', v: .6 }, { t: s.cue(1) + 1.6, k: 'pop' },
338
+ { t: s.cue(2), k: 'jingle', d: s.cue(3) - s.cue(2) }, { t: s.cue(2) + .2, k: 'coins', d: 2.5 }, { t: s.cue(2) + .4, k: 'bark' },
339
+ { t: s.cue(3) - .1, k: 'fire', d: s.cue(4) - s.cue(3) + .2 }, { t: s.at(3, '百万') + .6, k: 'impact', v: .6 },
340
+ { t: s.at(4, '状态压缩'), k: 'card' }, { t: s.at(4, 'Helium'), k: 'card' }, { t: s.at(4, 'Saga'), k: 'card' },
341
+ { t: s.cue(5), k: 'climb', d: s.end(5) - s.cue(5) }, { t: s.end(5) - .4, k: 'stamp', v: 1 },
342
+ ];
343
+ },
344
+ };
345
+ function node(ctx, x, y, label, col, a) {
346
+ glow(ctx, x, y, 90, col, .5 * a);
347
+ ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = '#0d0b1c'; ctx.beginPath(); ctx.arc(x, y, 34, 0, 7); ctx.fill(); ctx.strokeStyle = col; ctx.lineWidth = 6; ctx.stroke(); ctx.restore();
348
+ text(ctx, label, x, y - 66, { size: 38, fam: F_GR, weight: 700, color: col, align: 'center', alpha: a });
349
+ }
350
+ function dog(ctx, x, y, s, a, T) {
351
+ ctx.save(); ctx.globalAlpha = a; ctx.translate(x, y); ctx.scale(s, s); ctx.rotate(Math.sin(T * 3) * .05);
352
+ ctx.fillStyle = '#f2a33a';
353
+ ctx.beginPath(); ctx.moveTo(-110, -60); ctx.lineTo(-80, -170); ctx.lineTo(-30, -100); ctx.fill();
354
+ ctx.beginPath(); ctx.moveTo(110, -60); ctx.lineTo(80, -170); ctx.lineTo(30, -100); ctx.fill();
355
+ ctx.beginPath(); ctx.ellipse(0, 0, 130, 120, 0, 0, 7); ctx.fill();
356
+ ctx.fillStyle = '#fff3dd'; ctx.beginPath(); ctx.ellipse(0, 50, 80, 62, 0, 0, 7); ctx.fill();
357
+ ctx.fillStyle = '#1a1020'; ctx.beginPath(); ctx.ellipse(-45, -20, 13, 17, 0, 0, 7); ctx.ellipse(45, -20, 13, 17, 0, 0, 7); ctx.fill();
358
+ ctx.beginPath(); ctx.ellipse(0, 22, 22, 15, 0, 0, 7); ctx.fill();
359
+ ctx.strokeStyle = '#1a1020'; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(0, 36); ctx.lineTo(0, 56); ctx.moveTo(-28, 64); ctx.quadraticCurveTo(0, 82, 28, 64); ctx.stroke();
360
+ // santa hat
361
+ ctx.fillStyle = RED; ctx.beginPath(); ctx.moveTo(-90, -95); ctx.quadraticCurveTo(10, -230, 120, -150); ctx.lineTo(70, -95); ctx.fill();
362
+ ctx.fillStyle = '#fff'; rr(ctx, -100, -112, 180, 34, 17); ctx.fill(); ctx.beginPath(); ctx.arc(124, -150, 20, 0, 7); ctx.fill();
363
+ ctx.restore();
364
+ }
365
+ function flames(ctx, x, y, s, T, a) {
366
+ ctx.save(); ctx.globalAlpha = a; ctx.translate(x, y); ctx.scale(s, s); ctx.globalCompositeOperation = 'lighter';
367
+ for (let k = 0; k < 26; k++) {
368
+ const ph = (T * (1.2 + hash1(k) * .8) + hash1(k + 3)) % 1, xo = (hash1(k + 9) - .5) * 260 * (1 - ph * .6);
369
+ const h = 180 + hash1(k + 4) * 160, w = 50 + hash1(k + 1) * 50;
370
+ const yy = -ph * h;
371
+ const g = ctx.createRadialGradient(xo, yy, 0, xo, yy, w);
372
+ g.addColorStop(0, `rgba(255,240,180,${(1 - ph) * .9})`); g.addColorStop(.4, `rgba(255,130,40,${(1 - ph) * .6})`); g.addColorStop(1, 'rgba(255,60,20,0)');
373
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(xo, yy, w, 0, 7); ctx.fill();
374
+ }
375
+ ctx.restore();
376
+ glow(ctx, x, y - 120, 360, '#ff7a2e', .55 * a);
377
+ }
378
+ function phone(ctx, x, y, s, t) {
379
+ ctx.save(); ctx.translate(x, y); ctx.scale(s, s);
380
+ rr(ctx, -55, -100, 110, 200, 18); ctx.fillStyle = '#111'; ctx.fill(); ctx.strokeStyle = '#ffa63d'; ctx.lineWidth = 4; ctx.stroke();
381
+ rr(ctx, -46, -88, 92, 176, 10); const g = ctx.createLinearGradient(0, -88, 0, 88); g.addColorStop(0, PURPLE); g.addColorStop(1, GREEN); ctx.fillStyle = g; ctx.fill();
382
+ solLogo(ctx, 0, 0, 56, 1, .9 + .1 * Math.sin(t * 3));
383
+ ctx.restore();
384
+ }
385
+
386
+ // ---------------------------------------------------------------- 11. MEME 2024
387
+ const MEMES = [['WIF', '#e8b98a', 'hat'], ['BONK', '#f2a33a', 'dog'], ['POPCAT', '#d9d0c4', 'cat']];
388
+ const meme = {
389
+ draw(ctx, L, s) {
390
+ const { t, T } = s, st = L.stage, P = L.portrait;
391
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4);
392
+ // confetti
393
+ ctx.save();
394
+ for (let k = 0; k < 90; k++) {
395
+ const x = (hash1(k) * L.W + Math.sin(T + k) * 30), y = ((hash1(k + 4) * L.H + T * (80 + hash1(k + 2) * 120)) % (L.H + 40)) - 20;
396
+ ctx.save(); ctx.translate(x, y); ctx.rotate(T * 3 + k); ctx.fillStyle = [PURPLE, GREEN, GOLD, PINK, TEAL][k % 5]; ctx.globalAlpha = .55; ctx.fillRect(-7, -3, 14, 6); ctx.restore();
397
+ }
398
+ ctx.restore();
399
+ // c0: title
400
+ const a0 = win(t, .2, .5, eBack) * (1 - win(t, c1 - .1, .4));
401
+ if (a0 > 0) {
402
+ ctx.save(); ctx.translate(st.cx, st.cy); ctx.rotate(Math.sin(T * 4) * .03); ctx.scale(a0, a0);
403
+ gradText(ctx, 'MEME', 0, -40, { size: P ? 200 : 240, fam: F_GR, weight: 700, align: 'center', c1: PINK, c2: GOLD, ls: 10 });
404
+ text(ctx, '主 场', 0, P ? 110 : 130, { size: P ? 80 : 90, weight: 900, color: WHITE, align: 'center', ls: 20 });
405
+ ctx.restore();
406
+ }
407
+ // c1: Jupiter airdrop parachutes
408
+ const aJ = win(t, c1 - .1, .4) * (1 - win(t, c2 - .1, .4));
409
+ if (aJ > 0) {
410
+ for (let k = 0; k < 9; k++) {
411
+ const t0 = c1 + k * .25, d = t - t0; if (d < 0) continue;
412
+ const x = st.x + (k + .5) / 9 * st.w + Math.sin(T * 1.5 + k) * 20, y = st.y - 80 + d * 140;
413
+ ctx.save(); ctx.globalAlpha = aJ;
414
+ ctx.fillStyle = k % 2 ? GREEN : TEAL; ctx.beginPath(); ctx.arc(x, y - 90, 60, Math.PI, 0); ctx.fill();
415
+ ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - 60, y - 90); ctx.lineTo(x, y - 20); ctx.lineTo(x + 60, y - 90); ctx.moveTo(x, y - 150); ctx.lineTo(x, y - 20); ctx.stroke();
416
+ ctx.restore();
417
+ coin(ctx, x, y, 30, { label: 'JUP', c1: '#2fd1a5', c2: '#c7f284', alpha: aJ, spin: T * 2 + k, fs: 15 });
418
+ }
419
+ const n = Math.round(955000 * eOut(inv(c1, s.end(1), t)) / 1000) * 1000;
420
+ ctx.save(); ctx.globalAlpha = aJ;
421
+ glass(ctx, st.cx - (P ? 400 : 420), st.cy + (P ? 120 : 80), P ? 800 : 840, 200, {});
422
+ ctx.restore();
423
+ text(ctx, 'Jupiter 空投 · 2024.01', st.cx, st.cy + (P ? 165 : 125), { size: 36, weight: 800, color: GREEN, align: 'center', alpha: aJ });
424
+ bigNum(ctx, '≈ ' + n.toLocaleString('en-US') + ' 个地址', st.cx, st.cy + (P ? 245 : 205), P ? 58 : 64, { alpha: aJ, fam: F_SC, weight: 900 });
425
+ }
426
+ // c2: pump.fun coin machine
427
+ const aP = win(t, c2 - .1, .4) * (1 - win(t, c3 - .1, .4));
428
+ if (aP > 0) {
429
+ const mx = P ? st.cx - 200 : st.cx - 420, my = st.cy;
430
+ ctx.save(); ctx.globalAlpha = aP;
431
+ rr(ctx, mx - 130, my - 170, 260, 340, 30); const g = ctx.createLinearGradient(mx - 130, 0, mx + 130, 0); g.addColorStop(0, '#2a1f5a'); g.addColorStop(1, '#4a2f8a'); ctx.fillStyle = g; ctx.fill(); ctx.strokeStyle = GREEN; ctx.lineWidth = 5; ctx.stroke();
432
+ rr(ctx, mx - 90, my - 130, 180, 110, 16); ctx.fillStyle = '#0a1a14'; ctx.fill();
433
+ text(ctx, 'pump.fun', mx, my - 76, { size: 34, fam: F_GR, weight: 700, color: GREEN, align: 'center' });
434
+ ctx.fillStyle = RED; ctx.beginPath(); ctx.arc(mx, my + 50, 40 + 4 * Math.sin(t * 10), 0, 7); ctx.fill();
435
+ text(ctx, '发币', mx, my + 52, { size: 26, weight: 900, color: '#fff', align: 'center' });
436
+ ctx.restore();
437
+ const per = .22, n0 = Math.floor((t - c2) / per);
438
+ for (let k = Math.max(0, n0 - 14); k <= n0; k++) {
439
+ const d = t - (c2 + k * per); if (d < 0) continue;
440
+ const x = mx + 150 + d * (P ? 260 : 520), y = my + 120 + Math.sin(d * 3 + k) * 20 - d * 60 * (hash1(k) - .3);
441
+ if (x > L.W + 50) continue;
442
+ coin(ctx, x, y, 32, { label: hexStr(k, 3).toUpperCase(), c1: [PINK, GOLD, TEAL, GREEN][k % 4], c2: PURPLE, alpha: aP, spin: d * 4, fs: 16 });
443
+ }
444
+ const cnt = Math.max(0, n0) * 137 + 1;
445
+ text(ctx, `新币 +${cnt.toLocaleString()}`, P ? st.cx : st.cx + 280, P ? st.y + 120 : st.cy - 200, { size: P ? 56 : 64, fam: F_SC, weight: 900, color: GOLD, align: 'center', alpha: aP, stroke: 6, strokeColor: 'rgba(0,0,0,.6)' });
446
+ text(ctx, '几秒钟,发一个币(示意)', P ? st.cx : st.cx + 280, P ? st.y + 190 : st.cy - 130, { size: 30, weight: 700, color: 'rgba(255,255,255,.75)', align: 'center', alpha: aP });
447
+ }
448
+ // c3: meme faces
449
+ const aM = win(t, c3 - .1, .4) * (1 - win(t, c4 - .1, .4));
450
+ if (aM > 0) {
451
+ MEMES.forEach(([nm, col, kind], i) => {
452
+ const a = win(t, s.at(3, nm), .4, eBack) * aM; if (a <= 0) return;
453
+ const x = P ? st.cx + (i === 1 ? 160 : -100) : st.cx + (i - 1) * 520, y = P ? st.y + 160 + i * 270 : st.cy - 40;
454
+ const r = P ? 120 : 150, bob = Math.sin(T * 3 + i) * 12;
455
+ ctx.save(); ctx.translate(x, y + bob); ctx.scale(a, a); ctx.globalAlpha = clamp(a);
456
+ glow(ctx, 0, 0, r * 1.8, col, .45);
457
+ face(ctx, 0, 0, r, col, kind, T);
458
+ ctx.restore();
459
+ text(ctx, '$' + nm, x + (P ? (i === 1 ? -190 : 190) : 0), y + bob + (P ? 0 : r + 50), { size: P ? 50 : 54, fam: F_GR, weight: 700, color: WHITE, align: 'center', alpha: clamp(a), stroke: 6, strokeColor: 'rgba(0,0,0,.6)' });
460
+ });
461
+ const aa = win(t, s.at(3, '数十亿'), .4) * aM;
462
+ gradText(ctx, '市值 数十亿美元', st.cx, P ? st.y + st.h - 20 : st.y + st.h - 50, { size: P ? 60 : 66, align: 'center', alpha: aa, c1: GOLD, c2: GREEN });
463
+ }
464
+ // c4: DEX volume race (illustrative)
465
+ const aV = win(t, c4 - .1, .4);
466
+ if (aV > 0) {
467
+ const p = inv(c4, s.end(4) + .3, t);
468
+ const sol = .45 + .5 * eIO(p), eth = .7 + .05 * Math.sin(p * 6);
469
+ const bars = [['Solana DEX', sol, GREEN], ['Ethereum DEX', eth, '#8c9cff']];
470
+ const bx = P ? st.x + 30 : st.cx - 640, bwMax = P ? st.w - 60 : 1280, by = st.cy - 80;
471
+ bars.forEach(([nm, v, col], i) => {
472
+ const y = by + i * 150;
473
+ ctx.save(); ctx.globalAlpha = aV;
474
+ text(ctx, nm, bx, y - 50, { size: 36, fam: F_GR, weight: 700, color: col });
475
+ rr(ctx, bx, y - 22, bwMax, 56, 28); ctx.fillStyle = 'rgba(255,255,255,.06)'; ctx.fill();
476
+ rr(ctx, bx, y - 22, bwMax * v, 56, 28); ctx.fillStyle = col; ctx.fill();
477
+ ctx.restore();
478
+ if (i === 0) glow(ctx, bx + bwMax * v, y + 6, 90, GREEN, .6 * aV);
479
+ });
480
+ if (sol > eth) stamp(ctx, '反超!', P ? st.cx : st.cx + 420, by - 170, t, c4 + (s.end(4) + .3 - c4) * .62, { size: P ? 70 : 80, color: GREEN, rot: -.1 });
481
+ text(ctx, '交易量对比 · 示意', st.cx, by + 260, { size: 26, weight: 700, color: 'rgba(255,255,255,.5)', align: 'center', alpha: aV });
482
+ }
483
+ },
484
+ sfx(s) {
485
+ const ev = [{ t: .2, k: 'party', v: 1 }, { t: s.cue(1), k: 'whoosh', v: .5 }];
486
+ for (let k = 0; k < 9; k++) ev.push({ t: s.cue(1) + k * .25, k: 'coin', v: .45, p: k });
487
+ ev.push({ t: s.cue(2), k: 'machine', d: s.cue(3) - s.cue(2) });
488
+ MEMES.forEach(([nm]) => ev.push({ t: s.at(3, nm), k: 'boing' }));
489
+ ev.push({ t: s.cue(4), k: 'climb', d: s.end(4) - s.cue(4) }, { t: s.cue(4) + (s.end(4) + .3 - s.cue(4)) * .62, k: 'stamp', v: 1 });
490
+ return ev;
491
+ },
492
+ };
493
+ function face(ctx, x, y, r, col, kind, T) {
494
+ ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill();
495
+ if (kind === 'cat') { ctx.beginPath(); ctx.moveTo(x - r * .9, y - r * .3); ctx.lineTo(x - r * .7, y - r * 1.1); ctx.lineTo(x - r * .25, y - r * .85); ctx.moveTo(x + r * .9, y - r * .3); ctx.lineTo(x + r * .7, y - r * 1.1); ctx.lineTo(x + r * .25, y - r * .85); ctx.fill(); }
496
+ else { ctx.beginPath(); ctx.ellipse(x - r * .85, y - r * .2, r * .25, r * .5, .3, 0, 7); ctx.ellipse(x + r * .85, y - r * .2, r * .25, r * .5, -.3, 0, 7); ctx.fill(); }
497
+ ctx.fillStyle = '#1a1020'; ctx.beginPath(); ctx.arc(x - r * .35, y - r * .15, r * .1, 0, 7); ctx.arc(x + r * .35, y - r * .15, r * .1, 0, 7); ctx.fill();
498
+ if (kind === 'cat') { const o = (Math.sin(T * 8) > 0) ? 1 : .2; ctx.beginPath(); ctx.ellipse(x, y + r * .35, r * .22, r * .3 * o + r * .04, 0, 0, 7); ctx.fill(); }
499
+ else { ctx.beginPath(); ctx.ellipse(x, y + r * .12, r * .15, r * .1, 0, 0, 7); ctx.fill(); ctx.strokeStyle = '#1a1020'; ctx.lineWidth = r * .05; ctx.beginPath(); ctx.arc(x, y + r * .3, r * .25, .2, Math.PI - .2); ctx.stroke(); }
500
+ if (kind === 'hat') { ctx.fillStyle = '#ff9fc8'; ctx.beginPath(); ctx.ellipse(x, y - r * .78, r * .62, r * .28, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.ellipse(x, y - r * .7, r * .9, r * .14, 0, 0, 7); ctx.fill(); }
501
+ if (kind === 'dog') { ctx.fillStyle = '#fff3dd'; ctx.beginPath(); ctx.ellipse(x, y + r * .4, r * .5, r * .35, 0, 0, 7); ctx.globalAlpha *= .6; ctx.fill(); ctx.globalAlpha /= .6; }
502
+ }
503
+
504
+ // ---------------------------------------------------------------- 12. ATH 2025
505
+ const ath = {
506
+ draw(ctx, L, s) {
507
+ const { t, T } = s, st = L.stage, P = L.portrait;
508
+ const c1 = s.cue(1), c2 = s.cue(2);
509
+ const rect = P ? { x: st.x + 90, y: st.y + 360, w: st.w - 120, h: st.h - 420 } : { x: st.x + 120, y: st.y + 40, w: st.w - 160, h: st.h - 100 };
510
+ const prog = eIO(inv(.3, c1 + .4, t));
511
+ const dim = 1 - .55 * win(t, c1 + .4, .5);
512
+ const head = drawChart(ctx, rect, { from: idx('2024-01-01'), to: idx('2025-01-19'), prog, alpha: win(t, 0, .5) * dim, yMin: 50, yMax: 290, gridVals: [100, 150, 200, 250] });
513
+ // fireworks
514
+ if (t > c1 + .3) {
515
+ for (let k = 0; k < 14; k++) {
516
+ const t0 = c1 + .3 + k * .42, d = t - t0; if (d < 0) continue;
517
+ const x = st.x + hash1(k * 7) * st.w, y = st.y + 60 + hash1(k * 9) * st.h * .55;
518
+ const up = inv(0, .35, d); if (up < 1) { ctx.save(); ctx.fillStyle = GOLD; ctx.globalAlpha = .8; ctx.beginPath(); ctx.arc(x, L.H - (L.H - y) * eOut(up), 4, 0, 7); ctx.fill(); ctx.restore(); continue; }
519
+ burst(ctx, x, y, d - .35, { r: 200 + hash1(k) * 140, n: 44, seed: k + 20, color: [GREEN, PINK, GOLD, TEAL][k % 4], dur: 1.5, size: 5 });
520
+ }
521
+ }
522
+ const aN = win(t, c1 + .4, .5, eBack);
523
+ if (aN > 0) {
524
+ const v = 262.09 * eOut(inv(c1 + .4, c1 + 1.6, t));
525
+ const y = P ? st.y + 160 : st.cy - 60;
526
+ glow(ctx, st.cx, y, 520, GREEN, .45 * clamp(aN));
527
+ ctx.save(); ctx.translate(st.cx, y); ctx.scale(clamp(aN, 0, 1.2), clamp(aN, 0, 1.2));
528
+ bigNum(ctx, '$' + v.toFixed(2), 0, 0, P ? 170 : 220, { c1: '#b3ffdf', c2: GREEN });
529
+ ctx.restore();
530
+ pill(ctx, 'ATH · 2025.01.19 日收盘', st.cx, y + (P ? 120 : 150), { size: P ? 32 : 36, alpha: clamp(aN), color: GOLD });
531
+ }
532
+ const a2 = win(t, c2, .5);
533
+ if (a2 > 0) {
534
+ const y = P ? st.y + st.h - 20 : st.cy + 250;
535
+ text(ctx, '$0.74', st.cx - (P ? 230 : 300), y, { size: P ? 60 : 70, fam: F_GR, weight: 700, color: 'rgba(255,255,255,.75)', align: 'center', alpha: a2 });
536
+ text(ctx, '→', st.cx - (P ? 90 : 120), y, { size: 60, fam: F_GR, weight: 700, color: WHITE, align: 'center', alpha: a2 });
537
+ text(ctx, '$262', st.cx + (P ? 30 : 40), y, { size: P ? 60 : 70, fam: F_GR, weight: 700, color: GREEN, align: 'center', alpha: a2 });
538
+ stamp(ctx, '×350', st.cx + (P ? 230 : 330), y - 10, t, c2 + .4, { size: P ? 70 : 84, color: GOLD, fam: F_GR, rot: -.1 });
539
+ text(ctx, '2020.04.10 首个日线收盘 → 2025.01.19', st.cx, y + (P ? 70 : 80), { size: 26, weight: 600, color: 'rgba(255,255,255,.55)', align: 'center', alpha: a2 });
540
+ }
541
+ void head;
542
+ },
543
+ sfx(s) {
544
+ const ev = [{ t: .3, k: 'climb', d: s.cue(1) + .1 }, { t: s.cue(1) + .4, k: 'impact', v: 1 }, { t: s.cue(1) + .45, k: 'cheer', d: 4 }];
545
+ for (let k = 0; k < 14; k++) ev.push({ t: s.cue(1) + .3 + k * .42 + .35, k: 'firework', v: .55, p: k });
546
+ ev.push({ t: s.cue(2) + .4, k: 'stamp', v: .9 });
547
+ return ev;
548
+ },
549
+ };
550
+
551
+ // ---------------------------------------------------------------- 13. WALL ST 2025
552
+ const wall = {
553
+ draw(ctx, L, s) {
554
+ const { t, T } = s, st = L.stage, P = L.portrait;
555
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3);
556
+ const out = win(t, c3 - .2, .6);
557
+ // exchange facade
558
+ const aF = win(t, 0, .8) * (1 - out);
559
+ if (aF > 0) {
560
+ const fx = st.cx, fb = st.y + st.h - (P ? 30 : 10), fw = P ? 760 : 980, fh = P ? 520 : 560;
561
+ ctx.save(); ctx.globalAlpha = aF;
562
+ ctx.fillStyle = '#1c2236'; ctx.beginPath(); ctx.moveTo(fx - fw / 2 - 30, fb - fh + 110); ctx.lineTo(fx, fb - fh); ctx.lineTo(fx + fw / 2 + 30, fb - fh + 110); ctx.closePath(); ctx.fill();
563
+ ctx.strokeStyle = hexA(GOLD, .7); ctx.lineWidth = 4; ctx.stroke();
564
+ ctx.fillStyle = '#232a42'; ctx.fillRect(fx - fw / 2 - 20, fb - fh + 110, fw + 40, 40);
565
+ text(ctx, 'WALL STREET', fx, fb - fh + 131, { size: 28, fam: F_GR, weight: 700, color: GOLD, align: 'center', ls: 8 });
566
+ const n = P ? 6 : 8;
567
+ for (let i = 0; i < n; i++) { const x = fx - fw / 2 + (i + .5) * fw / n; const g = ctx.createLinearGradient(x - 22, 0, x + 22, 0); g.addColorStop(0, '#2e3654'); g.addColorStop(.5, '#59628a'); g.addColorStop(1, '#2e3654'); ctx.fillStyle = g; ctx.fillRect(x - 22, fb - fh + 150, 44, fh - 190); }
568
+ ctx.fillStyle = '#1c2236'; ctx.fillRect(fx - fw / 2 - 40, fb - 40, fw + 80, 40);
569
+ ctx.restore();
570
+ // door light (c2)
571
+ const dl = win(t, s.at(2, '大门') - .3, .8);
572
+ if (dl > 0) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const g = ctx.createLinearGradient(0, fb - 260, 0, L.H); g.addColorStop(0, hexA(GREEN, .55 * dl)); g.addColorStop(1, hexA(PURPLE, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(fx - 50, fb - 220); ctx.lineTo(fx + 50, fb - 220); ctx.lineTo(fx + 300 * dl, L.H); ctx.lineTo(fx - 300 * dl, L.H); ctx.fill(); ctx.restore(); glow(ctx, fx, fb - 160, 200, GREEN, dl * .7); }
573
+ }
574
+ // c1: balance sheet
575
+ const aB = win(t, c1 - .1, .5) * (1 - win(t, c2 - .1, .4));
576
+ if (aB > 0) {
577
+ const w = P ? 640 : 620, h = 420, x = st.cx - w / 2, y = st.cy - h / 2 - (P ? 40 : 30);
578
+ ctx.save(); ctx.globalAlpha = aB; ctx.translate(0, (1 - aB) * 60);
579
+ rr(ctx, x, y, w, h, 18); ctx.fillStyle = '#f5f2ea'; ctx.fill();
580
+ text(ctx, '资产负债表 · 资产', x + 40, y + 50, { size: 36, weight: 900, color: '#1a1030' });
581
+ ctx.fillStyle = '#c9c2b0'; ctx.fillRect(x + 40, y + 84, w - 80, 3);
582
+ const rows = [['现金及等价物', '$ ███'], ['短期投资', '$ ███'], ['数字资产 · SOL', '◎ ████']];
583
+ rows.forEach(([a, b], i) => {
584
+ const ra = i < 2 ? 1 : win(t, s.at(1, 'SOL'), .4);
585
+ const yy = y + 140 + i * 80;
586
+ if (i === 2 && ra > 0) { ctx.globalAlpha = aB * ra; rr(ctx, x + 24, yy - 34, w - 48, 68, 12); ctx.fillStyle = hexA(GREEN, .25); ctx.fill(); }
587
+ text(ctx, a, x + 40, yy, { size: 32, weight: 700, color: '#2a2040', alpha: ra });
588
+ text(ctx, b, x + w - 40, yy, { size: 32, fam: F_MONO, weight: 700, color: i === 2 ? '#0a7a50' : '#2a2040', align: 'right', alpha: ra });
589
+ ctx.globalAlpha = aB;
590
+ });
591
+ ctx.restore();
592
+ stamp(ctx, '上市公司', st.cx + (P ? 180 : 260), st.cy - h / 2 - (P ? 40 : 20), t, c1 + .5, { size: 54, color: PURPLE, rot: .12 });
593
+ }
594
+ // c2: ETF board
595
+ const aE = win(t, c2 - .1, .5) * (1 - out);
596
+ if (aE > 0) {
597
+ const y = P ? st.y + 120 : st.y + 70, w = P ? 780 : 1000;
598
+ ctx.save(); ctx.globalAlpha = aE;
599
+ rr(ctx, st.cx - w / 2, y - 70, w, 140, 16); ctx.fillStyle = '#05070c'; ctx.fill(); ctx.strokeStyle = GOLD; ctx.lineWidth = 4; ctx.stroke();
600
+ ctx.restore();
601
+ const msg = '现货 SOLANA ETF';
602
+ const flip = inv(c2, c2 + 1.2, t);
603
+ const shown = Array.from(msg).map((ch, i) => (flip * (msg.length + 4) - i > 1 ? ch : '▒')).join('');
604
+ text(ctx, shown, st.cx, y + 2, { size: P ? 64 : 76, weight: 900, color: GOLD, align: 'center', alpha: aE, ls: 4, shadow: 20, shadowColor: GOLD });
605
+ pill(ctx, '2025 年 10 月底 · 美国首批上市交易', st.cx, y + 115, { size: 28, alpha: win(t, c2 + 1, .4) * aE, color: GREEN });
606
+ }
607
+ // c3: Alpenglow
608
+ const aA = win(t, c3 - .1, .7);
609
+ if (aA > 0) {
610
+ const base = st.y + st.h + (P ? -60 : 40);
611
+ ctx.save(); ctx.globalAlpha = aA;
612
+ const sky = ctx.createLinearGradient(0, 0, 0, L.H); sky.addColorStop(0, 'rgba(30,10,50,.85)'); sky.addColorStop(1, 'rgba(255,110,140,.45)'); ctx.fillStyle = sky; ctx.fillRect(0, 0, L.W, L.H);
613
+ for (let layer = 0; layer < 3; layer++) {
614
+ ctx.beginPath(); ctx.moveTo(0, L.H);
615
+ const r = rng(30 + layer), peaks = 7 + layer * 2;
616
+ let x = -60; ctx.lineTo(x, base - 100);
617
+ for (let i = 0; i <= peaks; i++) { x = (i + .5) / peaks * (L.W + 120) - 60; const h = (360 - layer * 90) * (.5 + r() * .7); ctx.lineTo(x - 50, base - h * .55); ctx.lineTo(x, base - h); }
618
+ ctx.lineTo(L.W + 60, base - 80); ctx.lineTo(L.W, L.H); ctx.closePath();
619
+ const g = ctx.createLinearGradient(0, base - 400, 0, base); g.addColorStop(0, ['#ff7aa8', '#c04a8a', '#3a1a4a'][layer]); g.addColorStop(1, ['#4a1a5a', '#2a0f3a', '#120618'][layer]);
620
+ ctx.fillStyle = g; ctx.fill();
621
+ }
622
+ ctx.restore();
623
+ glow(ctx, L.W * .7, base - 400, 500, '#ff7aa8', .4 * aA);
624
+ const ty = P ? st.y + 110 : st.y + 60;
625
+ gradText(ctx, 'Alpenglow', st.cx, ty, { size: P ? 100 : 120, fam: F_GR, weight: 700, align: 'center', alpha: aA, c1: '#ffd1e0', c2: GREEN });
626
+ text(ctx, '新共识 · 2025 年获验证者投票通过', st.cx, ty + (P ? 80 : 90), { size: P ? 34 : 38, weight: 800, color: WHITE, align: 'center', alpha: aA });
627
+ // finality bars
628
+ const p = eIO(inv(s.at(3, '目标'), s.end(3), t));
629
+ const bx = P ? st.x + 30 : st.cx - 600, bw = P ? st.w - 60 : 1200, by = ty + (P ? 250 : 230);
630
+ const rows = [['当前最终确认 ≈ 12.8 秒', 1, '#c0c6dc'], ['Alpenglow 目标 ≈ 150 毫秒', lerp(1, .012, p), GREEN]];
631
+ rows.forEach(([lab, v, col], i) => {
632
+ const y = by + i * 130;
633
+ ctx.save(); ctx.globalAlpha = aA * win(t, c3 + .5 + i * .4, .4);
634
+ text(ctx, lab, bx, y - 40, { size: P ? 32 : 34, weight: 800, color: col });
635
+ rr(ctx, bx, y - 12, Math.max(14, bw * v), 40, 20); ctx.fillStyle = col; ctx.fill();
636
+ ctx.restore();
637
+ });
638
+ if (p > .9) { glow(ctx, bx + 14, by + 138, 80, GREEN, .8 * aA); text(ctx, '≈ 85× 更快', bx + 60, by + 138, { size: 34, weight: 900, color: GREEN, alpha: win(t, s.end(3) - .3, .4) }); }
639
+ }
640
+ void T;
641
+ },
642
+ sfx(s) {
643
+ return [
644
+ { t: 0, k: 'bell', v: .5 }, { t: s.cue(1), k: 'paper' }, { t: s.at(1, 'SOL'), k: 'pop' }, { t: s.cue(1) + .5, k: 'stamp', v: .8 },
645
+ { t: s.cue(2), k: 'flipboard', d: 1.2 }, { t: s.at(2, '大门') - .3, k: 'bell', v: 1 }, { t: s.at(2, '大门'), k: 'shimmer' },
646
+ { t: s.cue(3) - .1, k: 'riser', v: .5 }, { t: s.at(3, '目标'), k: 'zip', d: s.end(3) - s.at(3, '目标') }, { t: s.end(3), k: 'chime' },
647
+ ];
648
+ },
649
+ };
650
+
651
+ // ---------------------------------------------------------------- 14. NOW — the whole ride
652
+ const MARKS = [['2020-04-10', '起点'], ['2021-11-07', '2021 高点'], ['2022-12-30', 'FTX 后低点'], ['2023-12-31', '重回 $100'], ['2025-01-19', '历史新高'], [null, '今天']];
653
+ const now = {
654
+ draw(ctx, L, s) {
655
+ const { t } = s, st = L.stage, P = L.portrait;
656
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3);
657
+ const rect = P ? { x: st.x + 90, y: st.y + 160, w: st.w - 130, h: st.h - 260 } : { x: st.x + 120, y: st.y + 40, w: st.w - 200, h: st.h - 110 };
658
+ const prog = eIO(inv(.3, s.end(1) + .2, t));
659
+ const head = drawChart(ctx, rect, { from: 0, to: LAST, prog, log: true, yMin: .4, yMax: 420, gridVals: [1, 10, 100], headLabel: t > s.end(1), lw: 5 });
660
+ // milestone markers
661
+ MARKS.forEach(([d, lab], i) => {
662
+ const di = d ? idx(d) : LAST;
663
+ if (head.i < di) return;
664
+ const x = head.X(di), y = head.Y(price(di));
665
+ const a = win(t, .3 + (s.end(1) - .1) * (di / LAST), .35, eBack) * (1 - .5 * win(t, c2, .5));
666
+ const up = i % 2 === 0 ? -1 : 1, off = (P ? 70 : 80) * (i === 2 ? 1 : up === -1 ? -1 : -1);
667
+ glow(ctx, x, y, 40, GOLD, .7 * clamp(a));
668
+ ctx.save(); ctx.globalAlpha = clamp(a); ctx.fillStyle = GOLD; ctx.beginPath(); ctx.arc(x, y, 8, 0, 7); ctx.fill(); ctx.restore();
669
+ if (i === 5) return;
670
+ const yy = y + off - (i === 4 ? 30 : 0);
671
+ text(ctx, fmtUSD(price(di)), x, yy - 14, { size: P ? 24 : 28, fam: F_MONO, weight: 800, color: WHITE, align: 'center', alpha: clamp(a), stroke: 5, strokeColor: 'rgba(0,0,0,.7)' });
672
+ text(ctx, lab, x, yy + 16, { size: P ? 20 : 22, weight: 700, color: GOLD, align: 'center', alpha: clamp(a), stroke: 4, strokeColor: 'rgba(0,0,0,.7)' });
673
+ });
674
+ // rollercoaster cart riding the head
675
+ if (prog > 0 && prog < 1) {
676
+ const ang = Math.atan(head.slope);
677
+ ctx.save(); ctx.translate(head.x, head.y); ctx.rotate(ang); ctx.translate(0, -26);
678
+ rr(ctx, -34, -18, 68, 30, 8); ctx.fillStyle = PINK; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.stroke();
679
+ ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(-18, 14, 7, 0, 7); ctx.arc(18, 14, 7, 0, 7); ctx.fill();
680
+ ctx.fillStyle = '#1a1030'; ctx.beginPath(); ctx.arc(-8, -26, 9, 0, 7); ctx.arc(12, -26, 9, 0, 7); ctx.fill();
681
+ ctx.restore();
682
+ }
683
+ // c2: 2026 range band
684
+ const aR = win(t, c2, .5);
685
+ if (aR > 0) {
686
+ const x0 = head.X(idx('2026-01-01')), x1 = head.X(LAST), y0 = head.Y(146.51), y1 = head.Y(62.18);
687
+ ctx.save(); ctx.globalAlpha = aR;
688
+ ctx.fillStyle = hexA(TEAL, .16); ctx.fillRect(x0, y0, x1 - x0 + 10, y1 - y0);
689
+ ctx.strokeStyle = TEAL; ctx.setLineDash([8, 6]); ctx.lineWidth = 2; ctx.strokeRect(x0, y0, x1 - x0 + 10, y1 - y0); ctx.setLineDash([]);
690
+ ctx.restore();
691
+ text(ctx, '2026 震荡区间 $62 – $147', x0 - 16, y1 + 34, { size: P ? 24 : 28, weight: 800, color: TEAL, align: 'right', alpha: aR, stroke: 5, strokeColor: 'rgba(0,0,0,.7)' });
692
+ }
693
+ const aN = win(t, c3, .5);
694
+ if (aN > 0) {
695
+ const y = P ? st.y + 40 : st.y + 10;
696
+ ctx.save(); ctx.globalAlpha = aN; glass(ctx, st.cx - (P ? 360 : 380), y - 50, P ? 720 : 760, 100, {}); ctx.restore();
697
+ text(ctx, 'SOL ≈ $117 · 2026.10.07', st.cx, y + 2, { size: P ? 46 : 52, fam: F_GR, weight: 700, color: WHITE, align: 'center', alpha: aN });
698
+ }
699
+ text(ctx, '数据:Surf · SOL/USD 日收盘价(对数坐标)', rect.x + rect.w, rect.y + rect.h + (P ? 60 : 56), { size: 20, weight: 500, color: 'rgba(255,255,255,.45)', align: 'right', alpha: win(t, .3, .5) });
700
+ },
701
+ sfx(s) {
702
+ const ev = [{ t: .3, k: 'coaster', d: s.end(1) - .1 }];
703
+ MARKS.forEach(([d]) => { const di = d ? idx(d) : LAST; ev.push({ t: .3 + (s.end(1) - .1) * (di / LAST), k: 'blip', v: .6, p: 3 }); });
704
+ ev.push({ t: s.cue(2), k: 'whoosh', v: .4 }, { t: s.cue(3), k: 'chime' });
705
+ return ev;
706
+ },
707
+ };
708
+
709
+ // ---------------------------------------------------------------- 15. OUTRO
710
+ const outro = {
711
+ draw(ctx, L, s) {
712
+ const { t, T } = s, st = L.stage, P = L.portrait;
713
+ const c1 = s.cue(1), c2 = s.cue(2), c3 = s.cue(3), c4 = s.cue(4);
714
+ const cx = st.cx, cy = P ? st.cy - 120 : st.cy - 20;
715
+ const R = P ? 330 : 300;
716
+ const aC = win(t, 0, 1) * (1 - .65 * win(t, c2 - .2, .5) * (1 - win(t, c3, .6)));
717
+ // chain ring of hashes around the clock
718
+ const beads = 36;
719
+ ctx.save(); ctx.globalAlpha = aC;
720
+ for (let i = 0; i < beads; i++) {
721
+ const lit = (t / .4) % beads;
722
+ const a = (i / beads) * Math.PI * 2 - Math.PI / 2 + t * .05;
723
+ const x = cx + Math.cos(a) * R * 1.18, y = cy + Math.sin(a) * R * 1.18;
724
+ const on = ((lit - i + beads) % beads) < 8;
725
+ ctx.fillStyle = on ? GREEN : hexA(PURPLE, .5); ctx.beginPath(); ctx.arc(x, y, on ? 9 : 6, 0, 7); ctx.fill();
726
+ if (on) glow(ctx, x, y, 26, GREEN, .5);
727
+ }
728
+ ctx.restore();
729
+ dial(ctx, cx, cy, R, { alpha: aC, hand: Math.floor(t / .4) / 60, minute: t / 600, lw: 4 });
730
+ glow(ctx, cx, cy, R * 1.3, PURPLE, .35 * aC);
731
+ solLogo(ctx, cx, cy, R * .8, eOut(inv(.2, 1.6, t)), aC);
732
+ // c0/c1 quote lines
733
+ const q = [['第一个发明', '证明时间', 0, .1], ['被嘲笑 · 宕机 · 被判死刑', '又一次次站起来', 1, .1]];
734
+ q.forEach(([a1, a2, li]) => {
735
+ const a = win(t, s.cue(li) + .1, .5) * (1 - win(t, s.end(li) + .1, .3));
736
+ if (a <= 0) return;
737
+ const y = P ? cy + R + 200 : cy + R + 50;
738
+ text(ctx, a1, cx, y - 30, { size: P ? 40 : 38, weight: 700, color: 'rgba(255,255,255,.8)', align: 'center', alpha: a });
739
+ gradText(ctx, a2, cx, y + 36, { size: P ? 64 : 58, align: 'center', alpha: a });
740
+ });
741
+ // c2: the line
742
+ const aT = win(t, c2 - .1, .6) * (1 - win(t, c3 + .2, .5));
743
+ if (aT > 0) {
744
+ ctx.save(); ctx.fillStyle = `rgba(3,2,10,${.5 * aT})`; ctx.fillRect(0, 0, L.W, L.H); ctx.restore();
745
+ const y1 = P ? st.cy - 160 : st.cy - 90, y2 = P ? st.cy + 60 : st.cy + 90;
746
+ glowText(ctx, '速度,可以靠工程;', cx, y1, { size: P ? 80 : 104, weight: 900, color: WHITE, align: 'center', alpha: win(t, c2, .5), glowColor: PURPLE });
747
+ const a2 = win(t, s.at(2, '信任'), .5);
748
+ glow(ctx, cx, y2, 500, GREEN, .35 * a2 * aT);
749
+ gradText(ctx, '信任,只能靠时间。', cx, y2, { size: P ? 92 : 120, align: 'center', alpha: a2 * aT, c1: GOLD, c2: GREEN });
750
+ }
751
+ // c3: chain keeps writing
752
+ const a3 = win(t, c3, .5);
753
+ if (a3 > 0) {
754
+ const y = P ? cy + R + 170 : cy + R + 50;
755
+ const slot = Math.floor((T) / .4) + 420000000;
756
+ text(ctx, 'SLOT ' + slot.toLocaleString('en-US'), cx, y, { size: P ? 50 : 54, fam: F_MONO, weight: 800, color: GREEN, align: 'center', alpha: a3 * (1 - win(t, c4 - .2, .4)), shadow: 20, shadowColor: GREEN });
757
+ text(ctx, '每 400 毫秒 · 新的一页', cx, y + 64, { size: 34, weight: 700, color: 'rgba(255,255,255,.75)', align: 'center', alpha: a3 * (1 - win(t, c4 - .2, .4)) });
758
+ }
759
+ // c4: CTA
760
+ const a4 = win(t, c4 - .1, .5);
761
+ if (a4 > 0) {
762
+ const y = P ? cy + R + 170 : cy + R + 60;
763
+ const icons = [['赞', PINK], ['币', GOLD], ['藏', TEAL], ['关注', GREEN]];
764
+ icons.forEach(([lab, col], i) => {
765
+ const a = win(t, c4 + .2 + i * .25, .35, eBack); if (a <= 0) return;
766
+ const x = cx + (i - 1.5) * (P ? 170 : 190);
767
+ ctx.save(); ctx.translate(x, y); ctx.scale(a, a);
768
+ glow(ctx, 0, 0, 90, col, .5);
769
+ ctx.fillStyle = 'rgba(14,10,32,.9)'; ctx.beginPath(); ctx.arc(0, 0, 58, 0, 7); ctx.fill(); ctx.strokeStyle = col; ctx.lineWidth = 5; ctx.stroke();
770
+ text(ctx, lab, 0, 2, { size: lab.length > 1 ? 32 : 44, weight: 900, color: col, align: 'center' });
771
+ ctx.restore();
772
+ });
773
+ text(ctx, '仅为科普回顾 · 不构成任何投资建议', cx, y + 110, { size: 28, weight: 700, color: 'rgba(255,255,255,.6)', align: 'center', alpha: a4 });
774
+ text(ctx, 'SOLANA · 2017 → ∞', cx, P ? st.y + 20 : st.y - 30, { size: P ? 44 : 44, fam: F_GR, weight: 700, color: 'rgba(255,255,255,.85)', align: 'center', alpha: a4, ls: 8 });
775
+ }
776
+ },
777
+ sfx(s) {
778
+ const ev = [{ t: .1, k: 'shimmer' }];
779
+ for (let k = 1; k * .4 < 2.4; k++) ev.push({ t: k * .4, k: 'tick', v: .35 });
780
+ ev.push({ t: s.cue(2) - .1, k: 'impact', v: .6 }, { t: s.at(2, '信任'), k: 'shimmer' });
781
+ ev.push({ t: s.cue(3), k: 'hashtick', v: .35, p: 1 });
782
+ for (let i = 0; i < 4; i++) ev.push({ t: s.cue(4) + .2 + i * .25, k: 'pop', v: .6 });
783
+ ev.push({ t: s.d - 2.6, k: 'tick', v: .8 }, { t: s.d - 2.2, k: 'tick', v: .8 }, { t: s.d - 1.8, k: 'tick', v: 1 });
784
+ return ev;
785
+ },
786
+ };
787
+
788
+ export const SCENES_B = { outage, ftx, rebuild, meme, ath, wall, now, outro };
solana-clock/js/scenes.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import { SCENES_A } from './scenes-a.js';
2
+ import { SCENES_B } from './scenes-b.js';
3
+ export const SCENES = { ...SCENES_A, ...SCENES_B };
solana-clock/js/timeline.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Narration-driven timeline + layouts
2
+ import { SCRIPT } from './script-data.js';
3
+
4
+ const LEAD = { hook: 2.6, outro: 1.0 }; // silence before first line (s)
5
+ const TAIL = { hook: 1.6, outro: 4.5, ftx: 1.8, ath: 1.8 };
6
+ const GAP = { default: .42 };
7
+
8
+ export function buildTimeline() {
9
+ let T = 0;
10
+ const scenes = SCRIPT.map((sc, si) => {
11
+ const start = T;
12
+ let t = LEAD[sc.id] ?? 1.0;
13
+ const lines = sc.lines.map((ln, i) => {
14
+ const l = { ...ln, i, scene: sc.id, local: t, start: start + t, end: start + t + ln.dur };
15
+ t += ln.dur + (GAP[sc.id] ?? GAP.default);
16
+ return l;
17
+ });
18
+ t += (TAIL[sc.id] ?? 1.2) - (GAP[sc.id] ?? GAP.default);
19
+ T += t;
20
+ return { ...sc, si, start, end: start + t, dur: t, lines };
21
+ });
22
+ const lines = scenes.flatMap(s => s.lines);
23
+ return { scenes, lines, total: T };
24
+ }
25
+
26
+ export const SIZES = { landscape: [1920, 1080], portrait: [1080, 1920] };
27
+
28
+ /** Layout regions. Portrait keeps clear of notch/status bar (top), Douyin caption + nav (bottom) and action buttons (right). */
29
+ export function layout(orient) {
30
+ const [W, H] = SIZES[orient];
31
+ if (orient === 'landscape') {
32
+ return {
33
+ orient, W, H, portrait: false,
34
+ safe: { l: 90, t: 56, r: W - 90, b: H - 56 },
35
+ head: { x: 96, y: 92 },
36
+ stage: { x: 110, y: 170, w: W - 220, h: 700, cx: W / 2, cy: 170 + 350 },
37
+ sub: { cx: W / 2, y: H - 92, maxW: 1560, size: 50 },
38
+ unit: 1,
39
+ };
40
+ }
41
+ const safe = { l: 64, t: 236, r: W - 156, b: H - 450 };
42
+ return {
43
+ orient, W, H, portrait: true,
44
+ safe,
45
+ head: { x: 72, y: safe.t + 52 },
46
+ stage: { x: safe.l, y: safe.t + 170, w: safe.r - safe.l, h: 880, cx: (safe.l + safe.r) / 2, cy: safe.t + 170 + 440 },
47
+ sub: { cx: (safe.l + safe.r) / 2, y: safe.b - 76, maxW: safe.r - safe.l - 10, size: 50 },
48
+ unit: 1,
49
+ };
50
+ }
51
+
52
+ /** split a narration line into subtitle chunks with timings proportional to character count */
53
+ export function subChunks(line, maxChars) {
54
+ const parts = line.t.match(/[^,。;:?!、—]+[,。;:?!、—]*/g) || [line.t];
55
+ const chunks = [];
56
+ let cur = '';
57
+ const len = s => Array.from(s.replace(/[A-Za-z0-9.\-$%]/g, 'a')).reduce((n, ch) => n + (/[a-z]/.test(ch) ? .55 : 1), 0);
58
+ for (const p of parts) {
59
+ if (cur && len(cur + p) > maxChars) { chunks.push(cur); cur = p; } else cur += p;
60
+ }
61
+ if (cur) chunks.push(cur);
62
+ // split chunks still too long
63
+ const out = [];
64
+ for (const c of chunks) {
65
+ if (len(c) <= maxChars * 1.15) { out.push(c); continue; }
66
+ const arr = Array.from(c), n = Math.ceil(len(c) / maxChars), step = Math.ceil(arr.length / n);
67
+ for (let i = 0; i < arr.length; i += step) out.push(arr.slice(i, i + step).join(''));
68
+ }
69
+ const total = out.reduce((n, c) => n + len(c), 0);
70
+ let acc = 0;
71
+ return out.map(c => {
72
+ const a = line.start + line.dur * acc / total; acc += len(c);
73
+ return { text: c.replace(/[,。;、—]+$/, ''), start: a, end: line.start + line.dur * acc / total };
74
+ });
75
+ }
solana-clock/tools/fonts.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Subset fonts to only the characters used by the animation + UI -> fonts/*.woff2
3
+ Usage: python3 tools/fonts.py (expects source fonts in /tmp/fonts)
4
+ """
5
+ import pathlib, re
6
+ from fontTools import subset
7
+ from fontTools.ttLib import TTFont
8
+
9
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
10
+ SRC = pathlib.Path('/tmp/fonts')
11
+ OUT = ROOT / 'fonts'
12
+ OUT.mkdir(exist_ok=True)
13
+
14
+ chars = set()
15
+ for p in list((ROOT / 'js').glob('*.js')) + [ROOT / 'tools' / 'script.json', ROOT / 'index.html']:
16
+ if p.exists() and p.name != 'data-sol.js':
17
+ chars |= set(p.read_text(encoding='utf-8'))
18
+ chars |= set(chr(c) for c in range(0x20, 0x7f))
19
+ chars |= set('“”‘’…—–·•×÷±≈≥≤→←↑↓★☆●○◆▲▼✓✕%$,。!?:;、()《》【】')
20
+ text = ''.join(sorted(c for c in chars if ord(c) >= 0x20))
21
+ print('chars:', len(text))
22
+
23
+ JOBS = [
24
+ ('NotoSansSC-Black.otf', 'sol-sc-900.woff2', None),
25
+ ('NotoSansSC-Bold.otf', 'sol-sc-700.woff2', None),
26
+ ('NotoSansSC-Regular.otf', 'sol-sc-400.woff2', None),
27
+ ('SpaceGrotesk[wght].ttf', 'sol-grotesk.woff2', 'latin'),
28
+ ('JetBrainsMono[wght].ttf', 'sol-mono.woff2', 'latin'),
29
+ ]
30
+ for src, dst, kind in JOBS:
31
+ opts = subset.Options()
32
+ opts.flavor = 'woff2'
33
+ opts.layout_features = ['*']
34
+ opts.name_IDs = ['*']
35
+ opts.hinting = False
36
+ f = TTFont(SRC / src)
37
+ s = subset.Subsetter(opts)
38
+ t = text if kind is None else ''.join(c for c in text if ord(c) < 0x2500)
39
+ s.populate(text=t)
40
+ s.subset(f)
41
+ f.flavor = 'woff2'
42
+ f.save(OUT / dst)
43
+ print(dst, (OUT / dst).stat().st_size // 1024, 'KB')
solana-clock/tools/lint_layout.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Layout lint: instrument fillText/strokeText and path calls; report text that leaves the frame
3
+ (or the Douyin safe area in portrait) and NaN coordinates. Samples every STEP seconds."""
4
+ import sys, json, asyncio, pathlib
5
+ from playwright.async_api import async_playwright
6
+ base = 'http://localhost:8765/solana-clock/index.html'
7
+ STEP = float(sys.argv[1]) if len(sys.argv) > 1 else 0.5
8
+
9
+ JS = r"""
10
+ (async (STEP) => {
11
+ const P = CanvasRenderingContext2D.prototype;
12
+ const issues = new Map();
13
+ let cur = null, L = null;
14
+ const note = (k, msg) => { if (!issues.has(k)) issues.set(k, msg); };
15
+ for (const fn of ['fillText', 'strokeText']) {
16
+ const orig = P[fn];
17
+ P[fn] = function (str, x, y, maxW) {
18
+ if (cur !== null && this.canvas.id === 'view' && this.globalAlpha > 0.12 && String(str).trim()) {
19
+ const m = this.measureText(str);
20
+ let x0 = x - m.actualBoundingBoxLeft, x1 = x + m.actualBoundingBoxRight;
21
+ if (maxW && x1 - x0 > maxW) { const s = maxW / (x1 - x0); x0 = x + (x0 - x) * s; x1 = x + (x1 - x) * s; }
22
+ const y0 = y - m.actualBoundingBoxAscent, y1 = y + m.actualBoundingBoxDescent;
23
+ const t = this.getTransform(), sc = this.canvas.width / L.W;
24
+ const pts = [[x0, y0], [x1, y0], [x0, y1], [x1, y1]].map(([a, b]) => [(t.a * a + t.c * b + t.e) / sc, (t.b * a + t.d * b + t.f) / sc]);
25
+ const bx0 = Math.min(...pts.map(p => p[0])), bx1 = Math.max(...pts.map(p => p[0]));
26
+ const by0 = Math.min(...pts.map(p => p[1])), by1 = Math.max(...pts.map(p => p[1]));
27
+ const R = L.portrait ? L.safe : { l: 0, t: 0, r: L.W, b: L.H };
28
+ const tol = 6;
29
+ if (!window.__guides && (bx0 < R.l - tol || bx1 > R.r + tol || by0 < R.t - tol || by1 > R.b + tol)) {
30
+ const k = L.orient + '|' + cur.id + '|' + String(str).slice(0, 18);
31
+ note(k, `${L.orient} ${cur.id} @${cur.T.toFixed(1)} "${String(str).slice(0, 24)}" box=[${bx0|0},${by0|0},${bx1|0},${by1|0}] a=${this.globalAlpha.toFixed(2)}`);
32
+ }
33
+ }
34
+ return orig.apply(this, arguments);
35
+ };
36
+ }
37
+ for (const fn of ['moveTo', 'lineTo', 'arc', 'fillRect', 'quadraticCurveTo', 'bezierCurveTo', 'rect', 'drawImage', 'ellipse']) {
38
+ const orig = P[fn];
39
+ P[fn] = function (...a) {
40
+ if (cur && a.some(v => typeof v === 'number' && !Number.isFinite(v))) note('nan|' + cur.id + fn, `NaN in ${fn} ${L.orient} ${cur.id} @${cur.T.toFixed(1)}`);
41
+ return orig.apply(this, a);
42
+ };
43
+ }
44
+ const eng = await import('./js/engine.js'), tl = await import('./js/timeline.js');
45
+ const origGuides = eng.safeGuides;
46
+ for (const o of ['landscape', 'portrait']) {
47
+ L = tl.layout(o);
48
+ for (let T = 0; T < eng.TL.total; T += STEP) {
49
+ cur = { id: eng.sceneAt(T).id, T };
50
+ window.__render(T, o);
51
+ }
52
+ }
53
+ cur = null;
54
+ return [...issues.values()];
55
+ })
56
+ """
57
+
58
+ async def main():
59
+ async with async_playwright() as p:
60
+ b = await p.chromium.launch()
61
+ pg = await b.new_page(viewport={'width': 1400, 'height': 900})
62
+ errs = []
63
+ pg.on('pageerror', lambda e: errs.append(str(e)))
64
+ await pg.goto(base + '?nomix=1', wait_until='networkidle')
65
+ await pg.wait_for_function('window.__ready === true')
66
+ res = await pg.evaluate(JS + f'({STEP})')
67
+ print(len(res), 'issues'); print('\n'.join(res))
68
+ print('pageerrors:', errs)
69
+ await b.close()
70
+ asyncio.run(main())
solana-clock/tools/shoot.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Headless smoke test: load the player, render frames at many times in both orientations,
3
+ collect console errors, save screenshots to tools/out/.
4
+ Usage: python3 tools/shoot.py [base_url] [--every N] [--times 1,2,3]"""
5
+ import sys, json, pathlib, asyncio
6
+ from playwright.async_api import async_playwright
7
+
8
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
9
+ OUT = ROOT / 'tools' / 'out'; OUT.mkdir(parents=True, exist_ok=True)
10
+ base = next((a for a in sys.argv[1:] if a.startswith('http')), 'http://localhost:5173/solana-clock/index.html')
11
+ times = None
12
+ if '--times' in sys.argv: times = [float(x) for x in sys.argv[sys.argv.index('--times') + 1].split(',')]
13
+ orients = ['landscape', 'portrait']
14
+ if '--only' in sys.argv: orients = [sys.argv[sys.argv.index('--only') + 1]]
15
+
16
+ async def main():
17
+ async with async_playwright() as p:
18
+ b = await p.chromium.launch(args=['--autoplay-policy=no-user-gesture-required'])
19
+ errors = []
20
+ for o in orients:
21
+ pg = await b.new_page(viewport={'width': 1920, 'height': 1080} if o == 'landscape' else {'width': 1080, 'height': 1920})
22
+ pg.on('console', lambda m: errors.append(f'[{m.type}] {m.text}') if m.type in ('error', 'warning') else None)
23
+ pg.on('pageerror', lambda e: errors.append(f'[pageerror] {e}'))
24
+ await pg.goto(base + f'?nomix=1&o={o}', wait_until='networkidle')
25
+ await pg.wait_for_function('window.__ready === true', timeout=30000)
26
+ # make canvas exact design size for screenshots
27
+ await pg.add_style_tag(content='.top,.controls,.toggles,.side,.foot,.big-play,.loading{display:none!important}.app{padding:0;max-width:none}.layout{display:block}.stage-wrap{border:0;border-radius:0}.stage,.stage-wrap.portrait .stage{width:%dpx!important;height:%dpx!important}' % ((1920, 1080) if o == 'landscape' else (1080, 1920)))
28
+ await pg.wait_for_timeout(300)
29
+ scenes = await pg.evaluate('import("./js/engine.js").then(m => m.TL.scenes.map(s => ({id: s.id, start: s.start, dur: s.dur, cues: s.lines.map(l => l.local)})))')
30
+ ts = []
31
+ if times: ts = [(f't{t:07.2f}', t) for t in times]
32
+ else:
33
+ for s in scenes:
34
+ for k, f in enumerate([.12, .45, .8]):
35
+ ts.append((f"{s['id']}-{k}", s['start'] + s['dur'] * f))
36
+ for name, t in ts:
37
+ await pg.evaluate(f'window.__render({t})')
38
+ await pg.locator('#view').screenshot(path=str(OUT / f'{o[0]}-{name}.png'))
39
+ await pg.close()
40
+ await b.close()
41
+ print('frames ok')
42
+ print('\n'.join(dict.fromkeys(errors)) or 'no console errors')
43
+
44
+ asyncio.run(main())