0.1.5 update video and Chinese changelog
Real footage of the installed 0.1.5 apps, composited in Remotion with shape morphs between sections; synthesized score and UI sounds. Logs P3-17 (ElyTable keyboard zoom leaves the grid half-scrolled). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
out
|
||||
public/footage
|
||||
public/fonts/*.ttf
|
||||
public/fonts/*.otf
|
||||
node_modules
|
||||
public/audio
|
||||
__pycache__
|
||||
@@ -0,0 +1,26 @@
|
||||
# Ely Office 0.1.5 更新日志
|
||||
|
||||
## 全部应用
|
||||
- 二级菜单更好选:鼠标斜着移向子菜单时,路过的项不再抢先弹出;子菜单右侧放不下时改在左侧打开。
|
||||
- 应用已开着时再打开文件,直接在原窗口开成新标签,不再提示“已在运行”。
|
||||
- 中文显示统一:Windows 上同一个词不再一半粗一半细;界面里残留的英文改为中文。
|
||||
- macOS 安装包已签名并经 Apple 公证,打开时不再提示“无法验证开发者”。
|
||||
|
||||
## ElyMark
|
||||
- 表格可以在格内直接编辑:点哪格开哪格,Tab 与方向键在格间移动,回车或 Esc 写回。
|
||||
- 右键菜单随所点的内容变化:剪贴板、复制为 Markdown / HTML / 纯文本、格式、段落、表格、图片。
|
||||
- “段落”“格式”菜单与快捷键和 Typora 一致(如 Ctrl+1–6 设标题)。
|
||||
- 支持 `:表情:`、GitHub 提示块、`[toc]` 目录、front matter 与常用行内 HTML。
|
||||
- 侧栏可清除最近文件,重启后仍保持清空。
|
||||
|
||||
## ElyDoc
|
||||
- 右键菜单照 Word:正文、表格、图片各有对应的菜单。
|
||||
- 拖动表格边线即可调整列宽与行高。
|
||||
- 光标在表格里时,属性栏把表格设置排在最前。
|
||||
|
||||
## ElyTable
|
||||
- 右键菜单照 Excel:单元格、行列表头、图形各有对应的菜单。
|
||||
|
||||
## ElyShow
|
||||
- 右键菜单照 PowerPoint;表格边线可以拖动。
|
||||
- 左侧幻灯片缩略图条可以用滚轮滚动(修复 Linux Wayland 上的问题)。
|
||||
@@ -0,0 +1,50 @@
|
||||
# Ely Office 0.1.5 更新视频:规格
|
||||
|
||||
60.000 秒,1920×1080,60 fps,3600 帧。96 BPM,4/4:一拍 0.625 秒(37.5 帧),一小节 2.5 秒(150 帧),共 24 小节。段落切换都落在小节强拍上。
|
||||
|
||||
画面以**真实操作录屏**为主(已安装的 0.1.5,Windows,深色主题,中文界面)。录屏放在居中的“舞台”里,镜头在舞台内跟随指针平滑推近(像 Screen Studio)。段落之间用形变(shape morph)过渡:舞台收拢、变形成下一个应用的图标轮廓,再展开成下一个舞台。节奏从容,不快闪:任何画面至少停留一拍,形变用 0.6–1 秒。
|
||||
|
||||
## 时间表
|
||||
|
||||
| 小节 | 帧 | 段落 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1–2 | 0–300 | 开场 | 一个点在拍上呼吸,形变成六个应用图标的轮廓依次(每个至少一拍),最后化为“Ely Office 0.1.5”与“更顺手的一次更新”;第 3 小节强拍展开成第一个舞台 |
|
||||
| 3–6 | 300–900 | 菜单 | ElyMark 右键 → 表格 ▸,指针斜穿“插入”行进入子菜单,子菜单不被抢走;安全三角叠在录屏上 |
|
||||
| 7–10 | 900–1500 | 表格格内编辑 | ElyMark 点格即在格内编辑原文,Tab 到下一格,回车写回 |
|
||||
| 11–16 | 1500–2400 | 右键照 Word / Excel / PowerPoint | ElyDoc(表格右键、插入行、拖边线)、ElyTable(表头右键)、ElyShow(缩略图滚轮、表格右键) |
|
||||
| 17–21 | 2400–3150 | 还有 | 再开文件进同一窗口、清除最近文件、中文字形统一、macOS 已公证 |
|
||||
| 22–24 | 3150–3600 | 结尾 | 舞台收拢成点,再展开成六个图标与“Ely Office 0.1.5”、平台与下载地址;第 24 小节末结束 |
|
||||
|
||||
音乐的能量:开场渐起(无鼓),第 3 小节进入律动,第 11 小节加厚,第 17 小节最高,第 22 小节强拍落定后渐弱到 60 秒收尾。每个段落切换前有半小节的上扬(riser),强拍上有柔和的冲击(impact)。
|
||||
|
||||
## 视觉
|
||||
|
||||
- 底色 `#1c1c1e` 到 `#252525` 的极轻径向渐变,细微颗粒;舞台外不放杂物。
|
||||
- 强调色 `#3D7FE5`(ElyDoc 蓝);各应用色:Doc `#3D7FE5`,Table `#2E9E5B`,Show `#F07B2A`,PDF `#E0344F`,Tree `#7E57C2`,Mark `#3E4A5C`(点缀 `#F2A900`)。
|
||||
- 字体:中文 Noto Sans SC(`public/fonts/NotoSansSC.ttf`,可变字重),西文 Inter,等宽 JetBrains Mono。
|
||||
- 舞台:`STAGE = { x: 120, y: 118, w: 1680, h: 945 }`,圆角 18,1px `rgba(255,255,255,0.08)` 描边,柔和的大投影。录屏 2048×1152,按舞台宽缩放(0.8203)。
|
||||
- 段落标题在舞台上方左侧(y 30–100):中文大字 + 一行小字说明。
|
||||
- 动效:缓动用 `Easing.bezier(0.22, 1, 0.36, 1)`(出)与 `(0.65, 0, 0.35, 1)`(进出);弹簧 `damping 200`;无闪烁,无高频抖动。
|
||||
|
||||
## 代码结构(Remotion)
|
||||
|
||||
```text
|
||||
src/index.ts registerRoot
|
||||
src/Root.tsx <Composition id="Update" …>
|
||||
src/Video.tsx 各段 <Sequence>,音乐与音效
|
||||
src/theme.ts 帧、节拍、颜色、STAGE、字体
|
||||
src/design/ 共用:Stage、Heading、Caption、KeyCap、Morph、背景、字体加载
|
||||
src/scenes/Intro.tsx 开场
|
||||
src/scenes/Outro.tsx 结尾
|
||||
src/scenes/Menus.tsx … 录屏段落(由主导者写)
|
||||
src/footage/ 录屏的镜头跟随、事件读入
|
||||
public/footage/ 录屏与事件日志(不入库)
|
||||
public/audio/ music.wav 与音效
|
||||
```
|
||||
|
||||
`Stage` 负责舞台的外框、裁切与进出形变:`<Stage enter={{ from: 'ely-mark' }} exit={{ to: 'ely-doc' }}>{录屏}</Stage>`;进场时从图标轮廓(flubber 插值)展开成圆角矩形,出场时收拢成下一个图标的轮廓。裁切用 CSS `clip-path: path(...)`,录屏画在其中。
|
||||
|
||||
## 音频
|
||||
|
||||
- `public/audio/music.wav`:48 kHz 立体声,恰 60.000 秒,响度约 −16 LUFS,峰值 ≤ −1 dBTP,原创合成,无版权素材。
|
||||
- `public/audio/sfx/`:`click.wav`(轻点击)、`menu.wav`(菜单弹出的轻“嗒”)、`key.wav`(键入)、`whoosh.wav`(形变)、`impact.wav`(强拍落定)、`tick.wav`(细小提示)。都短、干净、不刺耳。
|
||||
@@ -0,0 +1,3 @@
|
||||
# Audio
|
||||
|
||||
`build.py` (score in `compose.py`, UI sounds in `sfx.py`) writes `public/audio/music.wav` (48 kHz, 24-bit stereo, 2,880,000 samples, 96 BPM, 24 bars of 2.5 s in F major) and the six UI sounds in `public/audio/sfx/`; it is deterministic (fixed seeds) and needs numpy, scipy, pedalboard (GPL-3.0, used only as a tool, its output is unencumbered) and pyloudnorm (MIT). Structure: bars 1–2 a rising felt-piano line over a Bb–Csus pad swell, no drums; bar 3 the groove enters (half-time kick, brushed snare on 2 and 4, eighth hats, sub bass, eighth-note pluck arpeggio, piano motif over F–Dm–Bb–C); bar 7 a lift (shaker, busier bass, motif answered higher, FM bell counter-line); bar 11 thicker (sixteenth arpeggio, octave strings, ghost snares, motif B through Gm–Csus); bar 17 the peak (four-on-the-floor, open hats, melody in octaves over Bb–C–Am–Dm–Gm–C); bar 22 a resolving Fadd9 hit with the drums gone, then piano echoes decaying to silence by 59.4 s. A half-bar riser (swept noise plus a reversed swell of the next chord) precedes the downbeats of bars 3, 7, 11, 17 and 22, each carrying a soft sub impact; drums stop in the half-bar before each for space (snare rolls before 17 and 22). Every sound is synthesized here (additive felt piano, polyBLEP saw pads, additive plucks, FM bells, synthesized drums, convolution reverb from a generated impulse); no samples, soundfonts or existing melodies are used. Mix: kick-keyed ducking on pads, bass, arp and reverb, a 2.6 kHz dip so UI sounds sit on top, section gain rides, bus compression, soft saturation and a 4×-oversampled look-ahead limiter; the result is normalized to −16 LUFS.
|
||||
@@ -0,0 +1,19 @@
|
||||
import numpy as np
|
||||
|
||||
from compose import OUT, SR, Mix, master, score, true_peak, write
|
||||
from sfx import sfx
|
||||
|
||||
|
||||
def main():
|
||||
mix = Mix()
|
||||
kicks = score(mix)
|
||||
x, lufs, stems = master(mix, kicks)
|
||||
write(OUT / "music.wav", x)
|
||||
print(f"music: {len(x) / SR:.3f} s, {lufs:.2f} LUFS, true peak {20 * np.log10(true_peak(x)):.2f} dBTP")
|
||||
print("stems (dB RMS):", {k: round(v, 1) for k, v in stems.items()})
|
||||
# Shares compose's generator, so runs after the music.
|
||||
sfx()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,496 @@
|
||||
# Usage: .work/video015/venv/Scripts/python.exe site/video/update-0.1.5/audio/compose.py
|
||||
"""Music bed (96 BPM, 24 bars, 60 s) and UI sounds for the 0.1.5 update video."""
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pedalboard as pb
|
||||
import pyloudnorm as pyln
|
||||
from scipy import signal
|
||||
from scipy.ndimage import minimum_filter1d, uniform_filter1d
|
||||
|
||||
SR = 48000
|
||||
BEAT = 30000
|
||||
BAR = 4 * BEAT
|
||||
N = 24 * BAR
|
||||
TAIL = 8 * SR
|
||||
OUT = Path(__file__).resolve().parent.parent / "public" / "audio"
|
||||
TARGET_LUFS = -16.0
|
||||
CEILING_DB = -1.4
|
||||
LIFTS = (3, 7, 11, 17, 22)
|
||||
BREAKS = (6, 10, 16, 21)
|
||||
GAINS = {"piano": 0.86, "pad": 0.245, "strings": 0.13, "arp": 0.14, "bell": 0.12,
|
||||
"bass": 0.16, "kick": 0.52, "snare": 0.64, "hats": 0.17, "fx": 0.37}
|
||||
rng = np.random.default_rng(105)
|
||||
|
||||
# chord: bass root, pad voicing, arp tones, piano left hand
|
||||
CHORDS = {
|
||||
"Bb": (34, [50, 57, 60, 62, 65], [62, 65, 69, 74], [46, 53, 62]),
|
||||
"Csus": (36, [55, 60, 65, 67, 72], [65, 67, 72, 74], [48, 55, 65]),
|
||||
"C": (36, [55, 60, 62, 64, 69], [64, 67, 72, 74], [48, 55, 64]),
|
||||
"F": (41, [53, 60, 64, 67, 69], [65, 69, 72, 76], [41, 48, 57]),
|
||||
"Dm": (38, [50, 57, 60, 65, 69], [62, 65, 69, 72], [50, 57, 65]),
|
||||
"Gm": (31, [53, 57, 58, 62, 65], [62, 65, 70, 74], [43, 50, 58]),
|
||||
"Am": (33, [52, 55, 60, 64, 67], [64, 67, 69, 72], [45, 52, 60]),
|
||||
"Fend": (29, [53, 60, 65, 67, 69, 72], [], [41, 48, 57, 65]),
|
||||
}
|
||||
CHANGES = [(0, "Bb"), (4, "Csus"), (8, "F"), (12, "Dm"), (16, "Bb"), (20, "C"),
|
||||
(24, "F"), (28, "Dm"), (32, "Bb"), (36, "C"), (40, "F"), (44, "Dm"),
|
||||
(48, "Bb"), (52, "C"), (56, "Gm"), (60, "Csus"), (62, "C"), (64, "Bb"),
|
||||
(68, "C"), (72, "Am"), (76, "Dm"), (80, "Gm"), (82, "Csus"), (84, "Fend")]
|
||||
|
||||
MEL_A = [(0, 72, 1.5, .62), (1.5, 77, .5, .5), (2, 76, 1, .55), (3, 72, 1, .46),
|
||||
(4, 74, 1.5, .6), (5.5, 69, .5, .46), (6, 72, 2, .55)]
|
||||
MEL_A1 = MEL_A + [(8, 74, 1.5, .6), (9.5, 77, .5, .5), (10, 76, 1, .55), (11, 72, 1, .46),
|
||||
(12, 67, 1.5, .55), (13.5, 72, .5, .46), (14, 74, 1, .5), (15, 76, 1, .48)]
|
||||
MEL_A2 = MEL_A + [(8, 74, 1.5, .62), (9.5, 77, .5, .52), (10, 81, 1, .6), (11, 79, 1, .52),
|
||||
(12, 76, 1.5, .58), (13.5, 74, .5, .48), (14, 72, 2, .55)]
|
||||
MEL_B = [(0, 77, 1, .66), (1, 76, .5, .5), (1.5, 72, 1, .55), (2.5, 69, .5, .48),
|
||||
(3, 72, 1, .52), (4, 74, 1, .62), (5, 72, .5, .5), (5.5, 69, 1, .52),
|
||||
(6.5, 65, .5, .45), (7, 69, 1, .5), (8, 74, 1, .62), (9, 77, .5, .52),
|
||||
(9.5, 81, 1.5, .62), (11, 79, 1, .52), (12, 76, 1.5, .6), (13.5, 74, .5, .5),
|
||||
(14, 72, 1, .52), (15, 74, 1, .5), (16, 77, 1.5, .62), (17.5, 74, .5, .5),
|
||||
(18, 70, 1, .52), (19, 72, 1, .5), (20, 74, 2, .58), (22, 76, 1, .6), (23, 79, 1, .66)]
|
||||
MEL_D = [(0, 81, 1.5, .72), (1.5, 79, .5, .56), (2, 77, 1, .62), (3, 74, 1, .56),
|
||||
(4, 76, 1.5, .68), (5.5, 72, .5, .52), (6, 79, 2, .64), (8, 76, 1.5, .68),
|
||||
(9.5, 72, .5, .52), (10, 74, 1, .58), (11, 76, 1, .56), (12, 77, 1.5, .68),
|
||||
(13.5, 76, .5, .54), (14, 74, 1, .58), (15, 72, 1, .54), (16, 74, 1.5, .64),
|
||||
(17.5, 77, .5, .56), (18, 76, 2, .62)]
|
||||
INTRO = [(0, 65, .3), (1, 69, .33), (2, 72, .36), (3, 74, .4),
|
||||
(4, 67, .42), (5, 72, .46), (6, 74, .5), (7, 77, .54)]
|
||||
BELLS = [(26.5, 81), (30.5, 79), (34.5, 81), (66.5, 84), (70.5, 84), (74.5, 81), (78.5, 81)]
|
||||
|
||||
|
||||
def hz(m):
|
||||
return 440.0 * 2 ** ((m - 69) / 12)
|
||||
|
||||
|
||||
def sample(beat):
|
||||
return int(round(beat * BEAT))
|
||||
|
||||
|
||||
def secs(n):
|
||||
return np.arange(n) / SR
|
||||
|
||||
|
||||
def filt(x, kind, f, order=2):
|
||||
return signal.sosfilt(signal.butter(order, f, kind, fs=SR, output="sos"), x, axis=0)
|
||||
|
||||
|
||||
def peaking(x, f0, gain_db, q):
|
||||
a, w = 10 ** (gain_db / 40), 2 * np.pi * f0 / SR
|
||||
alpha = np.sin(w) / (2 * q)
|
||||
b = [1 + alpha * a, -2 * np.cos(w), 1 - alpha * a]
|
||||
d = [1 + alpha / a, -2 * np.cos(w), 1 - alpha / a]
|
||||
return signal.lfilter(np.array(b) / d[0], np.array(d) / d[0], x, axis=0)
|
||||
|
||||
|
||||
def sweep(x, cutoff, kind, order=2, block=512):
|
||||
"""Filter whose corner follows cutoff(seconds); state carries across blocks."""
|
||||
y, zi = np.empty_like(x), None
|
||||
for s in range(0, len(x), block):
|
||||
e = min(s + block, len(x))
|
||||
sos = signal.butter(order, cutoff((s + e) / 2 / SR), kind, fs=SR, output="sos")
|
||||
if zi is None:
|
||||
zi = np.zeros((sos.shape[0], 2) + x.shape[1:])
|
||||
y[s:e], zi = signal.sosfilt(sos, x[s:e], axis=0, zi=zi)
|
||||
return y
|
||||
|
||||
|
||||
def curve(points, log=False):
|
||||
ts, vs = np.array(points).T
|
||||
vs = np.log(vs) if log else vs
|
||||
return lambda t: np.exp(np.interp(t, ts, vs)) if log else np.interp(t, ts, vs)
|
||||
|
||||
|
||||
def edges(x, a=0.003, r=0.02):
|
||||
x = x.copy()
|
||||
ai, ri = max(2, int(a * SR)), max(2, int(r * SR))
|
||||
up, down = np.linspace(0, 1, ai), np.linspace(1, 0, ri)
|
||||
if x.ndim == 2:
|
||||
up, down = up[:, None], down[:, None]
|
||||
x[:ai] *= up
|
||||
x[-ri:] *= down
|
||||
return x
|
||||
|
||||
|
||||
def place(x, pan):
|
||||
th = (pan + 1) * np.pi / 4
|
||||
return np.stack([x * np.cos(th), x * np.sin(th)], 1) * np.sqrt(2)
|
||||
|
||||
|
||||
def noise(n, ch=1):
|
||||
return rng.standard_normal((n, ch) if ch > 1 else n)
|
||||
|
||||
|
||||
class Mix:
|
||||
def __init__(self):
|
||||
self.stems, self.sends = {}, {k: self.blank() for k in ("hall", "room", "echo")}
|
||||
|
||||
@staticmethod
|
||||
def blank():
|
||||
return np.zeros((N + TAIL, 2))
|
||||
|
||||
def add(self, stem, sig, beat, gain=1.0, pan=0.0, **sends):
|
||||
sig = place(sig, pan) if sig.ndim == 1 else sig
|
||||
i = sample(beat)
|
||||
bus = self.stems.setdefault(stem, self.blank())
|
||||
n = min(len(sig), len(bus) - i)
|
||||
bus[i:i + n] += sig[:n] * gain
|
||||
for name, amount in sends.items():
|
||||
self.sends[name][i:i + n] += sig[:n] * gain * amount
|
||||
|
||||
|
||||
def piano(m, dur, vel):
|
||||
f0 = hz(m)
|
||||
n = int((dur + 0.5) * SR)
|
||||
t = secs(n)
|
||||
tau = np.clip(3.2 * (262 / f0) ** 0.7, 0.6, 7.0)
|
||||
bright = 800 + 2400 * vel
|
||||
y = np.zeros(n)
|
||||
for k in range(1, 16):
|
||||
fk = k * f0 * np.sqrt(1 + 0.0004 * k * k)
|
||||
if fk > 7000:
|
||||
break
|
||||
amp = k ** -1.2 / (1 + (fk / bright) ** 3)
|
||||
tk = tau / (1 + 0.45 * (k - 1))
|
||||
env = 0.6 * np.exp(-t / (0.3 * tk)) + 0.4 * np.exp(-t / tk)
|
||||
for cents in (-0.9, 0.2, 1.1):
|
||||
y += amp * env * np.sin(2 * np.pi * fk * 2 ** (cents / 1200) * t + rng.uniform(0, 6.28))
|
||||
off = int(dur * SR)
|
||||
y[off:] *= np.exp(-secs(n - off) / 0.14)
|
||||
k = int(0.012 * SR)
|
||||
y[:k] += filt(noise(k), "low", 900) * np.exp(-secs(k) / 0.003) * 0.12
|
||||
return edges(y, 0.006, 0.02) * vel ** 1.6 / 3
|
||||
|
||||
|
||||
def saw(f, n, phase):
|
||||
dt = f / SR
|
||||
p = (phase + dt * np.arange(n)) % 1.0
|
||||
y = 2 * p - 1
|
||||
lo = p < dt
|
||||
x = p[lo] / dt
|
||||
y[lo] -= 2 * x - x * x - 1
|
||||
hi = p > 1 - dt
|
||||
x = (p[hi] - 1) / dt
|
||||
y[hi] -= x * x + 2 * x + 1
|
||||
return y
|
||||
|
||||
|
||||
def pad(notes, dur, attack, release, octave=0, bright=1.0):
|
||||
n = int((dur + release) * SR)
|
||||
out = np.zeros((n, 2))
|
||||
for m in notes:
|
||||
f = hz(m + octave)
|
||||
for cents, side in ((-11, 0), (-4, 1), (4, 0), (11, 1)):
|
||||
v = saw(f * 2 ** (cents / 1200), n, rng.uniform())
|
||||
out[:, side] += 0.75 * v * bright
|
||||
out[:, 1 - side] += 0.25 * v * bright
|
||||
out += np.sin(2 * np.pi * f * secs(n))[:, None] * 0.6
|
||||
t = secs(n)
|
||||
env = np.minimum(1, t / attack) ** 2
|
||||
rel = np.clip((t - dur) / release, 0, 1)
|
||||
env *= np.cos(rel * np.pi / 2) ** 2
|
||||
return out * env[:, None] / len(notes)
|
||||
|
||||
|
||||
def pluck(m, vel):
|
||||
f0, n = hz(m), int(0.9 * SR)
|
||||
t = secs(n)
|
||||
y = np.zeros(n)
|
||||
for k in range(1, 25):
|
||||
if k * f0 > 7500:
|
||||
break
|
||||
y += k ** -1.3 * np.exp(-t / (0.5 / (1 + 0.7 * (k - 1)))) * np.sin(2 * np.pi * k * f0 * t + rng.uniform(0, 6.28))
|
||||
return edges(y, 0.0015, 0.05) * vel
|
||||
|
||||
|
||||
def bell(m, vel, dur=3.0):
|
||||
f, n = hz(m), int(dur * SR)
|
||||
t = secs(n)
|
||||
index = 1.6 * np.exp(-t / 0.35)
|
||||
y = np.sin(2 * np.pi * f * t + index * np.sin(2 * np.pi * 3.5 * f * t)) * np.exp(-t / 1.1)
|
||||
y += 0.3 * np.sin(2 * np.pi * 2 * f * t) * np.exp(-t / 0.5)
|
||||
return edges(y, 0.002, 0.1) * vel
|
||||
|
||||
|
||||
def bass(m, dur, vel, decay=None):
|
||||
f, n = hz(m), int((dur + 0.08) * SR)
|
||||
t = secs(n)
|
||||
y = np.sin(2 * np.pi * f * t) + 0.22 * np.sin(4 * np.pi * f * t) + 0.07 * np.sin(6 * np.pi * f * t)
|
||||
y = np.tanh(1.4 * y) / np.tanh(1.4)
|
||||
env = np.minimum(1, t / 0.008) * np.clip((dur + 0.06 - t) / 0.06, 0, 1)
|
||||
if decay:
|
||||
env *= np.exp(-t / decay)
|
||||
return y * env * vel
|
||||
|
||||
|
||||
def kick(vel):
|
||||
n = int(0.5 * SR)
|
||||
t = secs(n)
|
||||
f = 47 + 75 * np.exp(-t / 0.035)
|
||||
y = np.tanh(1.5 * np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t / 0.26))
|
||||
k = int(0.004 * SR)
|
||||
y[:k] += filt(noise(k), "low", 2200) * 0.2
|
||||
return edges(y, 0.001, 0.04) * vel
|
||||
|
||||
|
||||
def snare(vel):
|
||||
n = int(0.35 * SR)
|
||||
t = secs(n)
|
||||
env = 0.55 * np.exp(-t / 0.13)
|
||||
for d in (0.0, 0.009, 0.019):
|
||||
env += np.where(t >= d, np.exp(-(t - d) / 0.006), 0) * 0.6
|
||||
y = filt(noise(n), "band", [1200, 7500]) * env * np.minimum(1, t / 0.002)
|
||||
y += np.sin(2 * np.pi * 185 * t) * np.exp(-t / 0.045) * 0.35
|
||||
return edges(peaking(y, 2600, -5, 1.0), 0.001, 0.03) * vel
|
||||
|
||||
|
||||
def hat(vel, open_=False):
|
||||
n = int((0.3 if open_ else 0.08) * SR)
|
||||
t = secs(n)
|
||||
y = filt(noise(n), "band", [7000, 12500], 3) * np.exp(-t / (0.2 if open_ else 0.03))
|
||||
return edges(y, 0.001, 0.02) * vel
|
||||
|
||||
|
||||
def shaker(vel):
|
||||
n = int(0.09 * SR)
|
||||
t = secs(n)
|
||||
y = filt(noise(n), "band", [4800, 10000]) * np.minimum(1, t / 0.007) * np.exp(-t / 0.035)
|
||||
return edges(y, 0.001, 0.02) * vel
|
||||
|
||||
|
||||
def riser(strength, notes):
|
||||
n = int(1.25 * SR)
|
||||
t = secs(n)
|
||||
u = t / t[-1]
|
||||
swish = sweep(noise(n, 2), lambda s: [r := 300 * (16 ** ((s / 1.25) ** 1.4)), min(r * 2.4, 9000)], "band", 1)
|
||||
y = filt(swish, "low", 8000) * (u ** 2.4)[:, None] * 0.9
|
||||
swell = sum(np.sin(2 * np.pi * hz(m + 12) * t) for m in notes) / len(notes)
|
||||
y += place(swell * np.exp(5 * (u - 1)) * u * 0.6, 0)
|
||||
y[-int(0.02 * SR):] *= np.linspace(1, 0, int(0.02 * SR))[:, None]
|
||||
return y * strength
|
||||
|
||||
|
||||
def impact(strength, tail=2.4):
|
||||
n = int(tail * SR)
|
||||
t = secs(n)
|
||||
f = 38 + 32 * np.exp(-t / 0.12)
|
||||
boom = np.tanh(1.3 * np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t / 0.65))
|
||||
thump = filt(noise(n), "low", 220) * np.exp(-t / 0.05) * 0.8
|
||||
air = filt(noise(n, 2), "band", [250, 2200]) * np.exp(-t / 0.45)[:, None] * 0.07
|
||||
return edges(place(boom + thump, 0) + air, 0.001, 0.2) * strength
|
||||
|
||||
|
||||
def impulse(seconds, t60, seed):
|
||||
"""Stereo reverb impulse: decorrelated noise with per-band decay (low, mid, high)."""
|
||||
g = np.random.default_rng(seed)
|
||||
n = int(seconds * SR)
|
||||
t = secs(n)
|
||||
ir = np.zeros((n, 2))
|
||||
for ch in range(2):
|
||||
w = g.standard_normal(n)
|
||||
lo, hi = filt(w, "low", 500), filt(w, "high", 4000)
|
||||
bands = (lo, w - lo - hi, hi)
|
||||
ir[:, ch] = sum(b * 10 ** (-3 * t / T) for b, T in zip(bands, t60))
|
||||
ir = filt(ir, "low", 7000) * np.minimum(1, t / 0.02)[:, None]
|
||||
ir = np.concatenate([np.zeros((int(0.022 * SR), 2)), edges(ir, 0.001, seconds * 0.15)])
|
||||
return ir / np.sqrt((ir ** 2).sum(0))
|
||||
|
||||
|
||||
def reverb(x, ir):
|
||||
return np.stack([signal.fftconvolve(x[:, c], ir[:, c])[:len(x)] for c in range(2)], 1)
|
||||
|
||||
|
||||
def pingpong(x, beats=0.75, fb=0.4, taps=7):
|
||||
d, y, out = sample(beats), x.mean(1), np.zeros_like(x)
|
||||
for k in range(1, taps + 1):
|
||||
y = filt(filt(y, "low", 3200, 1), "high", 250, 1)
|
||||
out[k * d:, k % 2] += y[:len(x) - k * d] * fb ** (k - 1)
|
||||
return out
|
||||
|
||||
|
||||
def chord_at(beat):
|
||||
return CHORDS[[c for b, c in CHANGES if b <= beat][-1]]
|
||||
|
||||
|
||||
def drops(bar, beat):
|
||||
return bar in BREAKS and beat >= 2
|
||||
|
||||
|
||||
def score(mix):
|
||||
for i, (b, name) in enumerate(CHANGES):
|
||||
end = CHANGES[i + 1][0] if i + 1 < len(CHANGES) else 96
|
||||
notes = CHORDS[name][1]
|
||||
first, final = b == 0, name == "Fend"
|
||||
sig = pad(notes, (end - b) * 0.625, 2.6 if first else 0.25, 3.0 if final else 0.9)
|
||||
mix.add("pad", sig, b, hall=0.5)
|
||||
if 40 <= b < 84:
|
||||
mix.add("strings", pad(notes[-3:], (end - b) * 0.625, 0.4, 0.9, 12, 0.5), b, hall=0.6)
|
||||
|
||||
for x, m, v in INTRO:
|
||||
mix.add("piano", piano(m, 2.4, v), x, pan=(m - 70) / 30, hall=0.35)
|
||||
for x, name in ((0, "Bb"), (4, "Csus")):
|
||||
for m in CHORDS[name][3]:
|
||||
mix.add("piano", piano(m, 3.8, 0.3), x, pan=-0.2, hall=0.3)
|
||||
|
||||
for start, line, low in ((8, MEL_A1, 0), (24, MEL_A2, 0), (40, MEL_B, 0), (64, MEL_D, 1)):
|
||||
for x, m, d, v in line:
|
||||
mix.add("piano", piano(m, d * 0.625 + 0.15, v), start + x, pan=0.08, hall=0.28)
|
||||
if low:
|
||||
mix.add("piano", piano(m - 12, d * 0.625 + 0.15, v * 0.55), start + x, pan=-0.1, hall=0.28)
|
||||
for x, m, d, v in ((84, 77, 4.5, .66), (88, 84, 1.5, .32), (89.5, 81, .5, .27), (90, 77, 3.0, .26)):
|
||||
mix.add("piano", piano(m, d * 0.625, v), x, pan=0.1, hall=0.45)
|
||||
for m in CHORDS["Fend"][3]:
|
||||
mix.add("piano", piano(m, 4.0, 0.38), 84, pan=-0.15, hall=0.45)
|
||||
for x, m in BELLS + [(84, 89)]:
|
||||
mix.add("bell", bell(m, 0.5 if x < 84 else 0.35), x, pan=0.35, hall=0.7, echo=0.35)
|
||||
|
||||
kicks = []
|
||||
for bar in range(3, 22):
|
||||
base = (bar - 1) * 4
|
||||
section = 0 if bar < 7 else 1 if bar < 11 else 2 if bar < 17 else 3
|
||||
lh_vel = (0.3, 0.33, 0.37, 0.4)[section]
|
||||
for x in ((0,) if section < 2 else (0, 2)):
|
||||
for m in chord_at(base + x)[3]:
|
||||
mix.add("piano", piano(m, 1.8, lh_vel), base + x, pan=-0.2, hall=0.3)
|
||||
|
||||
pattern = ([0, 2], [0, 2, 2.5], [0, 0.75, 2, 2.5], [0, 1, 2, 3])[section]
|
||||
for x in pattern:
|
||||
if not drops(bar, x):
|
||||
kicks.append(base + x)
|
||||
mix.add("kick", kick(0.9 if x in (0, 2) else 0.7), base + x, room=0.05)
|
||||
for x in (1, 3):
|
||||
if not drops(bar, x):
|
||||
mix.add("snare", snare(0.55 + 0.1 * (section > 1)), base + x, pan=0.05, room=0.35, hall=0.12)
|
||||
if section > 1:
|
||||
for x in ((3.75,) if section == 2 else (1.75, 3.75)):
|
||||
if not drops(bar, x):
|
||||
mix.add("snare", snare(0.14), base + x, pan=0.1, room=0.3)
|
||||
if bar in (16, 21):
|
||||
for j in range(8):
|
||||
mix.add("snare", snare(0.12 + 0.05 * j), base + 2 + j * 0.25, pan=0.1, room=0.3, hall=0.1)
|
||||
for j in range(8):
|
||||
x = j * 0.5
|
||||
if drops(bar, x) and bar in (6, 10):
|
||||
continue
|
||||
jitter = rng.normal(0, 0.004)
|
||||
if section == 3 and j % 2:
|
||||
mix.add("hats", hat(0.32, True), base + x + jitter, pan=0.3, room=0.15)
|
||||
elif section == 2 and j == 7:
|
||||
mix.add("hats", hat(0.3, True), base + x + jitter, pan=0.3, room=0.15)
|
||||
else:
|
||||
mix.add("hats", hat(0.5 if j % 2 else 0.28), base + x + jitter, pan=0.3, room=0.1)
|
||||
if section > 0:
|
||||
for j in range(16):
|
||||
vel = (0.5, 0.22, 0.34, 0.22)[j % 4] * (1 + 0.3 * (section == 3))
|
||||
mix.add("hats", shaker(vel), base + j * 0.25 + rng.normal(0, 0.004), pan=-0.35, room=0.1)
|
||||
|
||||
step = 0.5 if section < 2 else 0.25
|
||||
for j in range(int(4 / step)):
|
||||
x = j * step
|
||||
tones = chord_at(base + x)[2]
|
||||
idx = ([0, 1, 2, 3, 2, 1, 2, 3] if section < 2 else [0, 1, 2, 3, 1, 2, 3, 2])[j % 8]
|
||||
m = tones[idx] + (12 if section == 3 and j % 8 in (3, 6) else 0)
|
||||
vel = (0.7 if j % 2 == 0 else 0.5) * (0.8 if step < 0.5 else 1.0)
|
||||
mix.add("arp", pluck(m, vel), base + x, pan=0.4 * np.sin(j * 0.9), hall=0.2, echo=0.32)
|
||||
|
||||
rhythm = ([(0, 1.8, 0), (2, 1.3, 0), (3.5, 0.4, 0)],
|
||||
[(0, 1.3, 0), (1.5, 0.4, 0), (2, 1.3, 0), (3.5, 0.4, 12)],
|
||||
[(0, .9, 0), (1, .4, 0), (1.5, .4, 12), (2, .9, 0), (3, .4, 0), (3.5, .4, 12)],
|
||||
[(0, .9, 0), (1, .4, 0), (1.5, .4, 12), (2, .9, 0), (3, .4, 0), (3.5, .4, 12)])[section]
|
||||
if bar in BREAKS:
|
||||
rhythm = [r for r in rhythm if r[0] < 2] + [(2, 1.9, 0)]
|
||||
for x, d, octave in rhythm:
|
||||
mix.add("bass", bass(chord_at(base + x)[0] + octave, d * 0.625, 0.8 if octave else 1.0), base + x)
|
||||
|
||||
kicks.append(84)
|
||||
mix.add("kick", kick(1.0), 84, room=0.05)
|
||||
mix.add("bass", bass(29, 5.0, 1.0, decay=1.8), 84)
|
||||
strengths = {3: (0.6, 0.6), 7: (0.55, 0.45), 11: (0.75, 0.7), 17: (1.0, 1.0), 22: (0.9, 1.0)}
|
||||
for bar in LIFTS:
|
||||
beat = (bar - 1) * 4
|
||||
rise, hit = strengths[bar]
|
||||
mix.add("fx", riser(rise, chord_at(beat)[1]), beat - 2, hall=0.3)
|
||||
mix.add("fx", impact(hit, 4.0 if bar == 22 else 2.4), beat, hall=0.45)
|
||||
return np.array(kicks)
|
||||
|
||||
|
||||
def duck(kicks, depth):
|
||||
g = np.ones(N + TAIL)
|
||||
shape = np.minimum(1, secs(int(0.5 * SR)) / 0.004) * np.exp(-secs(int(0.5 * SR)) / 0.13)
|
||||
for k in kicks:
|
||||
i = sample(k)
|
||||
n = min(len(shape), len(g) - i)
|
||||
g[i:i + n] = np.minimum(g[i:i + n], 1 - depth * shape[:n])
|
||||
return g[:, None]
|
||||
|
||||
|
||||
def run_board(board, x):
|
||||
return board(x.T.astype(np.float32), SR).T.astype(np.float64)
|
||||
|
||||
|
||||
def true_peak(x):
|
||||
return np.abs(signal.resample_poly(x, 4, 1, axis=0)).max()
|
||||
|
||||
|
||||
def limit(x, ceiling_db):
|
||||
"""Look-ahead limiter on 4x-oversampled peaks, release 120 ms."""
|
||||
c = 10 ** (ceiling_db / 20)
|
||||
peak = np.abs(signal.resample_poly(x, 4, 1, axis=0)).max(1)[:len(x) * 4].reshape(-1, 4).max(1)
|
||||
need = np.minimum(1, c / np.maximum(peak, 1e-9))
|
||||
look = 96
|
||||
red = 1 - minimum_filter1d(need, 2 * look + 1)
|
||||
r, a = np.empty_like(red), np.exp(-1 / (0.12 * SR))
|
||||
acc = 0.0
|
||||
for i, v in enumerate(red):
|
||||
acc = v if v > acc else acc * a
|
||||
r[i] = acc
|
||||
return x * uniform_filter1d(1 - r, look)[:, None]
|
||||
|
||||
|
||||
def master(mix, kicks):
|
||||
s = mix.stems
|
||||
gains = GAINS
|
||||
pad_lp = curve([(0, 220), (4.9, 1700), (5, 1500), (14.9, 1700), (15, 1900), (24.9, 2200),
|
||||
(25, 2300), (39.9, 2700), (40, 3000), (52.4, 3100), (52.5, 2600), (59, 450), (70, 450)], True)
|
||||
pad_bus = sweep(s["pad"] + s["strings"] * gains["strings"] / gains["pad"], pad_lp, "low", 4)
|
||||
pad_bus = run_board(pb.Pedalboard([pb.Chorus(rate_hz=0.22, depth=0.3, centre_delay_ms=9, mix=0.45)]), pad_bus)
|
||||
piano = peaking(filt(s["piano"], "high", 90), 2800, -2.5, 0.9)
|
||||
dry = (piano * gains["piano"] + pad_bus * gains["pad"] * duck(kicks, 0.4)
|
||||
+ s["arp"] * gains["arp"] * duck(kicks, 0.3) + s["bell"] * gains["bell"]
|
||||
+ s["bass"] * gains["bass"] * duck(kicks, 0.55) + s["kick"] * gains["kick"]
|
||||
+ s["snare"] * gains["snare"] + s["hats"] * gains["hats"] + s["fx"] * gains["fx"])
|
||||
sends = mix.sends
|
||||
echo = pingpong(sends["echo"] * 0.2)
|
||||
hall = reverb((sends["hall"] + echo * 0.5) * 0.4, impulse(3.6, (3.4, 2.6, 1.3), 7))
|
||||
room = reverb(sends["room"] * 0.25, impulse(0.9, (0.6, 0.5, 0.3), 11))
|
||||
wet = filt(hall, "high", 160) * duck(kicks, 0.25) + filt(room, "high", 200) + echo * 0.6
|
||||
ride = curve([(0, -10), (4.97, -4.5), (5.0, -3.2), (14.97, -3.2), (15, -2.2), (24.97, -2.2),
|
||||
(25, -1.1), (39.97, -1.1), (40, 0.8), (52.47, 0.8), (52.5, 0.4), (70, 0.4)])
|
||||
x = filt(dry + wet, "high", 28) * 10 ** (ride(secs(N + TAIL)) / 20)[:, None]
|
||||
x = peaking(x, 2600, -2.0, 0.8)
|
||||
x = x + 0.25 * filt(x, "high", 8000)
|
||||
x = run_board(pb.Pedalboard([pb.Compressor(threshold_db=-20, ratio=1.8, attack_ms=25, release_ms=250)]), x)
|
||||
x = x[:N]
|
||||
t = secs(N)
|
||||
x *= np.clip(np.cos(np.clip((t - 57.0) / 2.3, 0, 1) * np.pi / 2), 0, 1)[:, None] ** 2
|
||||
x = edges(x, 0.002, 0.01)
|
||||
meter = pyln.Meter(SR)
|
||||
for _ in range(3):
|
||||
x *= 10 ** ((TARGET_LUFS - meter.integrated_loudness(x)) / 20)
|
||||
x = np.tanh(x * 1.1) / 1.1
|
||||
x = limit(x, CEILING_DB)
|
||||
stems = {k: 20 * np.log10(np.sqrt((v[:N] ** 2).mean()) * gains.get(k, 1) + 1e-12) for k, v in s.items()}
|
||||
return x, meter.integrated_loudness(x), stems
|
||||
|
||||
|
||||
def write(path, x):
|
||||
x = np.atleast_2d(x.T).T
|
||||
q = np.round(np.clip(x, -1, 1 - 2 ** -23) * 2 ** 23).astype("<i4")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), "wb") as w:
|
||||
w.setnchannels(x.shape[1])
|
||||
w.setsampwidth(3)
|
||||
w.setframerate(SR)
|
||||
w.writeframes(q.view(np.uint8).reshape(-1, 4)[:, :3].tobytes())
|
||||
@@ -0,0 +1,43 @@
|
||||
import numpy as np
|
||||
|
||||
from compose import OUT, SR, edges, filt, impact, impulse, noise, place, reverb, secs, sweep, write
|
||||
|
||||
|
||||
def ui(x, peak_db=-6.0):
|
||||
x = x if x.ndim == 2 else place(x, 0) / np.sqrt(2)
|
||||
x = edges(x, 0.0008, 0.006)
|
||||
return x * 10 ** (peak_db / 20) / np.abs(x).max()
|
||||
|
||||
|
||||
def sfx():
|
||||
def env(n, decay):
|
||||
return np.exp(-secs(n) / decay)
|
||||
n = int(0.06 * SR)
|
||||
t = secs(n)
|
||||
click = (np.sin(2 * np.pi * 1150 * t) * env(n, 0.005) * 0.5 + np.sin(2 * np.pi * 380 * t) * env(n, 0.012) * 0.7
|
||||
+ filt(noise(n), "band", [2000, 6000]) * env(n, 0.0012) * 0.4)
|
||||
n = int(0.12 * SR)
|
||||
t = secs(n)
|
||||
f = 560 + 420 * (1 - np.exp(-t / 0.012))
|
||||
ph = 2 * np.pi * np.cumsum(f) / SR
|
||||
menu = (np.sin(ph) + 0.12 * np.sin(2 * ph)) * np.minimum(1, t / 0.0015) * env(n, 0.03)
|
||||
menu += filt(noise(n), "low", 3000) * env(n, 0.002) * 0.15
|
||||
n = int(0.05 * SR)
|
||||
t = secs(n)
|
||||
key = np.sin(2 * np.pi * 240 * t) * env(n, 0.008) * 0.7 + filt(noise(n), "band", [900, 4500]) * env(n, 0.0025) * 0.5
|
||||
late = int(0.011 * SR)
|
||||
key[late:] += filt(noise(n - late), "band", [600, 3000]) * env(n - late, 0.003) * 0.25
|
||||
n = int(0.7 * SR)
|
||||
u = secs(n) / 0.7
|
||||
body = 0.7 * noise(n)[:, None] + 0.3 * noise(n, 2)
|
||||
whoosh = sweep(body, lambda s: [c := 300 * 6 ** np.sin(np.pi * s / 0.7), c * 2.2], "band", 1)
|
||||
whoosh = filt(whoosh, "low", 4500) * (np.sin(np.pi * u ** 0.85) ** 2)[:, None]
|
||||
whoosh *= np.stack([np.cos(u * np.pi / 2), np.sin(u * np.pi / 2)], 1) * 1.2 + 0.3
|
||||
hit = impact(1.0, 1.5)
|
||||
hit = hit + filt(reverb(hit, impulse(1.0, (0.9, 0.6, 0.3), 3)), "low", 3000) * 0.25
|
||||
hit *= (np.cos(np.clip((secs(len(hit)) - 0.9) / 0.6, 0, 1) * np.pi / 2) ** 2)[:, None]
|
||||
n = int(0.09 * SR)
|
||||
t = secs(n)
|
||||
tick = (np.sin(2 * np.pi * 1568 * t) + 0.3 * np.sin(2 * np.pi * 2349 * t)) * np.minimum(1, t / 0.001) * env(n, 0.016)
|
||||
for name, x in (("click", click), ("menu", menu), ("key", key), ("whoosh", whoosh), ("impact", hit), ("tick", tick)):
|
||||
write(OUT / "sfx" / f"{name}.wav", ui(x))
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ely-update-0-1-5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"still": "remotion still src/index.ts",
|
||||
"render": "remotion render src/index.ts Update out/ely-office-0.1.5.mp4",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.535",
|
||||
"flubber": "^0.4.2",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"remotion": "^4.0.535"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.1": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="fold" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="0.96"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><path fill="#F4F4F2" d="M231 128C231 229.764 229.764 231 128 231C26.236 231 25 229.764 25 128C25 26.236 26.236 25 128 25C229.764 25 231 26.236 231 128Z"/><g transform="translate(39.420 43.420) scale(0.9227)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#000000"/></g><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#3D7FE5"/><g transform="translate(0 2)" opacity="0.2" filter="url(#fold)"><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#000000"/></g><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#94B9F1"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="84" width="64" height="12" rx="4" fill="#000000"/></g><rect x="56" y="84" width="64" height="12" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="108" width="80" height="8" rx="4" fill="#000000"/></g><rect x="56" y="108" width="80" height="8" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="124" width="80" height="8" rx="4" fill="#000000"/></g><rect x="56" y="124" width="80" height="8" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="140" width="80" height="8" rx="4" fill="#000000"/></g><rect x="56" y="140" width="80" height="8" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="156" width="48" height="8" rx="4" fill="#000000"/></g><rect x="56" y="156" width="48" height="8" rx="4" fill="#A8C5F3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="fold" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="0.96"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><g transform="scale(1.333333)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#000000"/></g><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#3D7FE5"/><g transform="translate(0 2)" opacity="0.2" filter="url(#fold)"><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#000000"/></g><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#94B9F1"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="84" width="64" height="12" rx="4" fill="#000000"/></g><rect x="56" y="84" width="64" height="12" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="108" width="80" height="8" rx="4" fill="#000000"/></g><rect x="56" y="108" width="80" height="8" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="124" width="80" height="8" rx="4" fill="#000000"/></g><rect x="56" y="124" width="80" height="8" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="140" width="80" height="8" rx="4" fill="#000000"/></g><rect x="56" y="140" width="80" height="8" rx="4" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="56" y="156" width="48" height="8" rx="4" fill="#000000"/></g><rect x="56" y="156" width="48" height="8" rx="4" fill="#A8C5F3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><path fill="#F4F4F2" d="M231 128C231 229.764 229.764 231 128 231C26.236 231 25 229.764 25 128C25 26.236 26.236 25 128 25C229.764 25 231 26.236 231 128Z"/><g transform="translate(39.420 43.420) scale(0.9227)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><rect x="28" y="24" width="136" height="152" rx="10" fill="#000000"/></g><rect x="28" y="24" width="136" height="152" rx="10" fill="#ECECEC"/><path d="M38 24H154A10 10 0 0 1 164 34V52H28V34A10 10 0 0 1 38 24Z" fill="#4F4F4F"/><rect x="46" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="70" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="94" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="118" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="142" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path d="M44 156V76H66L82 100L98 76H120V156H100V110L82 136L64 110V156Z" fill="#000000"/></g><path d="M44 156V76H66L82 100L98 76H120V156H100V110L82 136L64 110V156Z" fill="#3E4A5C"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path d="M128 76H148V124H164L138 158L112 124H128Z" fill="#000000"/></g><path d="M128 76H148V124H164L138 158L112 124H128Z" fill="#F2A900"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><g transform="scale(1.333333)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><rect x="28" y="24" width="136" height="152" rx="10" fill="#000000"/></g><rect x="28" y="24" width="136" height="152" rx="10" fill="#ECECEC"/><path d="M38 24H154A10 10 0 0 1 164 34V52H28V34A10 10 0 0 1 38 24Z" fill="#4F4F4F"/><rect x="46" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="70" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="94" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="118" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><rect x="142" y="32" width="10" height="14" rx="5" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path d="M44 156V76H66L82 100L98 76H120V156H100V110L82 136L64 110V156Z" fill="#000000"/></g><path d="M44 156V76H66L82 100L98 76H120V156H100V110L82 136L64 110V156Z" fill="#3E4A5C"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path d="M128 76H148V124H164L138 158L112 124H128Z" fill="#000000"/></g><path d="M128 76H148V124H164L138 158L112 124H128Z" fill="#F2A900"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="fold" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="0.96"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><path fill="#F4F4F2" d="M231 128C231 229.764 229.764 231 128 231C26.236 231 25 229.764 25 128C25 26.236 26.236 25 128 25C229.764 25 231 26.236 231 128Z"/><g transform="translate(39.420 43.420) scale(0.9227)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#000000"/></g><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#E0344F"/><g transform="translate(0 2)" opacity="0.2" filter="url(#fold)"><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#000000"/></g><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#EE8F9E"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="52" y="88" width="88" height="60" rx="6" fill="#000000"/></g><rect x="52" y="88" width="88" height="60" rx="6" fill="#FFFFFF"/><path d="M60 140L82 112L98 128L108 118L132 140Z" fill="#E0344F"/><circle cx="112" cy="104" r="7" fill="#EC8595"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="52" y="156" width="56" height="8" rx="4" fill="#000000"/></g><rect x="52" y="156" width="56" height="8" rx="4" fill="#F1A4B0"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="fold" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="0.96"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><g transform="scale(1.333333)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#000000"/></g><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#E0344F"/><g transform="translate(0 2)" opacity="0.2" filter="url(#fold)"><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#000000"/></g><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#EE8F9E"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="52" y="88" width="88" height="60" rx="6" fill="#000000"/></g><rect x="52" y="88" width="88" height="60" rx="6" fill="#FFFFFF"/><path d="M60 140L82 112L98 128L108 118L132 140Z" fill="#E0344F"/><circle cx="112" cy="104" r="7" fill="#EC8595"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="52" y="156" width="56" height="8" rx="4" fill="#000000"/></g><rect x="52" y="156" width="56" height="8" rx="4" fill="#F1A4B0"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><path fill="#F4F4F2" d="M231 128C231 229.764 229.764 231 128 231C26.236 231 25 229.764 25 128C25 26.236 26.236 25 128 25C229.764 25 231 26.236 231 128Z"/><g transform="translate(39.420 43.420) scale(0.9227)"><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><polyline points="96,136 64,176" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/></g><polyline points="96,136 64,176" fill="none" stroke="#5D5D5D" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><polyline points="96,136 128,176" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/></g><polyline points="96,136 128,176" fill="none" stroke="#5D5D5D" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><rect x="16" y="28" width="160" height="112" rx="10" fill="#000000"/></g><rect x="16" y="28" width="160" height="112" rx="10" fill="#F07B2A"/><rect x="16" y="28" width="160" height="16" rx="8" fill="#F4A36A"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="32" y="52" width="128" height="72" rx="4" fill="#000000"/></g><rect x="32" y="52" width="128" height="72" rx="4" fill="#FFFFFF"/><rect x="48" y="96" width="16" height="16" rx="2" fill="#F07B2A"/><rect x="72" y="80" width="16" height="32" rx="2" fill="#F07B2A"/><rect x="96" y="66" width="16" height="46" rx="2" fill="#F07B2A"/><path d="M136 66A16 16 0 1 0 152 82H136Z" fill="#5D7FC9"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><g transform="scale(1.333333)"><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><polyline points="96,136 64,176" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/></g><polyline points="96,136 64,176" fill="none" stroke="#5D5D5D" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><polyline points="96,136 128,176" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/></g><polyline points="96,136 128,176" fill="none" stroke="#5D5D5D" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><rect x="16" y="28" width="160" height="112" rx="10" fill="#000000"/></g><rect x="16" y="28" width="160" height="112" rx="10" fill="#F07B2A"/><rect x="16" y="28" width="160" height="16" rx="8" fill="#F4A36A"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><rect x="32" y="52" width="128" height="72" rx="4" fill="#000000"/></g><rect x="32" y="52" width="128" height="72" rx="4" fill="#FFFFFF"/><rect x="48" y="96" width="16" height="16" rx="2" fill="#F07B2A"/><rect x="72" y="80" width="16" height="32" rx="2" fill="#F07B2A"/><rect x="96" y="66" width="16" height="46" rx="2" fill="#F07B2A"/><path d="M136 66A16 16 0 1 0 152 82H136Z" fill="#5D7FC9"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="fold" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="0.96"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><path fill="#F4F4F2" d="M231 128C231 229.764 229.764 231 128 231C26.236 231 25 229.764 25 128C25 26.236 26.236 25 128 25C229.764 25 231 26.236 231 128Z"/><g transform="translate(39.420 43.420) scale(0.9227)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#000000"/></g><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#2E9E5B"/><g transform="translate(0 2)" opacity="0.2" filter="url(#fold)"><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#000000"/></g><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#8CCAA5"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path fill-rule="evenodd" d="M52 92H140V156H52ZM60 108H92V120H60ZM100 108H132V120H100ZM60 128H92V140H60ZM100 128H132V140H100Z" fill="#000000"/></g><path fill-rule="evenodd" d="M52 92H140V156H52ZM60 108H92V120H60ZM100 108H132V120H100ZM60 128H92V140H60ZM100 128H132V140H100Z" fill="#FFFFFF"/><rect x="100" y="128" width="32" height="12" rx="0" fill="#FFD54F"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="body" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="2.88"/></filter><filter id="fold" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="0.96"/></filter><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><g transform="scale(1.333333)"><g transform="translate(0 4)" opacity="0.2" filter="url(#body)"><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#000000"/></g><path d="M40 16H112L160 64V168A8 8 0 0 1 152 176H40A8 8 0 0 1 32 168V24A8 8 0 0 1 40 16Z" fill="#2E9E5B"/><g transform="translate(0 2)" opacity="0.2" filter="url(#fold)"><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#000000"/></g><path d="M112 16V56A8 8 0 0 0 120 64H160Z" fill="#8CCAA5"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path fill-rule="evenodd" d="M52 92H140V156H52ZM60 108H92V120H60ZM100 108H132V120H100ZM60 128H92V140H60ZM100 128H132V140H100Z" fill="#000000"/></g><path fill-rule="evenodd" d="M52 92H140V156H52ZM60 108H92V120H60ZM100 108H132V120H100ZM60 128H92V140H60ZM100 128H132V140H100Z" fill="#FFFFFF"/><rect x="100" y="128" width="32" height="12" rx="0" fill="#FFD54F"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><path fill="#F4F4F2" d="M231 128C231 229.764 229.764 231 128 231C26.236 231 25 229.764 25 128C25 26.236 26.236 25 128 25C229.764 25 231 26.236 231 128Z"/><g transform="translate(39.420 43.420) scale(0.9227)"><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><polyline points="64,44 64,148" fill="none" stroke="#000000" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/></g><polyline points="64,44 64,148" fill="none" stroke="#7E57C2" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path d="M64 128C64 92 128 104 128 72" fill="none" stroke="#000000" stroke-width="20" stroke-linecap="round"/></g><path d="M64 128C64 92 128 104 128 72" fill="none" stroke="#7E57C2" stroke-width="20" stroke-linecap="round"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><circle cx="64" cy="40" r="24" fill="#000000"/></g><circle cx="64" cy="40" r="24" fill="#7E57C2"/><circle cx="64" cy="40" r="11" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><circle cx="128" cy="64" r="24" fill="#000000"/></g><circle cx="128" cy="64" r="24" fill="#7E57C2"/><circle cx="128" cy="64" r="11" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><circle cx="64" cy="152" r="24" fill="#000000"/></g><circle cx="64" cy="152" r="24" fill="#F2A900"/><circle cx="64" cy="152" r="11" fill="#FFFFFF"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><defs><filter id="part" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="1.28"/></filter></defs><g transform="scale(1.333333)"><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><polyline points="64,44 64,148" fill="none" stroke="#000000" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/></g><polyline points="64,44 64,148" fill="none" stroke="#7E57C2" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><path d="M64 128C64 92 128 104 128 72" fill="none" stroke="#000000" stroke-width="20" stroke-linecap="round"/></g><path d="M64 128C64 92 128 104 128 72" fill="none" stroke="#7E57C2" stroke-width="20" stroke-linecap="round"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><circle cx="64" cy="40" r="24" fill="#000000"/></g><circle cx="64" cy="40" r="24" fill="#7E57C2"/><circle cx="64" cy="40" r="11" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><circle cx="128" cy="64" r="24" fill="#000000"/></g><circle cx="128" cy="64" r="24" fill="#7E57C2"/><circle cx="128" cy="64" r="11" fill="#FFFFFF"/><g transform="translate(0 2)" opacity="0.2" filter="url(#part)"><circle cx="64" cy="152" r="24" fill="#000000"/></g><circle cx="64" cy="152" r="24" fill="#F2A900"/><circle cx="64" cy="152" r="11" fill="#FFFFFF"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,306 @@
|
||||
"""Real input, window placement and screen capture for the update video."""
|
||||
import ctypes
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import tkinter
|
||||
from contextlib import contextmanager
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ImageGrab
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
|
||||
user32.SetWindowPos.argtypes = [wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
||||
ctypes.c_int, wintypes.UINT]
|
||||
ctypes.windll.winmm.timeBeginPeriod(1)
|
||||
|
||||
# The captured region, in physical pixels.
|
||||
X0, Y0, W, H = 1536, 504, 2048, 1152
|
||||
APPS = Path(os.environ["LOCALAPPDATA"]) / "Programs" / "Ely Office"
|
||||
DATA = Path(os.environ["LOCALAPPDATA"]) / "ElyOffice"
|
||||
KEEP = DATA.with_name("ElyOffice.video-keep")
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
|
||||
|
||||
class MOUSEINPUT(ctypes.Structure):
|
||||
_fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG), ("mouseData", wintypes.DWORD),
|
||||
("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t)]
|
||||
|
||||
|
||||
class KEYBDINPUT(ctypes.Structure):
|
||||
_fields_ = [("wVk", wintypes.WORD), ("wScan", wintypes.WORD), ("dwFlags", wintypes.DWORD),
|
||||
("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t)]
|
||||
|
||||
|
||||
class _U(ctypes.Union):
|
||||
_fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT), ("pad", ctypes.c_byte * 32)]
|
||||
|
||||
|
||||
class INPUT(ctypes.Structure):
|
||||
_fields_ = [("type", wintypes.DWORD), ("u", _U)]
|
||||
|
||||
|
||||
def _send(*inputs):
|
||||
arr = (INPUT * len(inputs))(*inputs)
|
||||
if user32.SendInput(len(inputs), arr, ctypes.sizeof(INPUT)) != len(inputs):
|
||||
raise OSError("SendInput was blocked")
|
||||
|
||||
|
||||
def _mouse(flags, data=0):
|
||||
i = INPUT(type=0)
|
||||
i.u.mi = MOUSEINPUT(0, 0, data, flags, 0, 0)
|
||||
return i
|
||||
|
||||
|
||||
def _key(vk=0, scan=0, flags=0):
|
||||
i = INPUT(type=1)
|
||||
i.u.ki = KEYBDINPUT(vk, scan, flags, 0, 0)
|
||||
return i
|
||||
|
||||
|
||||
VK = {"ctrl": 0x11, "shift": 0x10, "alt": 0x12, "enter": 0x0D, "esc": 0x1B, "tab": 0x09,
|
||||
"up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27, "back": 0x08, "space": 0x20,
|
||||
"home": 0x24, "end": 0x23, "delete": 0x2E, "=": 0xBB, "-": 0xBD}
|
||||
EXTENDED = {0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x2D, 0x2E}
|
||||
|
||||
|
||||
class Take:
|
||||
"""One recorded clip: the capture and its input log."""
|
||||
|
||||
def __init__(self, name, out: Path):
|
||||
self.name, self.out = name, out
|
||||
self.events = []
|
||||
self.zero = None
|
||||
self.proc = None
|
||||
|
||||
# Positions are region pixels; the log keeps them so.
|
||||
def log(self, kind, **fields):
|
||||
self.events.append({"t": round(time.perf_counter() - self.zero, 4), "kind": kind, **fields})
|
||||
|
||||
def start(self):
|
||||
path = self.out / f"{self.name}.mp4"
|
||||
grab = (f"ddagrab=output_idx=0:framerate=60:offset_x={X0}:offset_y={Y0}:"
|
||||
f"video_size={W}x{H}:draw_mouse=0,hwdownload,format=bgra")
|
||||
self.proc = subprocess.Popen(
|
||||
[FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", grab,
|
||||
"-c:v", "h264_nvenc", "-preset", "p7", "-tune", "hq", "-rc", "vbr", "-cq", "14",
|
||||
"-pix_fmt", "yuv420p", str(path)], stdin=subprocess.PIPE)
|
||||
self.zero = time.perf_counter()
|
||||
time.sleep(1.5)
|
||||
if self.proc.poll() is not None:
|
||||
raise RuntimeError("the capture stopped at once")
|
||||
# A square flashes in a corner; a frame diff finds it.
|
||||
front = user32.GetForegroundWindow()
|
||||
flash = tkinter.Tk()
|
||||
flash.overrideredirect(True)
|
||||
flash.attributes("-topmost", True)
|
||||
flash.configure(bg="#ff00ff")
|
||||
flash.geometry(f"60x60+{X0 + W - 90}+{Y0 + H - 90}")
|
||||
flash.update()
|
||||
self.log("sync", x=W - 60, y=H - 60)
|
||||
time.sleep(0.3)
|
||||
flash.destroy()
|
||||
_send(_key(0x12), _key(0x12, 0, 2))
|
||||
user32.SetForegroundWindow(front)
|
||||
time.sleep(0.5)
|
||||
if user32.GetForegroundWindow() != front:
|
||||
raise RuntimeError("the app lost the foreground to the sync flash")
|
||||
|
||||
def stop(self):
|
||||
time.sleep(0.8)
|
||||
self.proc.communicate(b"q")
|
||||
if self.proc.returncode != 0:
|
||||
raise RuntimeError(f"the capture failed: {self.proc.returncode}")
|
||||
(self.out / f"{self.name}.json").write_text(json.dumps(self.events, ensure_ascii=False, indent=1),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
TAKE: Take | None = None
|
||||
|
||||
|
||||
def _log(kind, **fields):
|
||||
if TAKE is not None and TAKE.zero is not None:
|
||||
TAKE.log(kind, **fields)
|
||||
|
||||
|
||||
def where():
|
||||
p = wintypes.POINT()
|
||||
user32.GetCursorPos(ctypes.byref(p))
|
||||
return p.x - X0, p.y - Y0
|
||||
|
||||
|
||||
def _ease(t):
|
||||
return 0.5 - 0.5 * math.cos(math.pi * t)
|
||||
|
||||
|
||||
def move_to(x, y, dur=0.6, path=None):
|
||||
"""Moves the pointer to region (x, y); `path` bends the line."""
|
||||
sx, sy = where()
|
||||
steps = max(1, int(dur * 240))
|
||||
start = time.perf_counter()
|
||||
for i in range(1, steps + 1):
|
||||
f = _ease(i / steps)
|
||||
px, py = (path(f) if path else (sx + (x - sx) * f, sy + (y - sy) * f))
|
||||
user32.SetCursorPos(int(round(X0 + px)), int(round(Y0 + py)))
|
||||
_log("move", x=round(px, 1), y=round(py, 1))
|
||||
target = start + i * dur / steps
|
||||
while time.perf_counter() < target:
|
||||
time.sleep(0.0005)
|
||||
user32.SetCursorPos(X0 + int(x), Y0 + int(y))
|
||||
|
||||
|
||||
def press(right=False):
|
||||
x, y = where()
|
||||
down, up = (0x8, 0x10) if right else (0x2, 0x4)
|
||||
_send(_mouse(down))
|
||||
_log("down", x=x, y=y, right=right)
|
||||
time.sleep(0.07)
|
||||
_send(_mouse(up))
|
||||
_log("up", x=x, y=y, right=right)
|
||||
|
||||
|
||||
def click(x, y, dur=0.6, right=False, settle=0.12):
|
||||
move_to(x, y, dur)
|
||||
time.sleep(settle)
|
||||
press(right)
|
||||
|
||||
|
||||
def wheel(notches, gap=0.09):
|
||||
"""Scrolls `notches` (negative is down), one notch at a time."""
|
||||
step = -120 if notches < 0 else 120
|
||||
for _ in range(abs(notches)):
|
||||
_send(_mouse(0x0800, step & 0xFFFFFFFF))
|
||||
x, y = where()
|
||||
_log("wheel", x=x, y=y, down=notches < 0)
|
||||
time.sleep(gap)
|
||||
|
||||
|
||||
def drag(x, y, dur=1.0):
|
||||
sx, sy = where()
|
||||
_send(_mouse(0x2))
|
||||
_log("down", x=sx, y=sy, right=False)
|
||||
time.sleep(0.15)
|
||||
move_to(x, y, dur)
|
||||
time.sleep(0.15)
|
||||
_send(_mouse(0x4))
|
||||
_log("up", x=x, y=y, right=False)
|
||||
|
||||
|
||||
def key(chord, label=None):
|
||||
codes = [VK[k] if k in VK else ord(k.upper()) for k in chord.split("+")]
|
||||
downs = [_key(c, 0, 1 if c in EXTENDED else 0) for c in codes]
|
||||
ups = [_key(c, 0, (1 if c in EXTENDED else 0) | 2) for c in reversed(codes)]
|
||||
_send(*downs)
|
||||
_log("key", key=label or chord)
|
||||
time.sleep(0.05)
|
||||
_send(*ups)
|
||||
|
||||
|
||||
def type_text(text, cps=9.0):
|
||||
"""Types `text` as Unicode characters at a human pace."""
|
||||
for ch in text:
|
||||
if ch == "\n":
|
||||
key("enter")
|
||||
else:
|
||||
code = ord(ch)
|
||||
_send(_key(0, code, 4), _key(0, code, 4 | 2))
|
||||
_log("char", ch=ch)
|
||||
time.sleep(1 / cps)
|
||||
|
||||
|
||||
def window_of(pid, timeout=60):
|
||||
found = []
|
||||
|
||||
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
def each(h, _):
|
||||
owner = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(h, ctypes.byref(owner))
|
||||
if owner.value == pid and user32.IsWindowVisible(h) and not user32.GetWindow(h, 4):
|
||||
found.append(h)
|
||||
return True
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
found.clear()
|
||||
user32.EnumWindows(each, 0)
|
||||
if found:
|
||||
return found[0]
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"process {pid} showed no window")
|
||||
|
||||
|
||||
def place(h):
|
||||
user32.ShowWindow(h, 9)
|
||||
if not user32.SetWindowPos(h, -1, X0, Y0, W, H, 0x40):
|
||||
raise ctypes.WinError()
|
||||
# The visible frame, without DWM's invisible border, fills the region.
|
||||
seen, outer = wintypes.RECT(), wintypes.RECT()
|
||||
ctypes.windll.dwmapi.DwmGetWindowAttribute(h, 9, ctypes.byref(seen), ctypes.sizeof(seen))
|
||||
user32.GetWindowRect(h, ctypes.byref(outer))
|
||||
left, top = seen.left - outer.left, seen.top - outer.top
|
||||
right, bottom = outer.right - seen.right, outer.bottom - seen.bottom
|
||||
user32.SetWindowPos(h, -1, X0 - left, Y0 - top, W + left + right, H + top + bottom, 0x40)
|
||||
# Alt lets this process take the foreground.
|
||||
_send(_key(0x12), _key(0x12, 0, 2))
|
||||
user32.SetForegroundWindow(h)
|
||||
time.sleep(0.4)
|
||||
if user32.GetForegroundWindow() != h:
|
||||
raise RuntimeError("the app is not in front; no input sent")
|
||||
|
||||
|
||||
def shot(path, scale=0.5):
|
||||
img = ImageGrab.grab(bbox=(X0, Y0, X0 + W, Y0 + H), all_screens=True)
|
||||
img.resize((int(W * scale), int(H * scale))).save(path)
|
||||
|
||||
|
||||
def launch(app, exe, *files, wait=6.0):
|
||||
env = dict(os.environ, ELY_LOCALE="zh-CN", ELY_APPEARANCE="dark", ELY_LOG="info", ELY_NO_DIALOG="1")
|
||||
proc = subprocess.Popen([str(APPS / app / exe), *map(str, files)], env=env)
|
||||
h = window_of(proc.pid)
|
||||
place(h)
|
||||
time.sleep(wait)
|
||||
place(h)
|
||||
return proc, h
|
||||
|
||||
|
||||
def quit_all():
|
||||
for exe in ("ely-doc", "ely-table", "ely-show", "ely-mark", "ely-pdf", "ely-tree"):
|
||||
subprocess.run(["taskkill", "/F", "/IM", f"{exe}.exe"], capture_output=True)
|
||||
for exe in ("ely-lok-worker", "ely-pdf-worker"):
|
||||
subprocess.run(["taskkill", "/F", "/IM", f"{exe}.exe"], capture_output=True)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fresh_data():
|
||||
"""The user's ElyOffice data waits aside; recordings start clean."""
|
||||
if KEEP.exists():
|
||||
raise RuntimeError(f"{KEEP} exists: restore it by hand first")
|
||||
quit_all()
|
||||
if DATA.exists():
|
||||
DATA.rename(KEEP)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
quit_all()
|
||||
if DATA.exists():
|
||||
shutil.rmtree(DATA)
|
||||
if KEEP.exists():
|
||||
KEEP.rename(DATA)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def take(name, out: Path):
|
||||
global TAKE
|
||||
TAKE = Take(name, out)
|
||||
TAKE.start()
|
||||
try:
|
||||
yield TAKE
|
||||
finally:
|
||||
TAKE.stop()
|
||||
TAKE = None
|
||||
@@ -0,0 +1,15 @@
|
||||
# Usage: explore.py <app> <exe> <file|-> <out.png> [python actions]
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from drive import * # noqa: F403
|
||||
|
||||
app, exe, file, out = sys.argv[1:5]
|
||||
actions = sys.argv[5] if len(sys.argv) > 5 else ""
|
||||
with fresh_data():
|
||||
files = [] if file == "-" else [Path(file).resolve()]
|
||||
proc, h = launch(app, exe, *files)
|
||||
exec(actions)
|
||||
time.sleep(0.6)
|
||||
shot(out)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Copies synced takes into public/footage with events in footage time.
|
||||
|
||||
Usage: prep.py <takes dir>
|
||||
Writes public/footage/<name>.mp4 and <name>.json: {fps, size, events, menus}.
|
||||
Event times are footage seconds; positions are footage pixels (2048×1152).
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TAKES = ["menus", "cells", "doc", "table", "show", "more"]
|
||||
# Submenu panels as recorded (x0, y0, x1, y1), for the safe triangle.
|
||||
SUBMENUS = {
|
||||
"menus": {"parent": [1100, 610, 1396, 652], "panel": [1394, 576, 1784, 1144]},
|
||||
"doc": {"parent": [1072, 702, 1370, 742], "panel": [1364, 698, 1660, 874]},
|
||||
"show": {"parent": [1182, 598, 1478, 638], "panel": [1474, 594, 1770, 774]},
|
||||
}
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
out = Path(__file__).resolve().parent.parent / "public" / "footage"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for name in TAKES:
|
||||
offset = json.loads((root / f"{name}.sync.json").read_text())["offset"]
|
||||
events = json.loads((root / f"{name}.json").read_text(encoding="utf-8"))
|
||||
for event in events:
|
||||
event["t"] = round(event["t"] + offset, 4)
|
||||
shutil.copyfile(root / f"{name}.60.mp4", out / f"{name}.mp4")
|
||||
data = {"fps": 60, "size": [2048, 1152], "events": events, "menus": SUBMENUS.get(name)}
|
||||
(out / f"{name}.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
actions = [e for e in events if e["kind"] not in ("move", "sync")]
|
||||
print(name, " ".join(f"{e['kind']}@{e['t']:.2f}" for e in actions))
|
||||
@@ -0,0 +1,100 @@
|
||||
# Writes the sample files the recordings open. Usage: samples.py <dir>
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import docx
|
||||
import openpyxl
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Pt
|
||||
from openpyxl.styles import Font
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt as PPt
|
||||
|
||||
out = Path(sys.argv[1])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ROWS = [
|
||||
("模块", "负责人", "状态", "进度"),
|
||||
("编辑器", "林夏", "已完成", "100%"),
|
||||
("表格", "周沐", "进行中", "72%"),
|
||||
("演示", "陈一", "进行中", "58%"),
|
||||
("发布", "许言", "未开始", "0%"),
|
||||
]
|
||||
|
||||
mark = """# 0.1.5 发布计划
|
||||
|
||||
这一版的重点是**更顺手**:菜单、表格与右键都照常用的软件来。
|
||||
|
||||
| 模块 | 负责人 | 状态 | 进度 |
|
||||
| --- | --- | --- | --- |
|
||||
| 编辑器 | 林夏 | **已完成** | 100% |
|
||||
| 表格 | 周沐 | 进行中 | 72% |
|
||||
| 演示 | 陈一 | 进行中 | 58% |
|
||||
| 发布 | 许言 | 未开始 | 0% |
|
||||
|
||||
> [!TIP]
|
||||
> 点任意一格即可在格内编辑,Tab 到下一格。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 收集反馈 :rocket:
|
||||
- 整理文档
|
||||
"""
|
||||
(out / "发布计划.md").write_text(mark, encoding="utf-8")
|
||||
(out / "会议记录.md").write_text("# 会议记录\n\n- 确认发布时间\n", encoding="utf-8")
|
||||
(out / "读书笔记.md").write_text("# 读书笔记\n\n摘录与想法。\n", encoding="utf-8")
|
||||
|
||||
d = docx.Document()
|
||||
d.styles["Normal"].font.size = Pt(12)
|
||||
# One installed face for every script: no missing-font banner.
|
||||
FACE = "微软雅黑"
|
||||
defaults = d.styles.element.find(qn("w:docDefaults"))
|
||||
for fonts in [*defaults.iter(qn("w:rFonts")), *(style.element.get_or_add_rPr().get_or_add_rFonts()
|
||||
for style in d.styles if style.type == 1)]:
|
||||
for name in list(fonts.attrib):
|
||||
if name.endswith("Theme"):
|
||||
del fonts.attrib[name]
|
||||
for attr in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"):
|
||||
fonts.set(qn(attr), FACE)
|
||||
d.add_heading("第三季度工作汇报", 1)
|
||||
d.add_paragraph("本季度完成了编辑器与表格两大模块,演示模块进入收尾阶段。下表列出各模块的负责人与进度。")
|
||||
t = d.add_table(rows=len(ROWS), cols=4)
|
||||
t.style = "Table Grid"
|
||||
for r, row in enumerate(ROWS):
|
||||
for c, value in enumerate(row):
|
||||
t.cell(r, c).text = value
|
||||
d.add_paragraph("下季度计划:完善导出与打印,补齐快捷键。")
|
||||
d.save(out / "工作汇报.docx")
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "销售"
|
||||
ws.append(["地区", "一月", "二月", "三月", "合计"])
|
||||
data = [("华东", 128, 142, 160), ("华南", 96, 104, 121), ("华北", 88, 97, 110), ("西南", 64, 71, 83)]
|
||||
for i, (name, *months) in enumerate(data, start=2):
|
||||
ws.append([name, *months, f"=SUM(B{i}:D{i})"])
|
||||
ws.append(["合计", "=SUM(B2:B5)", "=SUM(C2:C5)", "=SUM(D2:D5)", "=SUM(E2:E5)"])
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True)
|
||||
for col in "ABCDE":
|
||||
ws.column_dimensions[col].width = 12
|
||||
wb.save(out / "季度销售.xlsx")
|
||||
|
||||
p = Presentation()
|
||||
p.slide_width, p.slide_height = Inches(13.333), Inches(7.5)
|
||||
s = p.slides.add_slide(p.slide_layouts[0])
|
||||
s.shapes.title.text = "Ely Office 0.1.5"
|
||||
s.placeholders[1].text = "更顺手的一次更新"
|
||||
s = p.slides.add_slide(p.slide_layouts[5])
|
||||
s.shapes.title.text = "模块进度"
|
||||
table = s.shapes.add_table(len(ROWS), 4, Inches(1.2), Inches(2), Inches(10.9), Inches(3.6)).table
|
||||
for r, row in enumerate(ROWS):
|
||||
for c, value in enumerate(row):
|
||||
table.cell(r, c).text = value
|
||||
table.cell(r, c).text_frame.paragraphs[0].runs[0].font.size = PPt(20)
|
||||
for title in ("用户反馈", "编辑器", "表格", "演示", "文档", "快捷键", "右键菜单", "跨平台", "发布节奏", "下一步"):
|
||||
s = p.slides.add_slide(p.slide_layouts[1])
|
||||
s.shapes.title.text = title
|
||||
s.placeholders[1].text = "要点一\n要点二"
|
||||
p.save(out / "季度汇报.pptx")
|
||||
print("samples in", out)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Finds each take's sync jump; writes footage time = log time + offset.
|
||||
|
||||
Usage: sync.py <takes dir> <name> ...
|
||||
Converts each take to constant 60 fps (`<name>.60.mp4`) and writes
|
||||
`<name>.sync.json` with the offset and the source start time.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
|
||||
def first_change(video: Path, x: int, y: int, after: float, until: float):
|
||||
"""The first frame time when the patch at (x, y) changes."""
|
||||
with av.open(str(video)) as box:
|
||||
stream = box.streams.video[0]
|
||||
start = float(stream.start_time * stream.time_base) if stream.start_time else 0.0
|
||||
base = None
|
||||
for frame in box.decode(stream):
|
||||
t = float(frame.pts * stream.time_base) - start
|
||||
if t < after:
|
||||
continue
|
||||
if t > until:
|
||||
break
|
||||
patch = frame.to_ndarray(format="gray")[y - 24:y + 24, x - 24:x + 24].astype(np.int16)
|
||||
if base is None:
|
||||
base = patch
|
||||
continue
|
||||
if np.abs(patch - base).mean() > 6:
|
||||
return t, start
|
||||
raise RuntimeError(f"{video.name}: no sync jump between {after} and {until} s")
|
||||
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
for name in sys.argv[2:]:
|
||||
events = json.loads((root / f"{name}.json").read_text(encoding="utf-8"))
|
||||
sync = next(e for e in events if e["kind"] == "sync")
|
||||
seen, start = first_change(root / f"{name}.mp4", sync["x"], sync["y"], sync["t"] - 0.5, sync["t"] + 1.5)
|
||||
offset = round(seen - sync["t"], 4)
|
||||
(root / f"{name}.sync.json").write_text(json.dumps({"offset": offset, "start": start}), encoding="utf-8")
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(root / f"{name}.mp4"),
|
||||
"-vf", "fps=60", "-c:v", "h264_nvenc", "-preset", "p7", "-rc", "vbr", "-cq", "14",
|
||||
"-g", "30", "-pix_fmt", "yuv420p", str(root / f"{name}.60.mp4")], check=True)
|
||||
print(f"{name}: jump seen at {seen:.3f} s, logged at {sync['t']:.3f} s, offset {offset:+.3f} s")
|
||||
@@ -0,0 +1,156 @@
|
||||
"""The recorded takes. Usage: takes.py <out dir> <samples dir> [take ...]"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from drive import * # noqa: F403
|
||||
from drive import _log
|
||||
|
||||
|
||||
def menus(files):
|
||||
"""ElyMark: right-click › 表格 ▸, a diagonal over 插入 into the submenu."""
|
||||
proc, h = launch("ElyMark", "ely-mark.exe", files / "发布计划.md")
|
||||
with take("menus", OUT):
|
||||
move_to(900, 520, 0.8)
|
||||
time.sleep(0.4)
|
||||
click(1100, 394, 0.8, right=True)
|
||||
time.sleep(0.9)
|
||||
move_to(1200, 632, 0.7)
|
||||
time.sleep(1.1)
|
||||
# Across 插入, which has its own submenu, to 下移该行.
|
||||
move_to(1490, 824, 0.95)
|
||||
time.sleep(0.8)
|
||||
press()
|
||||
time.sleep(1.6)
|
||||
move_to(1000, 900, 0.9)
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def cells(files):
|
||||
"""ElyMark: a cell opens alone, shows its source, Tab and arrows move."""
|
||||
proc, h = launch("ElyMark", "ely-mark.exe", files / "发布计划.md")
|
||||
with take("cells", OUT):
|
||||
move_to(1000, 480, 0.8)
|
||||
time.sleep(0.3)
|
||||
click(1236, 344, 0.8)
|
||||
time.sleep(1.4)
|
||||
key("tab", "Tab")
|
||||
time.sleep(1.1)
|
||||
key("down", "↓")
|
||||
time.sleep(1.0)
|
||||
key("end", "End")
|
||||
time.sleep(0.3)
|
||||
for _ in range(3):
|
||||
key("back", "⌫")
|
||||
time.sleep(0.16)
|
||||
time.sleep(0.3)
|
||||
type_text("85%", cps=5)
|
||||
time.sleep(0.8)
|
||||
key("enter", "Enter")
|
||||
time.sleep(1.4)
|
||||
move_to(1100, 760, 0.8)
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def doc(files):
|
||||
"""ElyDoc: Word-style table menu, insert a row, drag a column border."""
|
||||
proc, h = launch("ElyDoc", "ely-doc.exe", files / "工作汇报.docx", wait=8)
|
||||
with take("doc", OUT):
|
||||
move_to(1000, 760, 0.8)
|
||||
time.sleep(0.3)
|
||||
click(1070, 634, 0.7)
|
||||
time.sleep(0.4)
|
||||
click(1070, 634, 0.2, right=True)
|
||||
time.sleep(0.9)
|
||||
move_to(1140, 722, 0.6)
|
||||
time.sleep(0.9)
|
||||
# Across 删除, which has its own submenu.
|
||||
move_to(1468, 848, 0.9)
|
||||
time.sleep(0.7)
|
||||
press()
|
||||
time.sleep(1.4)
|
||||
# The border between 负责人 and 状态.
|
||||
move_to(962, 560, 0.8)
|
||||
time.sleep(0.7)
|
||||
drag(1040, 560, 1.1)
|
||||
time.sleep(1.4)
|
||||
|
||||
|
||||
def table(files):
|
||||
"""ElyTable: Excel-style column header menu, insert a column."""
|
||||
proc, h = launch("ElyTable", "ely-table.exe", files / "季度销售.xlsx", wait=8)
|
||||
with take("table", OUT):
|
||||
move_to(600, 500, 0.8)
|
||||
time.sleep(0.3)
|
||||
click(406, 208, 0.8, right=True)
|
||||
time.sleep(1.1)
|
||||
move_to(510, 294, 0.6)
|
||||
time.sleep(0.6)
|
||||
press()
|
||||
time.sleep(1.6)
|
||||
move_to(700, 520, 0.8)
|
||||
time.sleep(0.6)
|
||||
|
||||
|
||||
def show(files):
|
||||
"""ElyShow: the slide strip scrolls; PowerPoint-style table menu."""
|
||||
proc, h = launch("ElyShow", "ely-show.exe", files / "季度汇报.pptx", wait=8)
|
||||
with take("show", OUT):
|
||||
move_to(180, 640, 0.9)
|
||||
time.sleep(0.3)
|
||||
wheel(-9, 0.11)
|
||||
time.sleep(0.6)
|
||||
wheel(9, 0.11)
|
||||
time.sleep(0.5)
|
||||
click(180, 380, 0.6)
|
||||
time.sleep(1.4)
|
||||
click(1180, 530, 0.8)
|
||||
time.sleep(0.4)
|
||||
click(1180, 530, 0.2, right=True)
|
||||
time.sleep(0.9)
|
||||
move_to(1250, 618, 0.6)
|
||||
time.sleep(0.8)
|
||||
move_to(1578, 662, 0.8)
|
||||
time.sleep(0.6)
|
||||
press()
|
||||
time.sleep(1.6)
|
||||
|
||||
|
||||
def more(files):
|
||||
"""ElyMark: a second launch hands its file over; the recent list clears."""
|
||||
proc, h = launch("ElyMark", "ely-mark.exe", files / "发布计划.md", files / "读书笔记.md")
|
||||
with take("more", OUT):
|
||||
move_to(900, 600, 0.8)
|
||||
time.sleep(0.6)
|
||||
env = dict(os.environ, ELY_LOCALE="zh-CN", ELY_APPEARANCE="dark")
|
||||
subprocess.Popen([str(APPS / "ElyMark" / "ely-mark.exe"), str(files / "会议记录.md")], env=env)
|
||||
_log("launch", file="会议记录.md")
|
||||
time.sleep(2.6)
|
||||
click(166, 1082, 1.0)
|
||||
time.sleep(1.8)
|
||||
move_to(700, 700, 0.8)
|
||||
time.sleep(0.6)
|
||||
|
||||
|
||||
OUT = Path(sys.argv[1])
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
SAMPLES = Path(sys.argv[2])
|
||||
ALL = {"menus": menus, "cells": cells, "doc": doc, "table": table, "show": show, "more": more}
|
||||
names = sys.argv[3:] or list(ALL)
|
||||
with fresh_data():
|
||||
for name in names:
|
||||
# Each take opens untouched copies under the same names.
|
||||
with tempfile.TemporaryDirectory(prefix="ely-video-") as tmp:
|
||||
files = Path(tmp) / "样例"
|
||||
shutil.copytree(SAMPLES, files)
|
||||
try:
|
||||
ALL[name](files)
|
||||
finally:
|
||||
quit_all()
|
||||
# The next take starts without this one's session.
|
||||
if DATA.exists():
|
||||
shutil.rmtree(DATA)
|
||||
print("recorded", name, flush=True)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Composition } from 'remotion';
|
||||
import { FPS, HEIGHT, TOTAL, WIDTH } from './theme';
|
||||
import { Video } from './Video';
|
||||
|
||||
export const Root: React.FC = () => (
|
||||
<Composition id="Update" component={Video} durationInFrames={TOTAL} fps={FPS} width={WIDTH} height={HEIGHT} />
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AbsoluteFill, Audio, Sequence, getStaticFiles, staticFile } from 'remotion';
|
||||
import './design/fonts';
|
||||
import { Background } from './design/Background';
|
||||
import { Intro } from './scenes/Intro';
|
||||
import { Outro } from './scenes/Outro';
|
||||
import { Menus } from './scenes/Menus';
|
||||
import { Cells } from './scenes/Cells';
|
||||
import { RightClick } from './scenes/RightClick';
|
||||
import { More } from './scenes/More';
|
||||
import { SECTION } from './theme';
|
||||
|
||||
// Set false to render without the score.
|
||||
const MUSIC = true;
|
||||
const MUSIC_FILE = 'audio/music.wav';
|
||||
const hasMusic = () => getStaticFiles().some((file) => file.name.split('\\').join('/') === MUSIC_FILE);
|
||||
|
||||
// Whooshes at each morph, impact on the outro downbeat.
|
||||
const MORPHS = [300, 840, 1440, 2340, 3090];
|
||||
|
||||
const at = (section: { from: number; duration: number }) => ({ from: section.from, durationInFrames: section.duration });
|
||||
|
||||
export const Video: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
<Sequence {...at(SECTION.intro)} name="Intro">
|
||||
<Intro />
|
||||
</Sequence>
|
||||
<Sequence {...at(SECTION.menus)} name="Menus">
|
||||
<Menus />
|
||||
</Sequence>
|
||||
<Sequence {...at(SECTION.cells)} name="Cells">
|
||||
<Cells />
|
||||
</Sequence>
|
||||
<Sequence {...at(SECTION.rightClick)} name="RightClick">
|
||||
<RightClick />
|
||||
</Sequence>
|
||||
<Sequence {...at(SECTION.more)} name="More">
|
||||
<More />
|
||||
</Sequence>
|
||||
<Sequence {...at(SECTION.outro)} name="Outro">
|
||||
<Outro />
|
||||
</Sequence>
|
||||
{MORPHS.map((frame) => (
|
||||
<Sequence key={frame} from={frame} durationInFrames={45} name="whoosh">
|
||||
<Audio src={staticFile('audio/sfx/whoosh.wav')} volume={0.35} />
|
||||
</Sequence>
|
||||
))}
|
||||
<Sequence from={3150} durationInFrames={90} name="impact">
|
||||
<Audio src={staticFile('audio/sfx/impact.wav')} volume={0.4} />
|
||||
</Sequence>
|
||||
{MUSIC && hasMusic() ? <Audio src={staticFile(MUSIC_FILE)} /> : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AbsoluteFill, useCurrentFrame } from 'remotion';
|
||||
import { COLOR, HEIGHT, WIDTH } from '../theme';
|
||||
|
||||
// Near-black field with a faint lift at the centre and fine film grain. The grain
|
||||
// changes every other frame (30 Hz) from a seed fixed by the frame number.
|
||||
export const Background: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const seed = Math.floor(frame / 2) % 997;
|
||||
return (
|
||||
<AbsoluteFill style={{ background: COLOR.bgOuter }}>
|
||||
<AbsoluteFill
|
||||
style={{ background: `radial-gradient(ellipse 72% 66% at 50% 47%, ${COLOR.bgInner} 0%, ${COLOR.bgOuter} 100%)` }}
|
||||
/>
|
||||
<AbsoluteFill style={{ background: 'radial-gradient(ellipse 90% 90% at 50% 50%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.28) 100%)' }} />
|
||||
<svg width={WIDTH} height={HEIGHT} style={{ position: 'absolute', inset: 0, opacity: 0.055, mixBlendMode: 'screen' }}>
|
||||
<filter id="grain" x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={seed} stitchTiles="stitch" />
|
||||
<feColorMatrix type="saturate" values="0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#grain)" />
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { interpolate, useCurrentFrame } from 'remotion';
|
||||
import { COLOR, EASE, FONT, STAGE } from '../theme';
|
||||
|
||||
export type CaptionProps = {
|
||||
text: string;
|
||||
// Section-local frames: starts entering at `from`, gone by `to`.
|
||||
from: number;
|
||||
to: number;
|
||||
// Centre of the pill in composition pixels (default: bottom centre of the stage).
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
|
||||
// A glass pill with one line of explanation, over the stage.
|
||||
export const Caption: React.FC<CaptionProps> = ({ text, from, to, x = STAGE.x + STAGE.w / 2, y = STAGE.y + STAGE.h - 64 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
if (frame < from || frame >= to) return null;
|
||||
const p = interpolate(frame, [from, from + 22], [0, 1], { ...clamp, easing: EASE.out });
|
||||
const q = interpolate(frame, [to - 16, to], [0, 1], { ...clamp, easing: EASE.in });
|
||||
const blur = (1 - p) * 6 + q * 5;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
transform: `translate(-50%, -50%) translateY(${((1 - p) * 14 - q * 8).toFixed(2)}px) scale(${(0.98 + 0.02 * p).toFixed(4)})`,
|
||||
opacity: p * (1 - q),
|
||||
filter: blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : undefined,
|
||||
padding: '13px 26px 14px',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(22,22,24,0.66)',
|
||||
backdropFilter: 'blur(18px) saturate(1.2)',
|
||||
border: '1px solid rgba(255,255,255,0.09)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.42)',
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 500,
|
||||
fontSize: 26,
|
||||
lineHeight: '34px',
|
||||
letterSpacing: '0.03em',
|
||||
color: COLOR.ink,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Img, interpolate, staticFile, useCurrentFrame } from 'remotion';
|
||||
import { mix } from './Shape';
|
||||
import { type AppId, COLOR, EASE, FONT, HEADING, STAGE, appOf } from '../theme';
|
||||
|
||||
export type HeadingSpec = {
|
||||
title: string;
|
||||
sub?: string;
|
||||
// App chip at the right edge, above the stage.
|
||||
app?: AppId;
|
||||
// Section-local frame the title starts to enter (default: beat 1).
|
||||
at?: number;
|
||||
// Section-local frame it starts to leave (default: the next heading's
|
||||
// entrance, or HEADING.out frames before the section ends).
|
||||
until?: number;
|
||||
};
|
||||
|
||||
const ENTER = 28;
|
||||
const LEAVE = 22;
|
||||
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
|
||||
// Per-character rise, unblur and fade; leaving drifts on up and dissolves.
|
||||
// The first `keepIn` characters are already on screen (shared with the previous
|
||||
// heading); the first `keepOut` stay for the next one until `handoff`.
|
||||
const Kinetic: React.FC<{
|
||||
text: string;
|
||||
local: number;
|
||||
leave: number;
|
||||
stagger: number;
|
||||
rise: number;
|
||||
keepIn?: number;
|
||||
keepOut?: number;
|
||||
handoff?: number;
|
||||
}> = ({ text, local, leave, stagger, rise, keepIn = 0, keepOut = 0, handoff = Infinity }) => {
|
||||
const chars = Array.from(text);
|
||||
return (
|
||||
<>
|
||||
{chars.map((char, i) => {
|
||||
const kept = i < keepOut;
|
||||
if (kept && local >= handoff) return <span key={i} style={{ display: 'inline-block', whiteSpace: 'pre', opacity: 0 }}>{char}</span>;
|
||||
const order = i - keepIn;
|
||||
const p = i < keepIn ? 1 : interpolate(local - order * stagger, [0, ENTER], [0, 1], { ...clamp, easing: EASE.out });
|
||||
const q = kept ? 0 : interpolate(local - leave - (i - keepOut) * stagger * 0.55, [0, LEAVE], [0, 1], { ...clamp, easing: EASE.in });
|
||||
const y = (1 - p) * rise - q * rise * 0.6;
|
||||
const blur = (1 - p) * 9 + q * 7;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
whiteSpace: 'pre',
|
||||
opacity: p * (1 - q),
|
||||
transform: `translateY(${y.toFixed(2)}px)`,
|
||||
filter: blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// One section title above the stage's left edge, with an optional app chip.
|
||||
export type HeadingTiming = {
|
||||
spec: HeadingSpec;
|
||||
enter: number;
|
||||
leave: number;
|
||||
// Characters shared with the previous / next heading of the section.
|
||||
keepIn: number;
|
||||
keepOut: number;
|
||||
// Section-local frame the next heading takes over (null for the last).
|
||||
handoff: number | null;
|
||||
// Rule colour of the previous heading, which this one eases from.
|
||||
fromColor: string | null;
|
||||
};
|
||||
|
||||
export const Heading: React.FC<{ timing: HeadingTiming; color: string }> = ({ timing, color }) => {
|
||||
const { spec, enter, leave, keepIn, keepOut, handoff, fromColor } = timing;
|
||||
const frame = useCurrentFrame();
|
||||
const local = frame - enter;
|
||||
const until = handoff ?? leave + LEAVE + 40;
|
||||
if (local < 0 || frame >= until) return null;
|
||||
const stay = leave - enter;
|
||||
const subAt = HEADING.sub;
|
||||
const rule = fromColor === null ? interpolate(local, [-6, 22], [0, 1], { ...clamp, easing: EASE.out }) : 1;
|
||||
const ruleOut = handoff === null ? interpolate(local - stay - 8, [0, 18], [0, 1], { ...clamp, easing: EASE.inOut }) : 0;
|
||||
const ruleColor = fromColor === null ? color : mix(fromColor, color, interpolate(local, [0, 24], [0, 1], { ...clamp, easing: EASE.inOut }));
|
||||
const chip = interpolate(local - 10, [0, 30], [0, 1], { ...clamp, easing: EASE.out });
|
||||
const chipOut = interpolate(local - stay, [0, LEAVE], [0, 1], { ...clamp, easing: EASE.in });
|
||||
const app = spec.app ? appOf(spec.app) : null;
|
||||
return (
|
||||
<div style={{ position: 'absolute', left: STAGE.x, top: 28, width: STAGE.w, height: 76 }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 6,
|
||||
width: 4,
|
||||
height: 64,
|
||||
borderRadius: 2,
|
||||
background: ruleColor,
|
||||
transformOrigin: '50% 0%',
|
||||
transform: `scaleY(${(rule * (1 - ruleOut)).toFixed(4)})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 22,
|
||||
top: 0,
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 600,
|
||||
fontSize: 42,
|
||||
lineHeight: '48px',
|
||||
letterSpacing: '0.02em',
|
||||
color: COLOR.ink,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Kinetic
|
||||
text={spec.title}
|
||||
local={local}
|
||||
leave={stay}
|
||||
stagger={1.8}
|
||||
rise={24}
|
||||
keepIn={keepIn}
|
||||
keepOut={keepOut}
|
||||
handoff={handoff === null ? Infinity : handoff - enter}
|
||||
/>
|
||||
</div>
|
||||
{spec.sub ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 23,
|
||||
top: 52,
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 400,
|
||||
fontSize: 19,
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '0.04em',
|
||||
color: COLOR.muted,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Kinetic text={spec.sub} local={local - subAt} leave={stay - subAt - 6} stagger={0.55} rise={12} />
|
||||
</div>
|
||||
) : null}
|
||||
{app ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 18,
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
opacity: chip * (1 - chipOut),
|
||||
transform: `translateX(${((1 - chip) * 18 - chipOut * 10).toFixed(2)}px)`,
|
||||
filter: chip < 1 || chipOut > 0 ? `blur(${((1 - chip) * 6 + chipOut * 5).toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile(`icons/${app.id}.svg`)} style={{ width: 40, height: 40 }} />
|
||||
<span style={{ fontFamily: FONT.latin, fontWeight: 500, fontSize: 21, letterSpacing: '0.01em', color: COLOR.muted }}>
|
||||
{app.name}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const shared = (a: string, b: string) => {
|
||||
const x = Array.from(a);
|
||||
const y = Array.from(b);
|
||||
let n = 0;
|
||||
while (n < x.length && n < y.length && x[n] === y[n]) n++;
|
||||
// Keep whole words only: stop after the last space of the shared run.
|
||||
const cut = x.slice(0, n).lastIndexOf(' ');
|
||||
return n === x.length || n === y.length ? n : cut + 1;
|
||||
};
|
||||
|
||||
// Resolve each heading's frames within a section; `accent` colours the rule.
|
||||
export const headingTimes = (specs: HeadingSpec[], duration: number, accent: (spec: HeadingSpec) => string): HeadingTiming[] =>
|
||||
specs.map((spec, i) => {
|
||||
const enter = spec.at ?? HEADING.in;
|
||||
const prev = specs[i - 1];
|
||||
const next = specs[i + 1];
|
||||
const nextAt = next ? (next.at ?? HEADING.in) : null;
|
||||
const leave = spec.until ?? (nextAt !== null ? nextAt - LEAVE - 6 : duration - HEADING.out);
|
||||
return {
|
||||
spec,
|
||||
enter,
|
||||
leave,
|
||||
keepIn: prev ? shared(prev.title, spec.title) : 0,
|
||||
keepOut: next ? shared(spec.title, next.title) : 0,
|
||||
handoff: nextAt,
|
||||
fromColor: prev ? accent(prev) : null,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Fragment } from 'react';
|
||||
import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
import { COLOR, EASE, FONT, SPRING, STAGE } from '../theme';
|
||||
|
||||
export type KeyCapProps = {
|
||||
// Keys of one chord, e.g. ['Ctrl', '1'], ['Tab'], ['右键'].
|
||||
keys: string[];
|
||||
// Section-local frames: pops in at `from`, gone by `to`.
|
||||
from: number;
|
||||
to: number;
|
||||
// Section-local frames at which the chord is pressed.
|
||||
press?: number[];
|
||||
// Centre in composition pixels; default: bottom-right corner of the stage.
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
const MOUSE: Record<string, 'left' | 'right' | 'wheel'> = { 右键: 'right', 左键: 'left', 点击: 'left', 滚轮: 'wheel' };
|
||||
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
|
||||
const Mouse: React.FC<{ part: 'left' | 'right' | 'wheel'; lit: number }> = ({ part, lit }) => {
|
||||
const on = `rgba(61,127,229,${(0.55 + 0.45 * lit).toFixed(3)})`;
|
||||
return (
|
||||
<svg width="18" height="26" viewBox="0 0 18 26">
|
||||
<rect x="1" y="1" width="16" height="24" rx="8" fill="none" stroke={COLOR.ink} strokeOpacity="0.85" strokeWidth="1.6" />
|
||||
{part === 'left' ? <path d="M9 1.8A7.2 7.2 0 0 0 1.8 9V11H9Z" fill={on} /> : null}
|
||||
{part === 'right' ? <path d="M9 1.8A7.2 7.2 0 0 1 16.2 9V11H9Z" fill={on} /> : null}
|
||||
<path d="M9 1.8V11M1.8 11H16.2" stroke={COLOR.ink} strokeOpacity="0.5" strokeWidth="1.2" />
|
||||
<rect x="7.4" y="4.6" width="3.2" height="5" rx="1.6" fill={part === 'wheel' ? on : COLOR.ink} fillOpacity={part === 'wheel' ? 1 : 0.6} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// A keyboard chord or mouse button that pops in, presses on cue, and leaves.
|
||||
export const KeyCap: React.FC<KeyCapProps> = ({ keys, from, to, press = [], x, y }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
if (frame < from || frame >= to) return null;
|
||||
const pop = spring({ frame: frame - from, fps, config: SPRING, durationInFrames: 24 });
|
||||
const out = interpolate(frame, [to - 14, to], [0, 1], { ...clamp, easing: EASE.in });
|
||||
// Down in 4 frames, back up over 12.
|
||||
const down = press.reduce((acc, at) => {
|
||||
const t = frame - at;
|
||||
const v = t < 0 ? 0 : t < 4 ? EASE.out(t / 4) : 1 - EASE.inOut(Math.min(1, (t - 4) / 12));
|
||||
return Math.max(acc, v);
|
||||
}, 0);
|
||||
const anchored = x === undefined || y === undefined;
|
||||
const blur = (1 - pop) * 5 + out * 4;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x ?? STAGE.x + STAGE.w - 36,
|
||||
top: y ?? STAGE.y + STAGE.h - 36,
|
||||
transform: `translate(${anchored ? '-100%, -100%' : '-50%, -50%'}) translateY(${((1 - pop) * 12 + out * 6).toFixed(2)}px) scale(${(0.88 + 0.12 * pop - 0.04 * out).toFixed(4)})`,
|
||||
transformOrigin: anchored ? '100% 100%' : '50% 50%',
|
||||
opacity: Math.min(1, pop * 1.4) * (1 - out),
|
||||
filter: blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{keys.map((key, i) => {
|
||||
const mouse = MOUSE[key];
|
||||
return (
|
||||
<Fragment key={`${key}-${i}`}>
|
||||
{i > 0 ? (
|
||||
<span style={{ fontFamily: FONT.latin, fontSize: 22, fontWeight: 400, color: COLOR.faint }}>+</span>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
height: 58,
|
||||
minWidth: 58,
|
||||
padding: '0 18px',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: 13,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
transform: `translateY(${(down * 3).toFixed(2)}px)`,
|
||||
background: 'linear-gradient(180deg, #3b3b3e 0%, #2d2d30 100%)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
boxShadow: [
|
||||
`0 ${(4 - down * 3).toFixed(2)}px 0 #161618`,
|
||||
`0 ${(12 - down * 6).toFixed(2)}px 28px rgba(0,0,0,0.5)`,
|
||||
'inset 0 1px 0 rgba(255,255,255,0.10)',
|
||||
`0 0 0 ${(down * 2).toFixed(2)}px rgba(61,127,229,${(down * 0.7).toFixed(3)})`,
|
||||
].join(', '),
|
||||
fontFamily: FONT.latin,
|
||||
fontWeight: 600,
|
||||
fontSize: 23,
|
||||
letterSpacing: '0.01em',
|
||||
color: COLOR.ink,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{mouse ? <Mouse part={mouse} lit={down} /> : null}
|
||||
<span style={{ fontFamily: mouse ? FONT.sans : FONT.latin, fontWeight: mouse ? 500 : 600 }}>{key}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AbsoluteFill, Img, staticFile } from 'remotion';
|
||||
import { ICON, STAGE, STAGE_CENTER } from '../theme';
|
||||
import type { ShapeId } from './morph';
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
export const rgba = (hex: string, alpha: number) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha.toFixed(4)})`;
|
||||
};
|
||||
|
||||
export const mix = (a: string, b: string, t: number) => {
|
||||
const x = parseInt(a.slice(1), 16);
|
||||
const y = parseInt(b.slice(1), 16);
|
||||
const c = (shift: number) => Math.round(lerp((x >> shift) & 255, (y >> shift) & 255, t));
|
||||
return `#${((c(16) << 16) | (c(8) << 8) | c(0)).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
// The highlighted table cell that the cell-editing section grows out of.
|
||||
const CellArt: React.FC = () => (
|
||||
<svg viewBox="0 0 192 192" width="100%" height="100%">
|
||||
<rect x="16" y="51" width="160" height="90" rx="8" fill="#3E4A5C" />
|
||||
<path d="M24 51H168A8 8 0 0 1 176 59V69H16V59A8 8 0 0 1 24 51Z" fill="#4E5B70" />
|
||||
<g stroke="#FFFFFF" strokeOpacity="0.16" strokeWidth="1.2">
|
||||
<path d="M69.3 51V141M122.7 51V141M16 69H176M16 93H176M16 117H176" />
|
||||
</g>
|
||||
<g fill="#FFFFFF" fillOpacity="0.55">
|
||||
<rect x="24" y="57" width="26" height="6" rx="3" />
|
||||
<rect x="77" y="57" width="30" height="6" rx="3" />
|
||||
<rect x="130" y="57" width="22" height="6" rx="3" />
|
||||
<rect x="24" y="78" width="32" height="6" rx="3" />
|
||||
<rect x="130" y="78" width="28" height="6" rx="3" />
|
||||
<rect x="24" y="102" width="22" height="6" rx="3" />
|
||||
<rect x="130" y="102" width="34" height="6" rx="3" />
|
||||
<rect x="24" y="126" width="30" height="6" rx="3" />
|
||||
<rect x="77" y="126" width="24" height="6" rx="3" />
|
||||
<rect x="130" y="126" width="26" height="6" rx="3" />
|
||||
</g>
|
||||
<rect x="77" y="78" width="26" height="6" rx="3" fill="#FFFFFF" fillOpacity="0.55" />
|
||||
<rect x="68.5" y="92.5" width="55" height="25" rx="2.5" fill="#F2A900" />
|
||||
<rect x="76" y="102" width="28" height="6" rx="3" fill="#FFFFFF" />
|
||||
<rect x="107" y="99" width="2.4" height="12" rx="1.2" fill="#FFFFFF" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type Details = { shape: ShapeId; opacity: number };
|
||||
|
||||
const Artwork: React.FC<{ details: Details; size: number; cx: number; cy: number }> = ({ details, size, cx, cy }) => {
|
||||
if (details.shape === 'dot' || details.opacity <= 0.001) return null;
|
||||
const style: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: cx - size / 2,
|
||||
top: cy - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: details.opacity,
|
||||
};
|
||||
if (details.shape === 'cell') {
|
||||
return (
|
||||
<div style={style}>
|
||||
<CellArt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Img src={staticFile(`icons/${details.shape}.svg`)} style={style} />;
|
||||
};
|
||||
|
||||
export type ShapeViewProps = {
|
||||
d: string;
|
||||
color: string;
|
||||
// Ambient glow colour (default: `color`).
|
||||
glowColor?: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
k: number;
|
||||
// App-colour wash over the content.
|
||||
fill: number;
|
||||
lift: number;
|
||||
border?: number;
|
||||
sweep?: number | null;
|
||||
details?: Details[];
|
||||
// Multiplier on shadow and glow, for shapes stacked on one another.
|
||||
presence?: number;
|
||||
content?: {
|
||||
node: ReactNode;
|
||||
opacity: number;
|
||||
scale: number;
|
||||
blur: number;
|
||||
background: string;
|
||||
};
|
||||
};
|
||||
|
||||
// One morphing shape: glow, shadow, clipped content, colour wash, artwork, edge.
|
||||
export const ShapeView: React.FC<ShapeViewProps> = ({
|
||||
d,
|
||||
color,
|
||||
glowColor = color,
|
||||
cx,
|
||||
cy,
|
||||
k,
|
||||
fill,
|
||||
lift,
|
||||
border = 0,
|
||||
sweep = null,
|
||||
details = [],
|
||||
presence = 1,
|
||||
content,
|
||||
}) => {
|
||||
const glowW = 400 * k;
|
||||
const glowH = glowW * lerp(1, 0.62, lift);
|
||||
const glowAlpha = lerp(0.3, 0.09, lift) * presence;
|
||||
const shadow = `drop-shadow(0 ${lerp(6, 34, lift).toFixed(2)}px ${lerp(10, 90, lift).toFixed(2)}px rgba(0,0,0,${(lerp(0.42, 0.6, lift) * presence).toFixed(3)}))`;
|
||||
const size = 192 * ICON.unit * k;
|
||||
const sweepX = sweep === null ? 0 : STAGE.x - 500 + sweep * (STAGE.w + 1000);
|
||||
return (
|
||||
<AbsoluteFill style={{ pointerEvents: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: cx - glowW / 2,
|
||||
top: cy - glowH / 2 + 24 * lift,
|
||||
width: glowW,
|
||||
height: glowH,
|
||||
background: `radial-gradient(closest-side, ${rgba(glowColor, glowAlpha)}, ${rgba(glowColor, 0)})`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill style={{ filter: shadow }}>
|
||||
<AbsoluteFill style={{ clipPath: `path('${d}')` }}>
|
||||
{content && content.opacity > 0.001 ? (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: content.opacity,
|
||||
transform: `scale(${content.scale})`,
|
||||
transformOrigin: `${STAGE_CENTER.x}px ${STAGE_CENTER.y}px`,
|
||||
filter: content.blur > 0.05 ? `blur(${content.blur.toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: STAGE.x,
|
||||
top: STAGE.y,
|
||||
width: STAGE.w,
|
||||
height: STAGE.h,
|
||||
background: content.background,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{content.node}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
) : null}
|
||||
{fill > 0.001 ? (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
// A soft top light that scales with the shape gives the flat colour depth.
|
||||
background: `radial-gradient(circle at ${cx.toFixed(1)}px ${(cy - 80 * k).toFixed(1)}px, rgba(255,255,255,0.09), rgba(255,255,255,0) ${(230 * k).toFixed(1)}px), ${color}`,
|
||||
opacity: fill,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
{details.map((detail, i) => (
|
||||
<Artwork key={`${detail.shape}-${i}`} details={detail} size={size} cx={cx} cy={cy} />
|
||||
))}
|
||||
{border > 0.001 || sweep !== null ? (
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="edge-sweep" gradientUnits="userSpaceOnUse" x1={sweepX - 260} y1={0} x2={sweepX + 260} y2={0}>
|
||||
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
<stop offset="0.5" stopColor="#FFFFFF" stopOpacity="1" />
|
||||
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={d} fill="none" stroke={`rgba(255,255,255,${(0.08 * border).toFixed(4)})`} strokeWidth={1} />
|
||||
{sweep !== null ? (
|
||||
<path d={d} fill="none" stroke="url(#edge-sweep)" strokeWidth={1.25} opacity={0.42 * Math.sin(Math.PI * sweep)} />
|
||||
) : null}
|
||||
</svg>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
import { COLOR, ENTER_END, EXIT_LEN, STAGE, STAGE_CENTER } from '../theme';
|
||||
import { roundedRectPath } from './geometry';
|
||||
import { Heading, type HeadingSpec, headingTimes } from './Heading';
|
||||
import { type MorphState, SHAPES, STAGE_ZOOM, type ShapeId, enterState, exitState, shapePath } from './morph';
|
||||
import { ShapeView, mix } from './Shape';
|
||||
|
||||
export type StageProps = {
|
||||
// Icon the stage blooms out of on the section's downbeat.
|
||||
enter?: { from: ShapeId };
|
||||
// Icon the stage collapses into on the next section's downbeat.
|
||||
exit?: { to: ShapeId };
|
||||
heading?: HeadingSpec | HeadingSpec[];
|
||||
// Panel colour behind the recording.
|
||||
background?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const OPEN: MorphState = {
|
||||
s: 1,
|
||||
k: STAGE_ZOOM,
|
||||
cx: STAGE_CENTER.x,
|
||||
cy: STAGE_CENTER.y,
|
||||
details: 0,
|
||||
fill: 0,
|
||||
content: 1,
|
||||
contentScale: 1,
|
||||
blur: 0,
|
||||
lift: 1,
|
||||
border: 1,
|
||||
sweep: null,
|
||||
};
|
||||
|
||||
const OPEN_PATH = roundedRectPath(STAGE.x, STAGE.y, STAGE.w, STAGE.h, STAGE.r);
|
||||
|
||||
// The framed screen of a footage section. Children are laid out in stage
|
||||
// coordinates (0..STAGE.w, 0..STAGE.h) and clipped to the stage's shape.
|
||||
export const Stage: React.FC<StageProps> = ({ enter, exit, heading, background = COLOR.panel, children }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
const entering = enter && frame < ENTER_END;
|
||||
const exiting = exit && frame >= durationInFrames - EXIT_LEN;
|
||||
const icon: ShapeId | null = entering ? enter.from : exiting ? exit.to : null;
|
||||
const state = entering ? enterState(frame) : exiting ? exitState(frame, durationInFrames) : OPEN;
|
||||
const d = icon === null ? OPEN_PATH : shapePath(icon, 'stage', state.s, state.k, state.cx, state.cy);
|
||||
const openColor = SHAPES[enter?.from ?? exit?.to ?? 'dot'].color;
|
||||
const color = icon === null ? openColor : SHAPES[icon].color;
|
||||
// The open stage glows in its own app's colour; closing hands over gradually.
|
||||
const glowColor = exiting ? mix(openColor, color, 1 - state.lift) : color;
|
||||
const sectionAccent = SHAPES[enter?.from ?? exit?.to ?? 'dot'].accent;
|
||||
const accent = (spec: HeadingSpec) => (spec.app ? SHAPES[spec.app].accent : sectionAccent);
|
||||
const headings = heading === undefined ? [] : headingTimes(Array.isArray(heading) ? heading : [heading], durationInFrames, accent);
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<ShapeView
|
||||
d={d}
|
||||
color={color}
|
||||
glowColor={glowColor}
|
||||
cx={state.cx}
|
||||
cy={state.cy}
|
||||
k={state.k}
|
||||
fill={state.fill}
|
||||
lift={state.lift}
|
||||
border={state.border}
|
||||
sweep={state.sweep}
|
||||
details={icon === null ? [] : [{ shape: icon, opacity: state.details }]}
|
||||
content={{ node: children, opacity: state.content, scale: state.contentScale, blur: state.blur, background }}
|
||||
/>
|
||||
{headings.map((timing) => (
|
||||
<Heading key={`${timing.spec.title}-${timing.enter}`} timing={timing} color={accent(timing.spec)} />
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cancelRender, continueRender, delayRender, staticFile } from 'remotion';
|
||||
|
||||
const FACES = [
|
||||
{ family: 'Noto Sans SC', file: 'fonts/NotoSansSC.ttf', weight: '100 900' },
|
||||
{ family: 'Inter', file: 'fonts/Inter.ttf', weight: '100 900' },
|
||||
{ family: 'JetBrains Mono', file: 'fonts/JetBrainsMono.ttf', weight: '100 800' },
|
||||
];
|
||||
|
||||
const handle = delayRender('Loading fonts', { timeoutInMilliseconds: 120000 });
|
||||
|
||||
Promise.all(
|
||||
FACES.map(async ({ family, file, weight }) => {
|
||||
const face = new FontFace(family, `url(${staticFile(file)}) format("truetype")`, { weight });
|
||||
await face.load();
|
||||
document.fonts.add(face);
|
||||
}),
|
||||
).then(
|
||||
() => continueRender(handle),
|
||||
(error: unknown) => cancelRender(error),
|
||||
);
|
||||
@@ -0,0 +1,186 @@
|
||||
export type Point = [number, number];
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
// Quarter (or any) arc sampled in y-down screen space; angles increase clockwise.
|
||||
export const arc = (cx: number, cy: number, r: number, from: number, to: number, steps: number): Point[] => {
|
||||
const out: Point[] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = from + ((to - from) * i) / steps;
|
||||
out.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const roundedRect = (x: number, y: number, w: number, h: number, r: number, steps = 10): Point[] => [
|
||||
...arc(x + w - r, y + r, r, -TAU / 4, 0, steps),
|
||||
...arc(x + w - r, y + h - r, r, 0, TAU / 4, steps),
|
||||
...arc(x + r, y + h - r, r, TAU / 4, TAU / 2, steps),
|
||||
...arc(x + r, y + r, r, TAU / 2, (TAU * 3) / 4, steps),
|
||||
];
|
||||
|
||||
export const circle = (r: number, steps = 96): Point[] =>
|
||||
Array.from({ length: steps }, (_, i): Point => [r * Math.cos((TAU * i) / steps - TAU / 4), r * Math.sin((TAU * i) / steps - TAU / 4)]);
|
||||
|
||||
// Exact rounded rectangle as an SVG path (arcs), for the open stage.
|
||||
export const roundedRectPath = (x: number, y: number, w: number, h: number, r: number) =>
|
||||
`M${x + r} ${y}H${x + w - r}A${r} ${r} 0 0 1 ${x + w} ${y + r}V${y + h - r}A${r} ${r} 0 0 1 ${x + w - r} ${y + h}` +
|
||||
`H${x + r}A${r} ${r} 0 0 1 ${x} ${y + h - r}V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z`;
|
||||
|
||||
export const toPath = (points: Point[], scale: number, cx: number, cy: number) => {
|
||||
let d = '';
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const [x, y] = points[i];
|
||||
d += `${i === 0 ? 'M' : 'L'}${(cx + x * scale).toFixed(2)} ${(cy + y * scale).toFixed(2)}`;
|
||||
}
|
||||
return `${d}Z`;
|
||||
};
|
||||
|
||||
// ---- Signed-distance silhouettes ------------------------------------------
|
||||
|
||||
export type Sdf = (x: number, y: number) => number;
|
||||
|
||||
export const sdfCircle = (cx: number, cy: number, r: number): Sdf => (x, y) => Math.hypot(x - cx, y - cy) - r;
|
||||
|
||||
export const sdfCapsule = (ax: number, ay: number, bx: number, by: number, r: number): Sdf => {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
return (x, y) => {
|
||||
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2));
|
||||
return Math.hypot(x - ax - t * dx, y - ay - t * dy) - r;
|
||||
};
|
||||
};
|
||||
|
||||
export const sdfRoundedBox = (x: number, y: number, w: number, h: number, r: number): Sdf => {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
return (px, py) => {
|
||||
const qx = Math.abs(px - cx) - w / 2 + r;
|
||||
const qy = Math.abs(py - cy) - h / 2 + r;
|
||||
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
|
||||
};
|
||||
};
|
||||
|
||||
// A cubic Bézier stroke as a chain of capsules.
|
||||
export const sdfCubicStroke = (p: [Point, Point, Point, Point], r: number, segments = 32): Sdf => {
|
||||
const at = (t: number): Point => {
|
||||
const u = 1 - t;
|
||||
return [
|
||||
u * u * u * p[0][0] + 3 * u * u * t * p[1][0] + 3 * u * t * t * p[2][0] + t * t * t * p[3][0],
|
||||
u * u * u * p[0][1] + 3 * u * u * t * p[1][1] + 3 * u * t * t * p[2][1] + t * t * t * p[3][1],
|
||||
];
|
||||
};
|
||||
const parts: Sdf[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const a = at(i / segments);
|
||||
const b = at((i + 1) / segments);
|
||||
parts.push(sdfCapsule(a[0], a[1], b[0], b[1], r));
|
||||
}
|
||||
return union(parts);
|
||||
};
|
||||
|
||||
export const union = (parts: Sdf[]): Sdf => (x, y) => {
|
||||
let d = Infinity;
|
||||
for (const part of parts) d = Math.min(d, part(x, y));
|
||||
return d;
|
||||
};
|
||||
|
||||
// Marching squares on the zero level set; returns the longest closed loop.
|
||||
export const contour = (sdf: Sdf, x0: number, y0: number, x1: number, y1: number, step: number): Point[] => {
|
||||
const nx = Math.ceil((x1 - x0) / step) + 1;
|
||||
const ny = Math.ceil((y1 - y0) / step) + 1;
|
||||
const v = new Float64Array(nx * ny);
|
||||
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) v[j * nx + i] = sdf(x0 + i * step, y0 + j * step);
|
||||
const at = (i: number, j: number) => v[j * nx + i];
|
||||
|
||||
const points = new Map<string, Point>();
|
||||
const links = new Map<string, string[]>();
|
||||
const edgePoint = (key: string, ax: number, ay: number, bx: number, by: number, va: number, vb: number) => {
|
||||
if (!points.has(key)) {
|
||||
const t = va / (va - vb);
|
||||
points.set(key, [x0 + (ax + (bx - ax) * t) * step, y0 + (ay + (by - ay) * t) * step]);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
const link = (a: string, b: string) => {
|
||||
links.set(a, [...(links.get(a) ?? []), b]);
|
||||
links.set(b, [...(links.get(b) ?? []), a]);
|
||||
};
|
||||
|
||||
for (let j = 0; j < ny - 1; j++) {
|
||||
for (let i = 0; i < nx - 1; i++) {
|
||||
const a = at(i, j);
|
||||
const b = at(i + 1, j);
|
||||
const c = at(i + 1, j + 1);
|
||||
const d = at(i, j + 1);
|
||||
const top = a < 0 !== b < 0 ? edgePoint(`h${i},${j}`, i, j, i + 1, j, a, b) : null;
|
||||
const right = b < 0 !== c < 0 ? edgePoint(`v${i + 1},${j}`, i + 1, j, i + 1, j + 1, b, c) : null;
|
||||
const bottom = d < 0 !== c < 0 ? edgePoint(`h${i},${j + 1}`, i, j + 1, i + 1, j + 1, d, c) : null;
|
||||
const left = a < 0 !== d < 0 ? edgePoint(`v${i},${j}`, i, j, i, j + 1, a, d) : null;
|
||||
const crossed = [top, right, bottom, left].filter((e): e is string => e !== null);
|
||||
if (crossed.length === 2) link(crossed[0], crossed[1]);
|
||||
else if (crossed.length === 4) {
|
||||
const centreInside = (a + b + c + d) / 4 < 0;
|
||||
const aInside = a < 0;
|
||||
if (aInside === centreInside) {
|
||||
link(top!, right!);
|
||||
link(bottom!, left!);
|
||||
} else {
|
||||
link(top!, left!);
|
||||
link(right!, bottom!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let best: Point[] = [];
|
||||
for (const start of links.keys()) {
|
||||
if (seen.has(start)) continue;
|
||||
const loop: Point[] = [];
|
||||
let prev = '';
|
||||
let cur = start;
|
||||
while (!seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
loop.push(points.get(cur)!);
|
||||
const next = links.get(cur)!.find((n) => n !== prev && !seen.has(n));
|
||||
if (!next) break;
|
||||
prev = cur;
|
||||
cur = next;
|
||||
}
|
||||
if (loop.length > best.length) best = loop;
|
||||
}
|
||||
return simplify(best, step * 0.12);
|
||||
};
|
||||
|
||||
// Ramer–Douglas–Peucker on a closed ring.
|
||||
const simplify = (ring: Point[], tolerance: number): Point[] => {
|
||||
const keep = new Uint8Array(ring.length);
|
||||
const walk = (a: number, b: number) => {
|
||||
let worst = -1;
|
||||
let dist = tolerance;
|
||||
const [ax, ay] = ring[a];
|
||||
const [bx, by] = ring[b];
|
||||
const len = Math.hypot(bx - ax, by - ay) || 1;
|
||||
for (let i = a + 1; i < b; i++) {
|
||||
const d = Math.abs((bx - ax) * (ay - ring[i][1]) - (ax - ring[i][0]) * (by - ay)) / len;
|
||||
if (d > dist) {
|
||||
dist = d;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
if (worst >= 0) {
|
||||
keep[worst] = 1;
|
||||
walk(a, worst);
|
||||
walk(worst, b);
|
||||
}
|
||||
};
|
||||
const half = Math.floor(ring.length / 2);
|
||||
keep[0] = 1;
|
||||
keep[half] = 1;
|
||||
walk(0, half);
|
||||
walk(half, ring.length - 1);
|
||||
keep[ring.length - 1] = 1;
|
||||
return ring.filter((_, i) => keep[i] === 1);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { interpolate as flubber } from 'flubber';
|
||||
import { interpolate } from 'remotion';
|
||||
import { type AppId, EASE, ICON, MORPH, STAGE, STAGE_CENTER } from '../theme';
|
||||
import {
|
||||
type Point,
|
||||
circle,
|
||||
contour,
|
||||
roundedRect,
|
||||
sdfCapsule,
|
||||
sdfCircle,
|
||||
sdfCubicStroke,
|
||||
sdfRoundedBox,
|
||||
toPath,
|
||||
union,
|
||||
arc,
|
||||
} from './geometry';
|
||||
|
||||
export type ShapeId = AppId | 'dot' | 'cell';
|
||||
|
||||
// Silhouettes in Papirus grid units, centred on the grid centre (96, 96).
|
||||
const centre = (points: Point[]): Point[] => points.map(([x, y]) => [x - 96, y - 96]);
|
||||
const Q = Math.PI / 2;
|
||||
|
||||
const page = centre([
|
||||
[40, 16],
|
||||
[112, 16],
|
||||
[160, 64],
|
||||
...arc(152, 168, 8, 0, Q, 8),
|
||||
...arc(40, 168, 8, Q, 2 * Q, 8),
|
||||
...arc(40, 24, 8, 2 * Q, 3 * Q, 8),
|
||||
]);
|
||||
|
||||
const board = centre(
|
||||
contour(
|
||||
union([sdfRoundedBox(16, 28, 160, 112, 10), sdfCapsule(96, 136, 64, 176, 6), sdfCapsule(96, 136, 128, 176, 6)]),
|
||||
0,
|
||||
0,
|
||||
192,
|
||||
192,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
|
||||
const branch = centre(
|
||||
contour(
|
||||
union([
|
||||
sdfCapsule(64, 44, 64, 148, 10),
|
||||
sdfCubicStroke(
|
||||
[
|
||||
[64, 128],
|
||||
[64, 92],
|
||||
[128, 104],
|
||||
[128, 72],
|
||||
],
|
||||
10,
|
||||
),
|
||||
sdfCircle(64, 40, 24),
|
||||
sdfCircle(128, 64, 24),
|
||||
sdfCircle(64, 152, 24),
|
||||
]),
|
||||
0,
|
||||
0,
|
||||
192,
|
||||
192,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
|
||||
// `color` fills the silhouette; `accent` marks the section heading.
|
||||
export const SHAPES: Record<ShapeId, { points: Point[]; color: string; accent: string }> = {
|
||||
'ely-doc': { points: page, color: '#3D7FE5', accent: '#3D7FE5' },
|
||||
'ely-table': { points: page, color: '#2E9E5B', accent: '#2E9E5B' },
|
||||
'ely-pdf': { points: page, color: '#E0344F', accent: '#E0344F' },
|
||||
'ely-show': { points: board, color: '#F07B2A', accent: '#F07B2A' },
|
||||
'ely-tree': { points: branch, color: '#7E57C2', accent: '#7E57C2' },
|
||||
'ely-mark': { points: centre(roundedRect(28, 24, 136, 152, 10)), color: '#3E4A5C', accent: '#F2A900' },
|
||||
cell: { points: centre(roundedRect(16, 51, 160, 90, 8)), color: '#3E4A5C', accent: '#F2A900' },
|
||||
dot: { points: circle(15), color: '#3D7FE5', accent: '#3D7FE5' },
|
||||
};
|
||||
|
||||
// The open stage, shrunk to icon size so shape and size can morph separately.
|
||||
const PROXY_W = 168;
|
||||
const PROXY_H = (PROXY_W * STAGE.h) / STAGE.w;
|
||||
const PROXY_R = (STAGE.r * PROXY_W) / STAGE.w;
|
||||
const proxy = roundedRect(-PROXY_W / 2, -PROXY_H / 2, PROXY_W, PROXY_H, PROXY_R, 10);
|
||||
// Zoom at which the proxy is exactly the stage.
|
||||
export const STAGE_ZOOM = STAGE.w / (PROXY_W * ICON.unit);
|
||||
|
||||
// Rings are resampled into four runs between corresponding anchors (the
|
||||
// shape's outer corners), so corners travel to corners and nothing shears.
|
||||
const RUN = 128;
|
||||
const ANCHORS: Partial<Record<ShapeId, Point[]>> = {
|
||||
// The board's corners, not the stand's feet.
|
||||
'ely-show': centre([
|
||||
[16, 28],
|
||||
[176, 28],
|
||||
[176, 140],
|
||||
[16, 140],
|
||||
]),
|
||||
};
|
||||
|
||||
const resample = (line: Point[], count: number): Point[] => {
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < line.length; i++) lengths.push(lengths[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]));
|
||||
const total = lengths[lengths.length - 1];
|
||||
const out: Point[] = [];
|
||||
let j = 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const at = (total * i) / count;
|
||||
while (j < line.length - 1 && lengths[j] < at) j++;
|
||||
const span = lengths[j] - lengths[j - 1] || 1;
|
||||
const u = (at - lengths[j - 1]) / span;
|
||||
out.push([line[j - 1][0] + (line[j][0] - line[j - 1][0]) * u, line[j - 1][1] + (line[j][1] - line[j - 1][1]) * u]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const prepared = new Map<string, Point[]>();
|
||||
const prepare = (id: ShapeId | 'stage'): Point[] => {
|
||||
const found = prepared.get(id);
|
||||
if (found) return found;
|
||||
let ring = id === 'stage' ? proxy : SHAPES[id].points;
|
||||
let area = 0;
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const [ax, ay] = ring[i];
|
||||
const [bx, by] = ring[(i + 1) % ring.length];
|
||||
area += ax * by - bx * ay;
|
||||
}
|
||||
// Clockwise on screen.
|
||||
if (area < 0) ring = [...ring].reverse();
|
||||
const xs = ring.map((p) => p[0]);
|
||||
const ys = ring.map((p) => p[1]);
|
||||
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
|
||||
const anchors = (id !== 'stage' && ANCHORS[id]) || [
|
||||
[x0, y0],
|
||||
[x1, y0],
|
||||
[x1, y1],
|
||||
[x0, y1],
|
||||
];
|
||||
const nearest = ([ax, ay]: Point) => ring.reduce((best, p, i) => (Math.hypot(p[0] - ax, p[1] - ay) < Math.hypot(ring[best][0] - ax, ring[best][1] - ay) ? i : best), 0);
|
||||
const first = nearest(anchors[0]);
|
||||
ring = [...ring.slice(first), ...ring.slice(0, first)];
|
||||
const cuts = [0, ...anchors.slice(1).map(nearest), ring.length];
|
||||
if (!cuts.every((c, i) => i === 0 || c > cuts[i - 1])) throw new Error(`Anchors of ${id} are out of order`);
|
||||
const out: Point[] = [];
|
||||
for (let k = 0; k < 4; k++) out.push(...resample([...ring.slice(cuts[k], cuts[k + 1]), ring[cuts[k + 1] % ring.length]], RUN));
|
||||
prepared.set(id, out);
|
||||
return out;
|
||||
};
|
||||
|
||||
type Ring = (t: number) => Point[];
|
||||
const cache = new Map<string, Ring>();
|
||||
|
||||
const ring = (from: ShapeId | 'stage', to: ShapeId | 'stage'): Ring => {
|
||||
const key = `${from}>${to}`;
|
||||
let found = cache.get(key);
|
||||
if (!found) {
|
||||
// Equal, aligned rings: flubber neither bisects nor needs to rotate them.
|
||||
found = flubber(prepare(from), prepare(to), { string: false, maxSegmentLength: 0 });
|
||||
cache.set(key, found);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
// Path of `from` turned `t` of the way into `to`, drawn at zoom `k` around (cx, cy).
|
||||
export const shapePath = (from: ShapeId | 'stage', to: ShapeId | 'stage', t: number, k: number, cx: number, cy: number) => {
|
||||
const points = from === to ? prepare(from) : ring(from, to)(t);
|
||||
return toPath(points, ICON.unit * k, cx, cy);
|
||||
};
|
||||
|
||||
// ---- Boundary choreography ------------------------------------------------
|
||||
|
||||
export type MorphState = {
|
||||
// 0: icon silhouette, 1: stage rectangle.
|
||||
s: number;
|
||||
// Zoom of the shape (1 = icon size, STAGE_ZOOM = stage size).
|
||||
k: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
// Opacity of the full-colour icon artwork over the silhouette.
|
||||
details: number;
|
||||
// Opacity of the app-colour wash over the stage content.
|
||||
fill: number;
|
||||
content: number;
|
||||
contentScale: number;
|
||||
blur: number;
|
||||
// 0: icon shadow, 1: stage shadow.
|
||||
lift: number;
|
||||
border: number;
|
||||
// 0..1 travel of the light sweep along the stage edge; null when idle.
|
||||
sweep: number | null;
|
||||
};
|
||||
|
||||
const clamp = (v: number) => Math.min(1, Math.max(0, v));
|
||||
const smooth = (v: number, a: number, b: number) => {
|
||||
const x = clamp((v - a) / (b - a));
|
||||
return x * x * (3 - 2 * x);
|
||||
};
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
// The downbeat swell: the icon breathes in on the cut.
|
||||
export const boundaryPulse = (d: number) => 0.04 * Math.exp(-((d / 6.5) ** 2));
|
||||
|
||||
// Icon resting on a cut, `d` frames from it (negative: before it).
|
||||
export const restState = (d: number): MorphState => ({
|
||||
s: 0,
|
||||
k: 1 + boundaryPulse(d),
|
||||
cx: ICON.x,
|
||||
cy: ICON.y,
|
||||
details: 1 - smooth(Math.abs(d), 10, MORPH.hold),
|
||||
fill: 1,
|
||||
content: 0,
|
||||
contentScale: 0.72,
|
||||
blur: 14,
|
||||
lift: 0,
|
||||
border: 0,
|
||||
sweep: null,
|
||||
});
|
||||
|
||||
// `o` is how open the stage is (0 icon, 1 stage), in each direction's own time.
|
||||
const openState = (shape: number, zoom: number, o: number, sweep: number | null): MorphState => ({
|
||||
s: shape,
|
||||
k: Math.exp(Math.log(STAGE_ZOOM) * zoom),
|
||||
cx: lerp(ICON.x, STAGE_CENTER.x, zoom),
|
||||
cy: lerp(ICON.y, STAGE_CENTER.y, zoom),
|
||||
details: 0,
|
||||
fill: 1 - smooth(o, 0.32, 0.82),
|
||||
content: smooth(o, 0.22, 0.7),
|
||||
contentScale: lerp(0.72, 1, EASE.out(o)),
|
||||
blur: 14 * (1 - o) ** 2,
|
||||
lift: o,
|
||||
border: smooth(o, 0.8, 1),
|
||||
sweep,
|
||||
});
|
||||
|
||||
// Section-local frame `f` of an entering stage.
|
||||
export const enterState = (f: number): MorphState => {
|
||||
if (f < MORPH.hold) return restState(f);
|
||||
const o = clamp((f - MORPH.hold) / MORPH.morph);
|
||||
const shape = EASE.inOut(clamp(o / 0.6));
|
||||
const zoom = EASE.bloom(o);
|
||||
const sweep = o > 0.55 ? clamp((o - 0.55) / 0.45) : null;
|
||||
return openState(shape, zoom, zoom, sweep);
|
||||
};
|
||||
|
||||
// Section-local frame `f` of a stage in a section `duration` frames long.
|
||||
export const exitState = (f: number, duration: number): MorphState => {
|
||||
const d = f - duration;
|
||||
if (d >= -MORPH.hold) return restState(d);
|
||||
const q = clamp((d + MORPH.hold + MORPH.morph) / MORPH.morph);
|
||||
const zoom = 1 - EASE.land(q);
|
||||
const shape = 1 - EASE.inOut(clamp((q - 0.22) / 0.78));
|
||||
return openState(shape, zoom, zoom, null);
|
||||
};
|
||||
|
||||
export const isOpen = (state: MorphState) => state.s >= 1 && state.k >= STAGE_ZOOM - 1e-6;
|
||||
|
||||
export const fade = (f: number, from: number, to: number, ease = EASE.out) =>
|
||||
interpolate(f, [from, to], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ease });
|
||||
@@ -0,0 +1,9 @@
|
||||
declare module 'flubber' {
|
||||
type Point = [number, number];
|
||||
type Shape = string | Point[];
|
||||
interface Options {
|
||||
maxSegmentLength?: number;
|
||||
}
|
||||
export function interpolate(from: Shape, to: Shape, options: Options & { string: false }): (t: number) => Point[];
|
||||
export function interpolate(from: Shape, to: Shape, options?: Options & { string?: true }): (t: number) => string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, interpolate, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
|
||||
import { EASE, FOOTAGE, FPS, STAGE } from '../theme';
|
||||
import { Cursor } from './Cursor';
|
||||
import type { TakeName } from './take';
|
||||
|
||||
/** A stretch of one take: footage seconds `from` to `to`. */
|
||||
export type Cut = { take: TakeName; from: number; to: number; rate?: number; fade?: number };
|
||||
|
||||
/** A camera key at section frame `f`: zoom and the footage point in the middle. */
|
||||
export type Shot = { f: number; z: number; x: number; y: number };
|
||||
|
||||
export type Placed = Cut & { start: number; length: number };
|
||||
|
||||
/** Each cut's section frames; a cut's fade overlaps the one before. */
|
||||
export const lay = (cuts: Cut[], first = 0): Placed[] => {
|
||||
let at = first;
|
||||
return cuts.map((cut, i) => {
|
||||
const length = Math.round(((cut.to - cut.from) * FPS) / (cut.rate ?? 1));
|
||||
const start = i === 0 ? at : at - (cut.fade ?? 0);
|
||||
at = start + length;
|
||||
return { ...cut, start, length };
|
||||
});
|
||||
};
|
||||
|
||||
/** Footage time a placed cut shows at section frame `f`. */
|
||||
export const sourceAt = (cut: Placed, f: number) => cut.from + ((f - cut.start) * (cut.rate ?? 1)) / FPS;
|
||||
|
||||
const camera = (shots: Shot[], f: number) => {
|
||||
if (f <= shots[0].f) return shots[0];
|
||||
const last = shots[shots.length - 1];
|
||||
if (f >= last.f) return last;
|
||||
const i = shots.findIndex((s, k) => k > 0 && s.f > f);
|
||||
const a = shots[i - 1];
|
||||
const b = shots[i];
|
||||
const k = EASE.inOut((f - a.f) / (b.f - a.f));
|
||||
return { f, z: a.z + (b.z - a.z) * k, x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k };
|
||||
};
|
||||
|
||||
/** Footage pixels to stage pixels, under the camera at frame `f`. */
|
||||
export const viewOf = (shots: Shot[], f: number) => {
|
||||
const c = camera(shots, f);
|
||||
const k = FOOTAGE.scale * c.z;
|
||||
const vw = FOOTAGE.w / c.z;
|
||||
const vh = FOOTAGE.h / c.z;
|
||||
const left = Math.min(Math.max(c.x - vw / 2, 0), FOOTAGE.w - vw);
|
||||
const top = Math.min(Math.max(c.y - vh / 2, 0), FOOTAGE.h - vh);
|
||||
return { k, left, top, z: c.z };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cuts: Cut[];
|
||||
shots: Shot[];
|
||||
/** Footage-space overlays for a cut at footage time `t`. */
|
||||
overlay?: (cut: Placed, t: number, f: number) => React.ReactNode;
|
||||
};
|
||||
|
||||
/** Recorded takes on the stage, with the drawn pointer and a directed camera. */
|
||||
export const Clip: React.FC<Props> = ({ cuts, shots, overlay }) => {
|
||||
const f = useCurrentFrame();
|
||||
const view = viewOf(shots, f);
|
||||
const placed = lay(cuts);
|
||||
return (
|
||||
<AbsoluteFill style={{ width: STAGE.w, height: STAGE.h, overflow: 'hidden', background: '#202020' }}>
|
||||
{placed.map((cut, i) => {
|
||||
const fade = i === 0 ? 0 : cut.fade ?? 0;
|
||||
const opacity = fade ? interpolate(f, [cut.start, cut.start + fade], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: EASE.inOut }) : 1;
|
||||
const next = placed[i + 1];
|
||||
const end = next ? next.start + (next.fade ?? 0) : cut.start + cut.length;
|
||||
if (f < cut.start || f >= end) return null;
|
||||
const t = sourceAt(cut, f);
|
||||
return (
|
||||
<AbsoluteFill key={i} style={{ opacity }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: FOOTAGE.w,
|
||||
height: FOOTAGE.h,
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${view.k}) translate(${-view.left}px, ${-view.top}px)`,
|
||||
}}
|
||||
>
|
||||
<Sequence from={cut.start} durationInFrames={end - cut.start} layout="none">
|
||||
<OffthreadVideo
|
||||
src={staticFile(`footage/${cut.take}.mp4`)}
|
||||
trimBefore={Math.round(cut.from * FPS)}
|
||||
playbackRate={cut.rate ?? 1}
|
||||
muted
|
||||
style={{ width: FOOTAGE.w, height: FOOTAGE.h, display: 'block' }}
|
||||
/>
|
||||
</Sequence>
|
||||
{overlay?.(cut, t, f)}
|
||||
<Cursor take={cut.take} t={t} zoom={view.z} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { interpolate } from 'remotion';
|
||||
import { COLOR, EASE } from '../theme';
|
||||
import { actions, pointerAt, type TakeName } from './take';
|
||||
|
||||
const RIPPLE = 0.55;
|
||||
const RIGHT = '#F2A900';
|
||||
|
||||
/** The pointer, drawn from the input log; presses ripple. */
|
||||
export const Cursor: React.FC<{ take: TakeName; t: number; zoom: number }> = ({ take, t, zoom }) => {
|
||||
const p = pointerAt(take, t);
|
||||
const downs = actions(take, 'down');
|
||||
const ups = actions(take, 'up');
|
||||
const held = downs.some((d) => {
|
||||
const up = ups.find((u) => u.t >= d.t);
|
||||
return t >= d.t && t <= (up ? up.t : d.t) + 0.06;
|
||||
});
|
||||
// Larger under zoom, but less than the footage grows.
|
||||
const size = 44 / Math.sqrt(zoom);
|
||||
const press = held ? 0.86 : 1;
|
||||
return (
|
||||
<>
|
||||
{downs
|
||||
.filter((d) => t >= d.t && t < d.t + RIPPLE)
|
||||
.map((d) => {
|
||||
const k = (t - d.t) / RIPPLE;
|
||||
const r = interpolate(k, [0, 1], [8, 46], { easing: EASE.out }) / Math.sqrt(zoom);
|
||||
const tint = d.right ? RIGHT : COLOR.accent;
|
||||
return (
|
||||
<div
|
||||
key={d.t}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: d.x! - r,
|
||||
top: d.y! - r,
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
borderRadius: '50%',
|
||||
border: `${3 / Math.sqrt(zoom)}px solid ${tint}`,
|
||||
background: `${tint}22`,
|
||||
opacity: 1 - k,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<svg
|
||||
width={size}
|
||||
height={size * 1.5}
|
||||
viewBox="0 0 24 36"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: p.x - size * 0.06,
|
||||
top: p.y - size * 0.04,
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${press})`,
|
||||
filter: 'drop-shadow(0 3px 5px rgba(0,0,0,0.45))',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M1.5 1.5 L1.5 27 L7.6 21.4 L11.6 30.6 L15.6 28.9 L11.7 19.9 L19.8 19.9 Z"
|
||||
fill="#fff"
|
||||
stroke="#111"
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Audio, Sequence, staticFile } from 'remotion';
|
||||
import { FPS } from '../theme';
|
||||
import { lay, type Cut } from './Clip';
|
||||
import { TAKES } from './take';
|
||||
|
||||
const SOUND: Record<string, { file: string; volume: number } | undefined> = {
|
||||
down: { file: 'click', volume: 0.55 },
|
||||
right: { file: 'menu', volume: 0.6 },
|
||||
key: { file: 'key', volume: 0.5 },
|
||||
char: { file: 'key', volume: 0.45 },
|
||||
wheel: { file: 'tick', volume: 0.18 },
|
||||
};
|
||||
|
||||
/** UI sounds on the logged presses, keys and scrolls of `cuts`. */
|
||||
export const Sounds: React.FC<{ cuts: Cut[] }> = ({ cuts }) => {
|
||||
const hits: { frame: number; file: string; volume: number }[] = [];
|
||||
for (const cut of lay(cuts)) {
|
||||
for (const e of TAKES[cut.take].events) {
|
||||
if (e.t < cut.from || e.t >= cut.to) continue;
|
||||
const sound = SOUND[e.kind === 'down' && e.right ? 'right' : e.kind];
|
||||
if (!sound) continue;
|
||||
hits.push({ frame: Math.round(cut.start + ((e.t - cut.from) * FPS) / (cut.rate ?? 1)), ...sound });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{hits.map((hit, i) => (
|
||||
<Sequence key={i} from={hit.frame} durationInFrames={60} layout="none">
|
||||
<Audio src={staticFile(`audio/sfx/${hit.file}.wav`)} volume={hit.volume} />
|
||||
</Sequence>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Section frame of footage time `t` in `cuts`, for timing overlays. */
|
||||
export const frameOf = (cuts: Cut[], t: number) => {
|
||||
const cut = lay(cuts).find((c) => t >= c.from && t < c.to);
|
||||
if (!cut) throw new Error(`no cut shows footage time ${t}`);
|
||||
return Math.round(cut.start + ((t - cut.from) * FPS) / (cut.rate ?? 1));
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { interpolate } from 'remotion';
|
||||
import { COLOR, EASE, FONT } from '../theme';
|
||||
import { pointerAt, TAKES, type TakeName } from './take';
|
||||
|
||||
type Props = {
|
||||
take: TakeName;
|
||||
t: number;
|
||||
/** Footage seconds the pointer crosses toward the submenu. */
|
||||
from: number;
|
||||
to: number;
|
||||
/** The row it crosses without opening, as y0..y1, and its note. */
|
||||
passed: [number, number];
|
||||
note: string;
|
||||
};
|
||||
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
|
||||
/** The safe triangle: pointer to the submenu's near corners, live. */
|
||||
export const Triangle: React.FC<Props> = ({ take, t, from, to, passed, note }) => {
|
||||
const menu = TAKES[take].menus;
|
||||
if (!menu) throw new Error(`${take} has no submenu rectangle`);
|
||||
const show = interpolate(t, [from - 0.35, from, to + 0.25, to + 0.7], [0, 1, 1, 0], { ...clamp, easing: EASE.inOut });
|
||||
if (show <= 0) return null;
|
||||
const p = pointerAt(take, Math.min(t, to + 0.25));
|
||||
const [x0, y0, , y1] = menu.panel;
|
||||
const [px0, , px1] = menu.parent;
|
||||
const drawn = interpolate(t, [from - 0.35, from + 0.1], [0, 1], { ...clamp, easing: EASE.out });
|
||||
const near = { x: x0 + 2, top: y0 + 6, bottom: y0 + 6 + (y1 - y0 - 12) * drawn };
|
||||
const row = interpolate(t, [from + 0.15, from + 0.45], [0, 1], { ...clamp, easing: EASE.out });
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: show, pointerEvents: 'none' }}>
|
||||
<svg width={2048} height={1152} style={{ position: 'absolute', inset: 0, overflow: 'visible' }}>
|
||||
<defs>
|
||||
<linearGradient id="safe" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stopColor={COLOR.accent} stopOpacity={0.05} />
|
||||
<stop offset="1" stopColor={COLOR.accent} stopOpacity={0.3} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={`M ${p.x} ${p.y} L ${near.x} ${near.top} L ${near.x} ${near.bottom} Z`}
|
||||
fill="url(#safe)"
|
||||
stroke={COLOR.accent}
|
||||
strokeWidth={2.5}
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="10 7"
|
||||
/>
|
||||
<circle cx={p.x} cy={p.y} r={7} fill={COLOR.accent} />
|
||||
<rect
|
||||
x={px0 + 6}
|
||||
y={passed[0]}
|
||||
width={(px1 - px0 - 12) * row}
|
||||
height={passed[1] - passed[0]}
|
||||
rx={8}
|
||||
fill="none"
|
||||
stroke="#F2A900"
|
||||
strokeWidth={2.5}
|
||||
opacity={0.9}
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 2048 - px0 + 22,
|
||||
top: (passed[0] + passed[1]) / 2 - 24,
|
||||
opacity: row,
|
||||
transform: `translateX(${(1 - row) * 16}px)`,
|
||||
padding: '8px 16px',
|
||||
borderRadius: 12,
|
||||
background: 'rgba(28,28,30,0.92)',
|
||||
border: '1.5px solid #F2A90088',
|
||||
color: '#F7E2A6',
|
||||
fontFamily: FONT.sans,
|
||||
fontSize: 26,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// Test entry for the footage sections: `remotion still src/footage/preview.tsx <Section> …`.
|
||||
import { AbsoluteFill, Composition, registerRoot } from 'remotion';
|
||||
import '../design/fonts';
|
||||
import { Background } from '../design/Background';
|
||||
import { Cells } from '../scenes/Cells';
|
||||
import { Menus } from '../scenes/Menus';
|
||||
import { More } from '../scenes/More';
|
||||
import { RightClick } from '../scenes/RightClick';
|
||||
import { SECTION } from '../theme';
|
||||
|
||||
const framed = (Section: React.FC) => () => (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
<Section />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
|
||||
const ALL = [
|
||||
['Menus', Menus, SECTION.menus],
|
||||
['Cells', Cells, SECTION.cells],
|
||||
['RightClick', RightClick, SECTION.rightClick],
|
||||
['More', More, SECTION.more],
|
||||
] as const;
|
||||
|
||||
registerRoot(() => (
|
||||
<>
|
||||
{ALL.map(([id, Section, at]) => (
|
||||
<Composition key={id} id={id} component={framed(Section)} width={1920} height={1080} fps={60} durationInFrames={at.duration} />
|
||||
))}
|
||||
</>
|
||||
));
|
||||
@@ -0,0 +1,62 @@
|
||||
import cells from '../../public/footage/cells.json';
|
||||
import doc from '../../public/footage/doc.json';
|
||||
import menus from '../../public/footage/menus.json';
|
||||
import more from '../../public/footage/more.json';
|
||||
import show from '../../public/footage/show.json';
|
||||
import table from '../../public/footage/table.json';
|
||||
|
||||
// Times are footage seconds; positions are footage pixels (2048×1152).
|
||||
export type Event = {
|
||||
t: number;
|
||||
kind: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
right?: boolean;
|
||||
key?: string;
|
||||
ch?: string;
|
||||
down?: boolean;
|
||||
};
|
||||
|
||||
export type Box = [number, number, number, number];
|
||||
|
||||
export type Take = {
|
||||
fps: number;
|
||||
size: number[];
|
||||
events: Event[];
|
||||
menus: { parent: number[]; panel: number[] } | null;
|
||||
};
|
||||
|
||||
export const TAKES = { menus, cells, doc, table, show, more } as Record<string, Take>;
|
||||
export type TakeName = 'menus' | 'cells' | 'doc' | 'table' | 'show' | 'more';
|
||||
|
||||
const placed = new Map<string, Event[]>();
|
||||
const pointed = (name: TakeName) => {
|
||||
let list = placed.get(name);
|
||||
if (!list) {
|
||||
list = TAKES[name].events.filter((e) => e.x !== undefined && e.kind !== 'sync');
|
||||
placed.set(name, list);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
/** Where the pointer was at footage time `t`. */
|
||||
export const pointerAt = (name: TakeName, t: number) => {
|
||||
const list = pointed(name);
|
||||
let lo = 0;
|
||||
let hi = list.length - 1;
|
||||
if (t <= list[0].t) return { x: list[0].x!, y: list[0].y! };
|
||||
if (t >= list[hi].t) return { x: list[hi].x!, y: list[hi].y! };
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (list[mid].t <= t) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const a = list[lo];
|
||||
const b = list[hi];
|
||||
const f = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
|
||||
return { x: a.x! + (b.x! - a.x!) * f, y: a.y! + (b.y! - a.y!) * f };
|
||||
};
|
||||
|
||||
/** Events of `kinds` in the take, in order. */
|
||||
export const actions = (name: TakeName, ...kinds: string[]) =>
|
||||
TAKES[name].events.filter((e) => kinds.includes(e.kind));
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { Root } from './Root';
|
||||
|
||||
registerRoot(Root);
|
||||
@@ -0,0 +1,121 @@
|
||||
import { cancelRender, continueRender, delayRender, interpolate, staticFile, useCurrentFrame } from 'remotion';
|
||||
import { COLOR, EASE, FONT } from '../theme';
|
||||
|
||||
// The 'before' card mixes faces, as Windows did.
|
||||
const serif = delayRender('Loading Noto Serif SC', { timeoutInMilliseconds: 120000 });
|
||||
new FontFace('Noto Serif SC', `url(${staticFile('fonts/NotoSerifSC.ttf')}) format("truetype")`, { weight: '200 900' })
|
||||
.load()
|
||||
.then((face) => {
|
||||
document.fonts.add(face);
|
||||
continueRender(serif);
|
||||
}, cancelRender);
|
||||
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
const BEAT = 37.5;
|
||||
|
||||
const Card: React.FC<{ at: number; x: number; title: string; sub: string; children: React.ReactNode }> = ({ at, x, title, sub, children }) => {
|
||||
const f = useCurrentFrame();
|
||||
const p = interpolate(f, [at, at + 34], [0, 1], { ...clamp, easing: EASE.out });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: 150,
|
||||
width: 700,
|
||||
height: 640,
|
||||
borderRadius: 28,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0.02))',
|
||||
border: `1px solid ${COLOR.hairline}`,
|
||||
boxShadow: '0 30px 80px rgba(0,0,0,0.35)',
|
||||
opacity: p,
|
||||
transform: `translateY(${(1 - p) * 40}px) scale(${0.96 + 0.04 * p})`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: '0 0 190px 0' }}>{children}</div>
|
||||
<div style={{ position: 'absolute', left: 48, right: 48, bottom: 56 }}>
|
||||
<div style={{ fontFamily: FONT.sans, fontSize: 40, fontWeight: 600, color: COLOR.ink, letterSpacing: '0.02em' }}>{title}</div>
|
||||
<div style={{ fontFamily: FONT.sans, fontSize: 24, fontWeight: 400, color: COLOR.muted, marginTop: 12, lineHeight: 1.5 }}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** One word in two faces, settling into one. */
|
||||
const Glyphs: React.FC<{ at: number }> = ({ at }) => {
|
||||
const f = useCurrentFrame();
|
||||
const fix = interpolate(f, [at + 2 * BEAT, at + 3.4 * BEAT], [0, 1], { ...clamp, easing: EASE.inOut });
|
||||
const label = (text: string, on: number) => (
|
||||
<span style={{ fontFamily: FONT.sans, fontSize: 22, fontWeight: 500, letterSpacing: '0.2em', color: COLOR.faint, opacity: on }}>{text}</span>
|
||||
);
|
||||
const word = (style: React.CSSProperties, text: string) => <span style={{ display: 'inline-block', ...style }}>{text}</span>;
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26 }}>
|
||||
<div style={{ position: 'relative', height: 30, width: 200, textAlign: 'center' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>{label('之前', 1 - fix)}</div>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>{label('现在', fix)}</div>
|
||||
</div>
|
||||
<div style={{ position: 'relative', fontSize: 120, color: COLOR.ink, lineHeight: 1, height: 130 }}>
|
||||
{word({ fontFamily: FONT.sans, fontVariationSettings: `'wght' ${800 - 300 * fix}`, fontWeight: 800 - 300 * fix }, '打开')}
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
{word({ fontFamily: '"Noto Serif SC"', fontWeight: 300, opacity: 1 - fix, filter: `blur(${fix * 6}px)` }, '仓库')}
|
||||
{word(
|
||||
{ position: 'absolute', left: 0, top: 0, fontFamily: FONT.sans, fontWeight: 500, opacity: fix, filter: `blur(${(1 - fix) * 6}px)` },
|
||||
'仓库',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 6, width: 360 * fix, borderRadius: 3, background: COLOR.accent, opacity: 0.9 }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** A shield draws itself, then a check. */
|
||||
const Notarized: React.FC<{ at: number }> = ({ at }) => {
|
||||
const f = useCurrentFrame();
|
||||
const shield = interpolate(f, [at + 10, at + 10 + 2 * BEAT], [0, 1], { ...clamp, easing: EASE.inOut });
|
||||
const check = interpolate(f, [at + 2.4 * BEAT, at + 3.4 * BEAT], [0, 1], { ...clamp, easing: EASE.out });
|
||||
const glow = interpolate(f, [at + 3 * BEAT, at + 4.2 * BEAT], [0, 1], { ...clamp, easing: EASE.out });
|
||||
const SHIELD = 'M120 20 L210 52 V128 C210 186 172 228 120 248 C68 228 30 186 30 128 V52 Z';
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width={260} height={280} viewBox="0 0 240 270" style={{ overflow: 'visible' }}>
|
||||
<path d={SHIELD} fill={COLOR.accent} opacity={0.14 * glow} />
|
||||
<path
|
||||
d={SHIELD}
|
||||
fill="none"
|
||||
stroke={COLOR.ink}
|
||||
strokeWidth={7}
|
||||
strokeLinejoin="round"
|
||||
pathLength={1}
|
||||
strokeDasharray="1 1"
|
||||
strokeDashoffset={1 - shield}
|
||||
/>
|
||||
<path
|
||||
d="M78 134 L108 164 L166 102"
|
||||
fill="none"
|
||||
stroke={COLOR.accent}
|
||||
strokeWidth={14}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pathLength={1}
|
||||
strokeDasharray="1 1"
|
||||
strokeDashoffset={1 - check}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The two cards after the footage, in stage coordinates. */
|
||||
export const Cards: React.FC<{ at: number }> = ({ at }) => (
|
||||
<>
|
||||
<Card at={at} x={100} title="中文显示统一" sub="Windows 上同一个词不再一半粗一半细;界面残留的英文改为中文">
|
||||
<Glyphs at={at} />
|
||||
</Card>
|
||||
<Card at={at + BEAT} x={880} title="macOS 已签名并公证" sub="Developer ID 签名、Apple 公证并钉票,打开时不再提示“无法验证开发者”">
|
||||
<Notarized at={at + BEAT} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AbsoluteFill } from 'remotion';
|
||||
import { Caption } from '../design/Caption';
|
||||
import { KeyCap } from '../design/KeyCap';
|
||||
import { Stage } from '../design/Stage';
|
||||
import { Clip, type Cut, type Shot } from '../footage/Clip';
|
||||
import { frameOf, Sounds } from '../footage/Sounds';
|
||||
|
||||
const CUTS: Cut[] = [{ take: 'cells', from: 3.1, to: 13.1 }];
|
||||
const SHOTS: Shot[] = [
|
||||
{ f: 0, z: 1, x: 1024, y: 576 },
|
||||
{ f: 60, z: 1.25, x: 1190, y: 440 },
|
||||
{ f: 110, z: 1.75, x: 1190, y: 420 },
|
||||
{ f: 470, z: 1.75, x: 1190, y: 420 },
|
||||
{ f: 560, z: 1.2, x: 1200, y: 460 },
|
||||
];
|
||||
|
||||
// One badge per key, held until the next one.
|
||||
const KEYS: { t: number; keys: string[] }[] = [
|
||||
{ t: 4.26, keys: ['点击'] },
|
||||
{ t: 5.73, keys: ['Tab'] },
|
||||
{ t: 6.88, keys: ['↓'] },
|
||||
{ t: 7.94, keys: ['End'] },
|
||||
{ t: 8.29, keys: ['⌫'] },
|
||||
{ t: 10.63, keys: ['Enter'] },
|
||||
];
|
||||
|
||||
export const Cells: React.FC = () => {
|
||||
const at = (t: number) => frameOf(CUTS, t);
|
||||
const backs = [8.29, 8.5, 8.71].map(at);
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Stage
|
||||
enter={{ from: 'cell' }}
|
||||
exit={{ to: 'ely-doc' }}
|
||||
heading={{ title: '表格在格内编辑', sub: '点哪格开哪格,Tab 到下一格,回车写回', app: 'ely-mark' }}
|
||||
>
|
||||
<Clip cuts={CUTS} shots={SHOTS} />
|
||||
</Stage>
|
||||
{KEYS.map((k, i) => {
|
||||
const from = at(k.t) - 12;
|
||||
const to = i + 1 < KEYS.length ? at(KEYS[i + 1].t) - 12 : at(k.t) + 70;
|
||||
return <KeyCap key={k.t} keys={k.keys} from={from} to={Math.min(to, from + 110)} press={k.keys[0] === '⌫' ? backs : [at(k.t)]} />;
|
||||
})}
|
||||
<Caption text="格内显示原文:**粗体** 带着记号" from={at(4.5)} to={at(5.6)} />
|
||||
<Caption text="Tab、方向键在格间移动" from={at(5.9)} to={at(7.8)} />
|
||||
<Caption text="回车写回,一步撤销" from={at(10.7)} to={at(12.2)} />
|
||||
<Sounds cuts={CUTS} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
|
||||
import { COLOR, EASE, FONT, ICON, SECTION } from '../theme';
|
||||
import { type ShapeId, SHAPES, boundaryPulse, shapePath } from '../design/morph';
|
||||
import { ShapeView, type Details, mix, rgba } from '../design/Shape';
|
||||
|
||||
// Each icon lands on a beat; the dot breathes for the first two beats.
|
||||
const STEPS: { shape: ShapeId; land: number; dur: number }[] = [
|
||||
{ shape: 'ely-doc', land: 75, dur: 30 },
|
||||
{ shape: 'ely-show', land: 112.5, dur: 24 },
|
||||
{ shape: 'ely-table', land: 150, dur: 24 },
|
||||
{ shape: 'ely-tree', land: 187.5, dur: 24 },
|
||||
{ shape: 'ely-pdf', land: 225, dur: 24 },
|
||||
{ shape: 'ely-mark', land: 262.5, dur: 24 },
|
||||
];
|
||||
|
||||
const END = SECTION.intro.duration;
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
const ramp = (f: number, a: number, b: number, ease = EASE.out) => interpolate(f, [a, b], [0, 1], { ...clamp, easing: ease });
|
||||
|
||||
const iconAt = (f: number) => {
|
||||
// Before the wordmark the icon sits a little higher, at the frame's centre.
|
||||
const cy = ICON.y - 40 * (1 - ramp(f, 150, 240, EASE.inOut));
|
||||
const pulse = boundaryPulse(f - END);
|
||||
const index = STEPS.findLastIndex((s) => f >= s.land - s.dur);
|
||||
if (index < 0) {
|
||||
const appear = ramp(f, 4, 38);
|
||||
const breath = 0.16 * Math.exp(-(((f - 37.5) / 8) ** 2));
|
||||
const k = Math.max(0.001, appear * (1 + breath));
|
||||
return { d: shapePath('dot', 'stage', 0, k, ICON.x, cy), color: SHAPES.dot.color, k, cy, details: [] as Details[] };
|
||||
}
|
||||
const step = STEPS[index];
|
||||
const from: ShapeId = index === 0 ? 'dot' : STEPS[index - 1].shape;
|
||||
const start = step.land - step.dur;
|
||||
const t = EASE.inOut(Math.min(1, (f - start) / step.dur));
|
||||
const swell = STEPS.slice(0, -1).reduce((sum, s) => sum + 0.03 * Math.exp(-(((f - s.land) / 6) ** 2)), 0);
|
||||
const k = (1 - 0.07 * Math.sin(Math.PI * t)) * (1 + swell + pulse);
|
||||
const d = t >= 1 ? shapePath(step.shape, 'stage', 0, k, ICON.x, cy) : shapePath(from, step.shape, t, k, ICON.x, cy);
|
||||
const details: Details[] = [
|
||||
{ shape: from, opacity: 1 - ramp(f, start, start + 6, EASE.inOut) },
|
||||
{ shape: step.shape, opacity: ramp(f, step.land - 3, step.land + 7, EASE.inOut) },
|
||||
];
|
||||
return { d, color: mix(SHAPES[from].color, SHAPES[step.shape].color, t), k, cy, details };
|
||||
};
|
||||
|
||||
const Letters: React.FC<{ text: string; start: number; stagger: number; rise: number; blur: number }> = ({ text, start, stagger, rise, blur }) => {
|
||||
const f = useCurrentFrame();
|
||||
return (
|
||||
<>
|
||||
{Array.from(text).map((char, i) => {
|
||||
const p = ramp(f, start + i * stagger, start + i * stagger + 32);
|
||||
const b = (1 - p) * blur;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
whiteSpace: 'pre',
|
||||
opacity: p,
|
||||
transform: `translateY(${((1 - p) * rise).toFixed(2)}px)`,
|
||||
filter: b > 0.05 ? `blur(${b.toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const WORD: React.CSSProperties = {
|
||||
fontFamily: FONT.latin,
|
||||
fontSize: 92,
|
||||
lineHeight: '100px',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 30,
|
||||
};
|
||||
|
||||
const Wordmark: React.FC<{ reveal: boolean; color?: string }> = ({ reveal, color }) => {
|
||||
const word = (text: string, start: number) =>
|
||||
reveal ? <Letters text={text} start={start} stagger={2.2} rise={30} blur={10} /> : text.split('').map((c, i) => <span key={i} style={{ display: 'inline-block', whiteSpace: 'pre' }}>{c}</span>);
|
||||
return (
|
||||
<div style={WORD}>
|
||||
<span style={{ fontWeight: 600, letterSpacing: '-0.025em', color: color ?? '#ECEAE6' }}>{word('Ely Office', 150)}</span>
|
||||
<span style={{ fontWeight: 300, letterSpacing: '0.01em', color: color ?? COLOR.muted }}>{word('0.1.5', 172)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Intro: React.FC = () => {
|
||||
const f = useCurrentFrame();
|
||||
const icon = iconAt(f);
|
||||
const dawn = 1 - ramp(f, 0, 50, EASE.inOut);
|
||||
const ripple = ramp(f, 37.5, 37.5 + 50);
|
||||
const leaveWord = ramp(f, 272, 294, EASE.in);
|
||||
const leaveLine = ramp(f, 264, 286, EASE.in);
|
||||
const rule = ramp(f, 182, 210) * (1 - ramp(f, 258, 278, EASE.inOut));
|
||||
const sweep = ramp(f, 216, 262, EASE.inOut);
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{f < 92 ? (
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<circle
|
||||
cx={ICON.x}
|
||||
cy={icon.cy}
|
||||
r={20 + 150 * ripple}
|
||||
fill="none"
|
||||
stroke={rgba(SHAPES.dot.color, 0.5 * (1 - ripple) * Math.min(1, ripple * 8))}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
<ShapeView d={icon.d} color={icon.color} cx={ICON.x} cy={icon.cy} k={icon.k} fill={1} lift={0} details={icon.details} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: 1920,
|
||||
top: 232,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
opacity: 1 - leaveWord,
|
||||
transform: `translateY(${(-20 * leaveWord).toFixed(2)}px)`,
|
||||
filter: leaveWord > 0.01 ? `blur(${(8 * leaveWord).toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Wordmark reveal />
|
||||
{sweep > 0 && sweep < 1 ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.9,
|
||||
maskImage: 'linear-gradient(100deg, transparent 0%, transparent 44%, black 50%, transparent 56%, transparent 100%)',
|
||||
maskSize: '320% 100%',
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: `${(100 - sweep * 100).toFixed(2)}% 0%`,
|
||||
}}
|
||||
>
|
||||
<Wordmark reveal={false} color="#FFFFFF" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', left: 960 - 28, top: 354, width: 56, height: 2, borderRadius: 1, background: COLOR.accent, transform: `scaleX(${rule.toFixed(4)})` }} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: 1920,
|
||||
top: 372,
|
||||
textAlign: 'center',
|
||||
fontFamily: FONT.sans,
|
||||
fontWeight: 300,
|
||||
fontSize: 31,
|
||||
lineHeight: '40px',
|
||||
letterSpacing: '0.34em',
|
||||
paddingLeft: '0.34em',
|
||||
boxSizing: 'border-box',
|
||||
color: '#BDBAB5',
|
||||
opacity: 1 - leaveLine,
|
||||
transform: `translateY(${(-12 * leaveLine).toFixed(2)}px)`,
|
||||
filter: leaveLine > 0.01 ? `blur(${(6 * leaveLine).toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
<Letters text="更顺手的一次更新" start={192} stagger={3} rise={14} blur={8} />
|
||||
</div>
|
||||
{dawn > 0.001 ? <AbsoluteFill style={{ background: '#09090a', opacity: dawn }} /> : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AbsoluteFill } from 'remotion';
|
||||
import { Caption } from '../design/Caption';
|
||||
import { KeyCap } from '../design/KeyCap';
|
||||
import { Stage } from '../design/Stage';
|
||||
import { Clip, type Cut, type Shot } from '../footage/Clip';
|
||||
import { frameOf, Sounds } from '../footage/Sounds';
|
||||
import { Triangle } from '../footage/Triangle';
|
||||
|
||||
// The crossing over 插入 plays at half speed.
|
||||
const CROSS = { from: 7.09, to: 8.04 };
|
||||
const CUTS: Cut[] = [
|
||||
{ take: 'menus', from: 3.6, to: 7.0 },
|
||||
{ take: 'menus', from: 7.0, to: 8.15, rate: 0.5 },
|
||||
{ take: 'menus', from: 8.15, to: 12.45 },
|
||||
];
|
||||
const SHOTS: Shot[] = [
|
||||
{ f: 0, z: 1, x: 1024, y: 576 },
|
||||
{ f: 40, z: 1.15, x: 1120, y: 560 },
|
||||
{ f: 115, z: 1.45, x: 1380, y: 720 },
|
||||
{ f: 355, z: 1.45, x: 1380, y: 720 },
|
||||
{ f: 420, z: 1.25, x: 1150, y: 600 },
|
||||
{ f: 560, z: 1, x: 1024, y: 576 },
|
||||
];
|
||||
|
||||
export const Menus: React.FC = () => {
|
||||
const right = frameOf(CUTS, 4.43);
|
||||
const cross = frameOf(CUTS, CROSS.from);
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Stage
|
||||
enter={{ from: 'ely-mark' }}
|
||||
exit={{ to: 'cell' }}
|
||||
heading={{ title: '二级菜单更好选', sub: '斜着移向子菜单,路过的项不再抢先弹出', app: 'ely-mark' }}
|
||||
>
|
||||
<Clip
|
||||
cuts={CUTS}
|
||||
shots={SHOTS}
|
||||
overlay={(_, t) => <Triangle take="menus" t={t} {...CROSS} passed={[654, 696]} note="路过「插入」,不抢走" />}
|
||||
/>
|
||||
</Stage>
|
||||
<KeyCap keys={['右键']} from={right - 20} to={right + 75} press={[right]} />
|
||||
<Caption text="0.5× · 安全三角:朝子菜单走,三角内的项不切换" from={cross - 30} to={frameOf(CUTS, 8.6)} />
|
||||
<Sounds cuts={CUTS} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
|
||||
import { Caption } from '../design/Caption';
|
||||
import { KeyCap } from '../design/KeyCap';
|
||||
import { Stage } from '../design/Stage';
|
||||
import { Clip, type Cut, type Shot } from '../footage/Clip';
|
||||
import { frameOf, Sounds } from '../footage/Sounds';
|
||||
import { EASE } from '../theme';
|
||||
import { Cards } from './Cards';
|
||||
|
||||
const CUTS: Cut[] = [{ take: 'more', from: 2.6, to: 9.0 }];
|
||||
const SHOTS: Shot[] = [
|
||||
{ f: 0, z: 1, x: 1024, y: 576 },
|
||||
{ f: 40, z: 1.4, x: 731, y: 360 },
|
||||
{ f: 190, z: 1.4, x: 731, y: 360 },
|
||||
{ f: 240, z: 1.5, x: 683, y: 780 },
|
||||
{ f: 296, z: 1.5, x: 683, y: 780 },
|
||||
{ f: 336, z: 1.5, x: 683, y: 384 },
|
||||
];
|
||||
// The cards replace the footage from here.
|
||||
const CARDS = 372;
|
||||
|
||||
export const More: React.FC = () => {
|
||||
const f = useCurrentFrame();
|
||||
const clear = frameOf(CUTS, 7.38);
|
||||
const away = interpolate(f, [CARDS - 20, CARDS + 10], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: EASE.inOut });
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Stage
|
||||
enter={{ from: 'ely-show' }}
|
||||
exit={{ to: 'dot' }}
|
||||
heading={[
|
||||
{ title: '再开文件,进同一个窗口', sub: '应用已开着时,文件直接开成新标签;侧栏可清除最近文件', app: 'ely-mark' },
|
||||
{ title: '还有', sub: '中文显示统一 · macOS 安装包已公证', at: CARDS },
|
||||
]}
|
||||
>
|
||||
{f < CARDS + 10 ? (
|
||||
<AbsoluteFill style={{ opacity: away, transform: `scale(${0.97 + 0.03 * away})` }}>
|
||||
<Clip cuts={CUTS} shots={SHOTS} />
|
||||
</AbsoluteFill>
|
||||
) : null}
|
||||
{f >= CARDS - 20 ? <Cards at={CARDS} /> : null}
|
||||
</Stage>
|
||||
<Caption text="第二次打开:交给已开着的那份,开成新标签" from={frameOf(CUTS, 3.4)} to={frameOf(CUTS, 6.7)} />
|
||||
<KeyCap keys={['点击']} from={clear - 20} to={clear + 60} press={[clear]} />
|
||||
<Caption text="清除最近文件,重启后仍是空的" from={clear + 10} to={CARDS - 10} />
|
||||
<Sounds cuts={CUTS} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
|
||||
import { APPS, BEAT, COLOR, EASE, FONT, ICON } from '../theme';
|
||||
import { SHAPES, boundaryPulse, shapePath } from '../design/morph';
|
||||
import { ShapeView, mix, rgba } from '../design/Shape';
|
||||
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
const ramp = (f: number, a: number, b: number, ease = EASE.out) => interpolate(f, [a, b], [0, 1], { ...clamp, easing: ease });
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
// Row after the bloom, and the tighter row it gathers into above the wordmark.
|
||||
const ROW = { gap: 212, y: 470, k: 0.62 };
|
||||
const GATHER = { gap: 128, y: 384, k: 0.38, at: 5 * BEAT };
|
||||
const BLOOM = { at: BEAT, every: BEAT / 2, dur: 26 };
|
||||
const SPLIT = { at: 16, dur: 40 };
|
||||
const FADE = { from: 390, to: 446 };
|
||||
|
||||
const Ring: React.FC<{ start: number; reach: number; width: number }> = ({ start, reach, width }) => {
|
||||
const f = useCurrentFrame();
|
||||
const p = ramp(f, start, start + 64);
|
||||
if (p <= 0 || p >= 1) return null;
|
||||
return (
|
||||
<circle
|
||||
cx={ICON.x}
|
||||
cy={ICON.y}
|
||||
r={18 + reach * p}
|
||||
fill="none"
|
||||
stroke={rgba(SHAPES.dot.color, 0.55 * (1 - p) * Math.min(1, p * 10))}
|
||||
strokeWidth={width}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AppIcon: React.FC<{ index: number }> = ({ index }) => {
|
||||
const f = useCurrentFrame();
|
||||
const app = APPS[index];
|
||||
const offset = index - (APPS.length - 1) / 2;
|
||||
const travel = Math.min(1, Math.max(0, (f - SPLIT.at) / SPLIT.dur));
|
||||
const bloomAt = BLOOM.at + index * BLOOM.every;
|
||||
const t = EASE.inOut(Math.min(1, Math.max(0, (f - bloomAt) / BLOOM.dur)));
|
||||
const land = bloomAt + BLOOM.dur;
|
||||
const gather = ramp(f, GATHER.at + index * 2, GATHER.at + index * 2 + 44, EASE.inOut);
|
||||
|
||||
// Split: the dots rise first, then fan out, like a fountain.
|
||||
// Both start from rest; the rise leads the spread.
|
||||
const splitX = lerp(ICON.x, ICON.x + offset * ROW.gap, EASE.inOut(Math.max(0, (travel - 0.15) / 0.85)));
|
||||
const splitY = lerp(ICON.y, ROW.y, EASE.inOut(Math.min(1, travel / 0.7)));
|
||||
const cx = lerp(splitX, ICON.x + offset * GATHER.gap, gather);
|
||||
const cy = lerp(splitY, GATHER.y, gather);
|
||||
|
||||
const swell = 0.035 * Math.exp(-(((f - land) / 6) ** 2));
|
||||
const base = lerp(lerp(1 + boundaryPulse(f), ROW.k, EASE.inOut(travel)), GATHER.k, gather);
|
||||
const k = base * (1 - 0.08 * Math.sin(Math.PI * t)) * (1 + swell);
|
||||
const d = t >= 1 ? shapePath(app.id, 'stage', 0, k, cx, cy) : shapePath('dot', app.id, t, k, cx, cy);
|
||||
const color = mix(SHAPES.dot.color, app.color, Math.max(EASE.inOut(travel) * 0.6, t));
|
||||
return (
|
||||
<ShapeView
|
||||
d={d}
|
||||
color={color}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
k={k}
|
||||
fill={1}
|
||||
lift={0}
|
||||
presence={lerp(1 / APPS.length, 1, EASE.inOut(travel))}
|
||||
details={[{ shape: app.id, opacity: ramp(f, land - 4, land + 8, EASE.inOut) }]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Line: React.FC<{ text: string; start: number; stagger: number; style: React.CSSProperties }> = ({ text, start, stagger, style }) => {
|
||||
const f = useCurrentFrame();
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', whiteSpace: 'nowrap', ...style }}>
|
||||
{Array.from(text).map((char, i) => {
|
||||
const p = ramp(f, start + i * stagger, start + i * stagger + 30);
|
||||
const b = (1 - p) * 8;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
whiteSpace: 'pre',
|
||||
opacity: p,
|
||||
transform: `translateY(${((1 - p) * 18).toFixed(2)}px)`,
|
||||
filter: b > 0.05 ? `blur(${b.toFixed(2)}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Outro: React.FC = () => {
|
||||
const f = useCurrentFrame();
|
||||
const fade = 1 - ramp(f, FADE.from, FADE.to, EASE.inOut);
|
||||
const push = 1 + 0.022 * ramp(f, GATHER.at, FADE.to, EASE.inOut);
|
||||
const glow = ramp(f, 0, 18) * (1 - ramp(f, 18, 80, EASE.inOut));
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity: fade, transform: `scale(${push.toFixed(5)})`, transformOrigin: '960px 520px' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: ICON.x - 360,
|
||||
top: ICON.y - 360,
|
||||
width: 720,
|
||||
height: 720,
|
||||
background: `radial-gradient(closest-side, ${rgba(COLOR.accent, 0.22 * glow)}, ${rgba(COLOR.accent, 0)})`,
|
||||
}}
|
||||
/>
|
||||
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
||||
<Ring start={0} reach={300} width={1.6} />
|
||||
<Ring start={9} reach={460} width={1} />
|
||||
</svg>
|
||||
{f < SPLIT.at ? (
|
||||
<ShapeView
|
||||
d={shapePath('dot', 'stage', 0, 1 + boundaryPulse(f), ICON.x, ICON.y)}
|
||||
color={SHAPES.dot.color}
|
||||
cx={ICON.x}
|
||||
cy={ICON.y}
|
||||
k={1 + boundaryPulse(f)}
|
||||
fill={1}
|
||||
lift={0}
|
||||
/>
|
||||
) : (
|
||||
APPS.map((app, i) => <AppIcon key={app.id} index={i} />)
|
||||
)}
|
||||
<div style={{ position: 'absolute', left: 0, width: 1920, top: 478 }}>
|
||||
<Line
|
||||
text="Ely Office 0.1.5"
|
||||
start={6 * BEAT}
|
||||
stagger={1.8}
|
||||
style={{ fontFamily: FONT.latin, fontWeight: 600, fontSize: 84, lineHeight: '96px', letterSpacing: '-0.02em', color: '#ECEAE6' }}
|
||||
/>
|
||||
<Line
|
||||
text="Windows · macOS · Linux"
|
||||
start={7 * BEAT}
|
||||
stagger={1}
|
||||
style={{ marginTop: 22, fontFamily: FONT.latin, fontWeight: 400, fontSize: 26, lineHeight: '34px', letterSpacing: '0.06em', color: COLOR.muted }}
|
||||
/>
|
||||
<Line
|
||||
text="git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice"
|
||||
start={8 * BEAT}
|
||||
stagger={0.6}
|
||||
style={{ marginTop: 18, fontFamily: FONT.mono, fontWeight: 400, fontSize: 22, lineHeight: '30px', letterSpacing: '0.01em', color: '#86A9E6' }}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AbsoluteFill } from 'remotion';
|
||||
import { Caption } from '../design/Caption';
|
||||
import { KeyCap } from '../design/KeyCap';
|
||||
import { Stage } from '../design/Stage';
|
||||
import { Clip, lay, type Cut, type Shot } from '../footage/Clip';
|
||||
import { frameOf, Sounds } from '../footage/Sounds';
|
||||
import { Triangle } from '../footage/Triangle';
|
||||
|
||||
const CUTS: Cut[] = [
|
||||
{ take: 'doc', from: 3.9, to: 9.5 },
|
||||
{ take: 'doc', from: 11.75, to: 13.85, fade: 12 },
|
||||
{ take: 'table', from: 3.9, to: 7.4, fade: 14 },
|
||||
{ take: 'show', from: 3.1, to: 7.57, fade: 14 },
|
||||
];
|
||||
const [, drag, table, show] = lay(CUTS);
|
||||
const SHOTS: Shot[] = [
|
||||
{ f: 0, z: 1, x: 1024, y: 576 },
|
||||
{ f: 45, z: 1.2, x: 1100, y: 620 },
|
||||
{ f: 120, z: 1.45, x: 1260, y: 800 },
|
||||
{ f: 290, z: 1.45, x: 1260, y: 800 },
|
||||
{ f: drag.start, z: 1.35, x: 920, y: 640 },
|
||||
{ f: drag.start + 28, z: 1.65, x: 990, y: 620 },
|
||||
{ f: table.start - 6, z: 1.65, x: 990, y: 620 },
|
||||
{ f: table.start + 34, z: 1.45, x: 560, y: 380 },
|
||||
{ f: show.start - 6, z: 1.45, x: 560, y: 380 },
|
||||
{ f: show.start + 40, z: 1.5, x: 430, y: 620 },
|
||||
{ f: show.start + 160, z: 1.5, x: 430, y: 620 },
|
||||
{ f: show.start + 230, z: 1.15, x: 900, y: 560 },
|
||||
];
|
||||
|
||||
export const RightClick: React.FC = () => {
|
||||
const docRight = frameOf(CUTS.slice(0, 1), 4.95);
|
||||
const tableRight = table.start + Math.round((4.25 - table.from) * 60);
|
||||
const wheel = show.start + Math.round((3.42 - show.from) * 60);
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Stage
|
||||
enter={{ from: 'ely-doc' }}
|
||||
exit={{ to: 'ely-show' }}
|
||||
heading={[
|
||||
{ title: '右键照 Word', sub: '正文、表格、图片各有对应的菜单;拖动边线调列宽行高', app: 'ely-doc' },
|
||||
{ title: '右键照 Excel', sub: '单元格、行列表头、图形各有对应的菜单', app: 'ely-table', at: table.start },
|
||||
{ title: '右键照 PowerPoint', sub: '缩略图条可用滚轮滚动,表格边线可以拖动', app: 'ely-show', at: show.start },
|
||||
]}
|
||||
>
|
||||
<Clip
|
||||
cuts={CUTS}
|
||||
shots={SHOTS}
|
||||
overlay={(cut, t) =>
|
||||
cut.take === 'doc' ? (
|
||||
<Triangle take="doc" t={t} from={7.29} to={8.19} passed={[742, 784]} note="路过「删除」,不抢走" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Stage>
|
||||
<KeyCap keys={['右键']} from={docRight - 20} to={docRight + 75} press={[docRight]} />
|
||||
<Caption text="拖动表格边线,调整列宽" from={drag.start + 10} to={table.start - 4} />
|
||||
<KeyCap keys={['右键']} from={tableRight - 20} to={tableRight + 75} press={[tableRight]} />
|
||||
<Caption text="表头右键:插入、删除、列宽、隐藏" from={tableRight + 30} to={show.start - 4} />
|
||||
<KeyCap keys={['滚轮']} from={wheel - 16} to={wheel + 170} press={[wheel, wheel + 96]} />
|
||||
<Caption text="缩略图条可以用滚轮滚动" from={wheel + 10} to={wheel + 190} />
|
||||
<Sounds cuts={CUTS} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Easing } from 'remotion';
|
||||
|
||||
export const WIDTH = 1920;
|
||||
export const HEIGHT = 1080;
|
||||
export const FPS = 60;
|
||||
export const TOTAL = 3600;
|
||||
|
||||
// 96 BPM, 4/4.
|
||||
export const BEAT = 37.5;
|
||||
export const BAR = 150;
|
||||
export const beat = (n: number) => Math.round(n * BEAT);
|
||||
export const bar = (n: number) => n * BAR;
|
||||
|
||||
// Section start frames (bars 1, 3, 7, 11, 17, 22) and lengths.
|
||||
export const SECTION = {
|
||||
intro: { from: 0, duration: 300 },
|
||||
menus: { from: 300, duration: 600 },
|
||||
cells: { from: 900, duration: 600 },
|
||||
rightClick: { from: 1500, duration: 900 },
|
||||
more: { from: 2400, duration: 750 },
|
||||
outro: { from: 3150, duration: 450 },
|
||||
} as const;
|
||||
|
||||
export const COLOR = {
|
||||
bgInner: '#252525',
|
||||
bgOuter: '#1c1c1e',
|
||||
ink: '#F4F4F2',
|
||||
muted: '#A8A6A3',
|
||||
faint: '#6E6C6A',
|
||||
accent: '#3D7FE5',
|
||||
panel: '#1f1f21',
|
||||
hairline: 'rgba(255,255,255,0.08)',
|
||||
} as const;
|
||||
|
||||
export type AppId = 'ely-doc' | 'ely-table' | 'ely-show' | 'ely-pdf' | 'ely-tree' | 'ely-mark';
|
||||
|
||||
export const APPS: { id: AppId; name: string; color: string }[] = [
|
||||
{ id: 'ely-doc', name: 'ElyDoc', color: '#3D7FE5' },
|
||||
{ id: 'ely-table', name: 'ElyTable', color: '#2E9E5B' },
|
||||
{ id: 'ely-show', name: 'ElyShow', color: '#F07B2A' },
|
||||
{ id: 'ely-pdf', name: 'ElyPDF', color: '#E0344F' },
|
||||
{ id: 'ely-tree', name: 'ElyTree', color: '#7E57C2' },
|
||||
{ id: 'ely-mark', name: 'ElyMark', color: '#3E4A5C' },
|
||||
];
|
||||
|
||||
export const appOf = (id: AppId) => APPS.find((a) => a.id === id)!;
|
||||
|
||||
export const STAGE = { x: 120, y: 118, w: 1680, h: 945, r: 18 } as const;
|
||||
export const STAGE_CENTER = { x: STAGE.x + STAGE.w / 2, y: STAGE.y + STAGE.h / 2 };
|
||||
// Recordings are 2048×1152; this maps them onto the stage width.
|
||||
export const FOOTAGE = { w: 2048, h: 1152, scale: STAGE.w / 2048 } as const;
|
||||
|
||||
// Where boundary icons rest, and the size of one icon-grid unit (Papirus 192 grid).
|
||||
export const ICON = { x: 960, y: 590, unit: 1.375 } as const;
|
||||
|
||||
// Section-local morph frames. Boundary icons rest for `hold` frames on each side
|
||||
// of the cut (one beat in total, centred on the downbeat); the shape morph takes
|
||||
// `morph` frames. The stage is fully open from `ENTER_END`, and starts closing at
|
||||
// `duration - EXIT_LEN`.
|
||||
export const MORPH = { hold: 18, morph: 42 } as const;
|
||||
export const ENTER_END = MORPH.hold + MORPH.morph;
|
||||
export const EXIT_LEN = MORPH.hold + MORPH.morph;
|
||||
|
||||
// Headings enter on beat 1 of a section, the sub line half a beat later, and
|
||||
// start to leave `out` frames before the section ends (gone before it closes).
|
||||
export const HEADING = { in: beat(1), sub: beat(0.5), out: 92 } as const;
|
||||
|
||||
export const EASE = {
|
||||
out: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
inOut: Easing.bezier(0.65, 0, 0.35, 1),
|
||||
in: Easing.bezier(0.55, 0, 0.75, 0.06),
|
||||
// Starts from rest, decelerates long: for growth that should feel heavy.
|
||||
bloom: Easing.bezier(0.5, 0, 0.1, 1),
|
||||
// Gentle start, soft landing: for collapses that must come to rest.
|
||||
land: Easing.bezier(0.6, 0, 0.2, 1),
|
||||
} as const;
|
||||
|
||||
export const SPRING = { damping: 200 } as const;
|
||||
|
||||
export const FONT = {
|
||||
sans: '"Noto Sans SC", "Inter", sans-serif',
|
||||
latin: '"Inter", "Noto Sans SC", sans-serif',
|
||||
mono: '"JetBrains Mono", monospace',
|
||||
} as const;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": [],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -157,4 +157,5 @@ P0 次序:W-01、K-01、L-01、O-01、W-02、L-02、W-03、L-03、W-04、L-04
|
||||
- [ ] P3-14 Windows:ElyTree 九个单测恒失败:五个比对 Git 写出的文字,本机 Git 的 `core.autocrlf` 写成 CRLF(测试仓库未设 `core.autocrlf false`);其余写 Windows 不许的文件名(os error 123)。代码行为未见错,未修
|
||||
- [ ] P3-15 Windows:全量并行单测中偶有一个 LOK worker 读到的 BuildId 为 `development`(`desktop/source/app/app.cxx` 读不到 bootstrap 的 buildid 时的默认值),握手照规则拒绝;同一时刻别的 worker 读到正确的 8221e31,内核与 worker 都未重建,单独重跑即过(2026-10-14 ElyDoc `review::tests::one_unreadable_time_spares_the_other_notes`,三次重跑皆过)。疑为并发时读 `version.ini` 偶发失败,原因未查
|
||||
- [ ] P3-16 发布:`ci.sh` 在三端各自调用 `packaging/source.sh` 并上传同名的源码包与上游源码包,发布里同名文件出现三份(Gitea 允许重名);上游源码包打的是各机的 `engines/mupdf-src`,含构建留下的文件,三份大小不同(v0.1.5:Linux 108 MB,macOS 161 MB)。v0.1.5 手动删去后到的两份,留 Linux 的一份。应只出一份,且上游包只含源码
|
||||
- [ ] P3-17 ElyTable(Windows,0.1.5 安装版,2026-10-15 录视频时见):A1 选中后按两次 Ctrl+= 缩放到 150%,网格停在半滚动处:第 5 行被表头截去一半,第 1–4 行看不见,Ctrl+Home 也回不到 A1。应保持首行与首列在视图内(同 R2.7 的按键缩放)
|
||||
|
||||