Video: voxel titles, new opening with world turn, app punch-ins and callouts, six-screen montage, props sequence; site carries the new film

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 00:36:35 -04:00
1 parent 70cd57bd5d
commit 4d8bd05633
37 files changed
+2672 -165

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+13 -15
View File
@@ -1,19 +1,17 @@
# 分镜
112 BPM,4/4,42 小节,共 90.000 秒。一拍 0.5357 秒,一小节 2.1429 秒。所有切点落在拍上。
112 BPM,4/4,42 小节,共 90.000 秒。一拍 0.5357 秒,一小节 2.1429 秒。所有切点与落位都在拍上。画面不留空:每一拍都有东西落下、移动或切换。
| 小节 | 秒 | 画面 | 音乐 |
| --- | --- | --- | --- |
| 1–4 | 0.00–8.57 | 黑场;一块地砖按拍落下,网格逐格长成一张桌面 | 单音拨弦与节拍器式轻点 |
| 5–8 | 8.57–17.14 | 六座积木建筑按拍升起(Doc、Table、Show、PDF、Tree、Mark),道路连通;字幕 Ely Office | 加入低鼓,和声进入 |
| 9–12 | 17.14–25.71 | ElyDoc:镜头推近其屏幕面,转为全屏录屏:打开文档、键入、缩放 | 主段 A |
| 13–16 | 25.71–34.29 | ElyTable:录屏:公式、筛选、图表 | 主段 A |
| 17–20 | 34.29–42.86 | ElyShow:录屏:幻灯片、动画预览、放映 | 主段 A |
| 21–24 | 42.86–51.43 | ElyPDF:录屏:翻页、批注、签名 | 主段 B |
| 25–28 | 51.43–60.00 | ElyTree:录屏:差异、暂存、提交、历史图 | 主段 B |
| 29–32 | 60.00–68.57 | ElyMark:录屏:可视编辑、公式、Mermaid、切到源码 | 主段 B |
| 33–36 | 68.57–77.14 | 回到模型:文件块沿道路从 Mark 走到 Tree、从 Doc 导出到 PDF;整座模型深浅色切换 | 间奏,减配 |
| 37–40 | 77.14–85.71 | 三件小道具依次落位:拔下的网线(离线)、打开的盒子(开源)、文件夹(文件在本地) | 回升 |
| 41–42 | 85.71–90.00 | 模型合拢;Ely Office 0.1.0 · macOS · 下载地址 | 终止音,余音收尾 |
| 小节 | 秒 | 画面 |
| --- | --- | --- |
| 1–2 | 0.00–4.29 | 黑场(#252525)。体素字 E、L、Y 由立方体按十六分音符落下拼成,镜头贴近;第 2 小节末字母散成地砖 |
| 3–4 | 4.29–8.57 | 地砖飞落成桌面网格,加速填满,镜头拉远,桌面占满画面;背景由深转浅,落在第 4 小节末的强拍上 |
| 5–6 | 8.57–12.86 | 六座建筑按拍升起,道路铺开;第 6 小节强拍起整座模型绕竖轴转 90°(正交投影,平行线不变) |
| 7–8 | 12.86–17.14 | 体素字“Ely Office”在桌前立起,下方细字“六个桌面应用 / Six desktop apps”;模型转回原位 |
| 9–32 | 17.14–68.57 | 六个应用各 4 小节(Doc、Table、Show、PDF、Tree、Mark)。第 1 小节:镜头推向该建筑,体素应用名在建筑旁搭起,屏幕面上播放录屏,最后一拍屏幕连续展平为全画面。第 2–4 小节:真实录屏,关键动作在拍上 2 倍推近,等轴标注积木(如“=SUM”“Fly In”“Sign”)从画面边缘升起指向动作;录屏四周是浅色等轴网格,格子随拍轻跳;底部双语字幕 |
| 33–34 | 68.57–72.86 | 群像:六块屏幕同时立在模型上,各播自己的录屏,整座模型缓转 90° |
| 35–36 | 72.86–77.14 | 文件块沿道路逐格前行;第 36 小节强拍起深色从台灯处按距离逐块扩散 |
| 37–40 | 77.14–85.71 | 三件道具各一小节推近并逐格搭起,带双语字幕:断网照常工作;开源,AGPL;文件留在你自己的文件夹里;第 40 小节拉回全景 |
| 41–42 | 85.71–90.00 | 体素字“Ely Office 0.1.0”;细字“macOS 现已推出 · Windows 与 Linux 即将推出”与下载地址;结束于 90.000 秒 |
字幕中英并列,白灰黑为主,一种强调色。
白灰黑为主,一种强调色 #3d7fe5。无透视、无投影、无渐变。
+1 -1
View File
@@ -15,7 +15,7 @@ const { x, y, w, h } = plan.window;
const css = `
html, body { margin: 0; width: 1920px; height: 1080px; background: transparent; }
.hole { position: absolute; left: ${x}px; top: ${y}px; width: ${w}px; height: ${h}px; border-radius: 10px;
box-shadow: 0 0 0 1px rgba(0,0,0,.07), 0 18px 48px rgba(0,0,0,.10), 0 2px 6px rgba(0,0,0,.05), 0 0 0 3000px #fbfbfa; }
box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 0 0 3000px #fbfbfa; }
.cap { position: absolute; left: ${x}px; right: ${1920 - x - w}px; top: ${y + h}px; bottom: 0;
display: flex; align-items: center; gap: 28px; color: #1d1d1f; -webkit-font-smoothing: antialiased; }
.name { font: 700 30px/1 "SF Pro Display", system-ui; letter-spacing: -0.01em; color: #3d7fe5; min-width: 150px; }
+14 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Pulls stills at boundaries and mid-sections for review.
# Pulls stills at boundaries and fixes, plus contact sheets.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
film="$here/../out/ElyOffice-launch.mp4"
@@ -7,9 +7,18 @@ out="$here/../out/edit/check"
rm -rf "$out"
mkdir -p "$out"
ffprobe -v error -show_entries format=duration:stream=codec_name,profile,width,height,r_frame_rate,pix_fmt -of compact "$film"
# Frames: scene ends, plate mids, cuts, inner cuts, section ends.
frames=(0 120 257 258 400 514 515 545 578 579 640 700 771 772 805 835 836 870 899 900 960 1028 1029 1060 1092 1093 1120 1141 1142 1180 1221 1222 1260 1285
1286 1320 1349 1350 1400 1450 1542 1543 1575 1607 1608 1630 1655 1656 1700 1799 1800 1835 1864 1865 1950 2057 2058 2200 2315 2450 2572 2699)
frames=(0 60 128 129 250 386 450 513 514 515 540 560 570 576 578 579 640 700 771 772 800 830 835 836 900 957 965 1011
1029 1080 1092 1093 1150 1286 1330 1349 1350 1427 1430 1500 1543 1580 1600 1607 1608 1700 1800 1850 1864 1865 1900 1990
2057 2185 2186 2200 2230 2260 2290 2310 2314 2315 2350 2400 2420 2470 2530 2572 2620 2699)
expr=$(printf "+eq(n\\,%s)" "${frames[@]}")
ffmpeg -v error -y -i "$film" -vf "select='${expr:1}'" -fps_mode passthrough -frame_pts 1 "$out/%05d.png"
ls "$out" | wc -l
ls "$out"/*.png | wc -l
cd "$out"
ls [0-9]*.png | split -l 12 - sheet-
for list in sheet-*; do
args=()
while read -r f; do args+=(-i "$f"); done < "$list"
n=$(wc -l < "$list" | tr -d ' ')
ffmpeg -v error -y "${args[@]}" -filter_complex "$(for i in $(seq 0 $((n - 1))); do printf "[%d]scale=640:360[s%d];" "$i" "$i"; done)$(for i in $(seq 0 $((n - 1))); do printf "[s%d]" "$i"; done)xstack=inputs=$n:layout=$(python3 -c "print('|'.join(f'{(i%3)*640}_{(i//3)*360}' for i in range($n)))"):fill=black" "$list.png"
rm "$list"
done
+134 -29
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Composites the launch film from scenes, recordings and captions."""
"""Composites the launch film from scenes, recordings, layers and captions."""
import json
import math
import subprocess
@@ -10,11 +10,15 @@ HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "out"
SCENES = OUT / "scenes"
REC = OUT / "rec"
EXTRA = OUT / "extra"
WORK = OUT / "edit"
FILM = OUT / "ElyOffice-launch.mp4"
FPS = 30
BEAT = 60 / 112
TOTAL = 2700
RGB_IN = "scale=in_color_matrix=bt709:in_range=tv,format=rgb24"
# Recording is shrunk into a padded frame for clean edges.
PAD = 4
def run(args):
@@ -33,19 +37,64 @@ def lerp(points, x):
def piecewise(points, var):
"""An ffmpeg expression for lerp over var."""
if len(points) == 1:
return f"{points[0][1]:.4f}"
expr = None
for i in range(len(points) - 2, -1, -1):
(x0, y0), (x1, y1) = points[i], points[i + 1]
seg = f"({y0:.6f}+({var}-{x0:.6f})*{(y1 - y0) / (x1 - x0):.6f})"
expr = seg if expr is None else f"if(lt({var},{x1:.6f}),{seg},{expr})"
seg = f"({y0:.4f}+({var}-{x0:.4f})*{(y1 - y0) / (x1 - x0):.6f})"
expr = seg if expr is None else f"if(lt({var},{x1:.4f}),{seg},{expr})"
return expr
def knots(values):
"""Breakpoints where a per-frame series stops being linear."""
keep = [(0, values[0])]
for k in range(1, len(values) - 1):
if abs(values[k - 1] - 2 * values[k] + values[k + 1]) > 1e-3:
keep.append((k, values[k]))
if len(values) > 1:
keep.append((len(values) - 1, values[-1]))
return keep
def frames_expr(values):
"""Per-frame value; perspective counts input frames from one."""
return "+".join(f"eq(in,{k + 1})*{v:.3f}" for k, v in enumerate(values))
def solve(a, b):
"""Gaussian elimination for a small dense system."""
n = len(b)
m = [row[:] + [b[i]] for i, row in enumerate(a)]
for c in range(n):
p = max(range(c, n), key=lambda r: abs(m[r][c]))
m[c], m[p] = m[p], m[c]
for r in range(n):
if r != c:
f = m[r][c] / m[c][c]
m[r] = [x - f * y for x, y in zip(m[r], m[c])]
return [m[i][n] / m[i][i] for i in range(n)]
def homography(src, dst):
"""3x3 map taking four src points to four dst points."""
a, b = [], []
for (x, y), (u, v) in zip(src, dst):
a.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); b.append(u)
a.append([0, 0, 0, x, y, 1, -v * x, -v * y]); b.append(v)
h = solve(a, b) + [1]
return lambda x, y: ((h[0] * x + h[1] * y + h[2]) / (h[6] * x + h[7] * y + 1),
(h[3] * x + h[4] * y + h[5]) / (h[6] * x + h[7] * y + 1))
def padded(corners):
"""Pins for the padded frame so the content meets `corners`."""
content = [(PAD, PAD), (1920 - PAD, PAD), (PAD, 1080 - PAD), (1920 - PAD, 1080 - PAD)]
h = homography(content, corners)
return [h(0, 0), h(1920, 0), h(0, 1080), h(1920, 1080)]
def piece_chain(label, piece, start, f0, f1, out):
"""Retimes one recording piece into frames f0..f1 of a section."""
anchors = [((start + b) * BEAT, s) for b, s in piece["anchors"]]
@@ -82,10 +131,12 @@ def section(s, scene, end, plan):
inputs += ["-framerate", FPS, "-i", SCENES / scene["name"] / "%05d.png"]
inputs += ["-loop", 1, "-framerate", FPS, "-i", WORK / "cards" / "frame.png"]
inputs += ["-loop", 1, "-framerate", FPS, "-i", WORK / "cards" / f"{app}.png"]
inputs += ["-loop", 1, "-framerate", FPS, "-i", WORK / "cards" / "frame.png"]
chains.append(f"{''.join(parts)}concat=n={len(parts)}:v=1:a=0[rec]")
chains.append(f"[rec]split[ra][rb]")
chains.append(plate_chain(scene, f"[ra]trim=end_frame={n_plate},setpts=PTS-STARTPTS", k, "plate"))
chains.append(full_chain(s, plan, f"[rb]trim=start_frame={n_plate},setpts=PTS-STARTPTS", length - n_plate, k, "full"))
chains.append("[rec]split[ra][rb]")
chains.append(plate_chain(scene, "[ra]trim=end_frame=%d,setpts=PTS-STARTPTS" % n_plate, k, f"[{k + 3}:v]format=rgba", "plate"))
chains.append(full_chain(app, plan, "[rb]trim=start_frame=%d,setpts=PTS-STARTPTS" % n_plate,
first + n_plate, length - n_plate, k, f"[{k + 1}:v]", "full"))
chains.append("[plate][full]concat=n=2:v=1:a=0,format=rgb24[v]")
target = WORK / f"{app}.mkv"
run([*inputs, "-filter_complex", ";".join(chains), "-map", "[v]", "-frames:v", length,
@@ -93,11 +144,11 @@ def section(s, scene, end, plan):
return target
def plate_chain(scene, rec, k, out):
"""Pins the recording onto the plate's screen face."""
def plate_chain(scene, rec, k, card, out):
"""Pins the recording to the screen face, then flattens it into the window."""
data = json.loads((SCENES / f"{scene['name']}.json").read_text())["frames"]
landed = next(f["corners"] for f in data if f["corners"])
corners = [f["corners"] or landed for f in data]
corners = [padded(f["corners"] or landed) for f in data]
pins = ":".join(
f"{axis}{c}='{frames_expr([fr[c][j] for fr in corners])}'"
for c in range(4) for j, axis in enumerate("xy")
@@ -106,28 +157,48 @@ def plate_chain(scene, rec, k, out):
f",colorchannelmixer=aa={f['opacity']:.3f}:enable='eq(n,{f['local']})'"
for f in data if f["opacity"] < 0.999
)
flats = "".join(
f",colorchannelmixer=aa={f['flat']:.4f}:enable='eq(n,{f['local']})'"
for f in data if f["flat"] < 0.9999
)
return (
f"{rec},scale=800:450:flags=area,scale=1912:1072:flags=bicubic,format=rgba,"
f"pad=1920:1080:4:4:color=black@0,setsar=1,perspective={pins}:sense=destination:eval=frame:interpolation=cubic"
f"{fades}[pin];[{k}:v]format=rgba[bg];[bg][pin]overlay=format=rgb:eof_action=repeat,format=rgb24,setsar=1[{out}]"
f"{rec},scale=1920-{2 * PAD}:1080-{2 * PAD}:flags=area,format=rgba,"
f"pad=1920:1080:{PAD}:{PAD}:color=black@0,setsar=1,"
f"perspective={pins}:sense=destination:eval=frame:interpolation=cubic{fades}[pin];"
f"{card}{flats}[mask];"
f"[{k}:v]format=rgba[bg];[bg][mask]overlay=format=rgb:shortest=1[bm];"
f"[bm][pin]overlay=format=rgb:eof_action=repeat,format=rgb24,setsar=1[{out}]"
)
def full_chain(s, plan, rec, n, k, out):
"""Letterboxes the recording with a slow push and the caption."""
def view_expr(app, first, n):
"""Per-frame crop of the recording from the fx layer, or the whole."""
path = EXTRA / f"{app}-fx" / "view.json"
if not path.exists():
return [[0, 0, 1920, 1080]] * n
view = json.loads(path.read_text())
rects = view["rects"]
if view["first"] != first or len(rects) < n:
raise SystemExit(f"{path}: frames {view['first']}+{len(rects)} do not cover {first}+{n}")
return rects[:n]
def full_chain(app, plan, rec, first, n, k, card, out):
"""Letterboxes the recording with the fx punch-ins and the caption."""
w = plan["window"]
fx, fy = s["focus"]
z = f"(1+{plan['push'] - 1:.4f}*in/{n - 1})"
cw, ch = f"(1920/{z})", f"(1080/{z})"
left = f"clip({fx}-{cw}/2,0,1920-{cw})"
top = f"clip({fy}-{ch}/2,0,1080-{ch})"
quad = (f"x0='{left}':y0='{top}':x1='{left}+{cw}':y1='{top}':"
f"x2='{left}':y2='{top}+{ch}':x3='{left}+{cw}':y3='{top}+{ch}'")
rects = view_expr(app, first, n)
series = {
"left": [r[0] for r in rects], "top": [r[1] for r in rects],
"right": [r[0] + r[2] for r in rects], "bottom": [r[1] + r[3] for r in rects],
}
e = {name: piecewise(knots(v), "(in-1)") for name, v in series.items()}
quad = (f"x0='{e['left']}':y0='{e['top']}':x1='{e['right']}':y1='{e['top']}':"
f"x2='{e['left']}':y2='{e['bottom']}':x3='{e['right']}':y3='{e['bottom']}'")
rise = "round(12*pow(max(0,1-t/0.2),2))"
return (
f"{rec},perspective={quad}:sense=source:eval=frame:interpolation=cubic,"
f"scale={w['w']}:{w['h']}:flags=lanczos,pad=1920:1080:{w['x']}:{w['y']}:color=0xfbfbfa,setsar=1,format=rgba[lb];"
f"[{k + 1}:v]format=rgba[fr];[lb][fr]overlay=format=rgb:shortest=0[lf];"
f"[lb]{card}overlay=format=rgb:shortest=1[lf];"
f"[{k + 2}:v]format=rgba,fade=in:st=0:d=0.2:alpha=1[cap];"
f"[lf][cap]overlay=x=0:y='{rise}':format=rgb:eval=frame,trim=end_frame={n},format=rgb24,setsar=1[{out}]"
)
@@ -137,18 +208,52 @@ def scene_input(name):
return ["-framerate", FPS, "-i", SCENES / name / "%05d.png"]
def layers():
"""Delivered layers, later starts on top; grid under its fx."""
found = []
for path in sorted(EXTRA.glob("*/manifest.json")):
m = json.loads(path.read_text())
if m["fps"] != FPS or m["kind"] not in ("base", "over"):
raise SystemExit(f"{path}: unexpected manifest")
have = len(list(path.parent.glob("[0-9]*.png")))
if have != m["count"]:
raise SystemExit(f"{path}: {have} frames, manifest says {m['count']}")
found.append(m | {"dir": path.parent})
rank = lambda m: (m["first"], m["kind"] != "base", m["name"] != "fx-grid")
return sorted(found, key=rank)
def starts():
"""Each app name layer starts no earlier than its plate."""
scenes = json.loads((SCENES / "scenes.json").read_text())["scenes"]
return {f"type-{s['name'][6:]}": s["first"] for s in scenes if s["name"].startswith("plate-")}
def film(made):
"""Joins every part and the music into the final MP4."""
"""Joins every part, lays the delivered layers on top, adds music."""
order = ["opening", "rise", *made, "files", "props", "end"]
inputs = []
for part in order:
inputs += ["-i", part] if isinstance(part, Path) else scene_input(part)
graph = ["".join(f"[{i}:v]" for i in range(len(order)))
+ f"concat=n={len(order)}:v=1:a=0,format=rgb24,settb=1/{FPS},setpts=N[base0]"]
last = "base0"
floor = starts()
for j, m in enumerate(layers()):
if m["first"] < floor.get(m["name"], 0):
m = m | {"first": floor[m["name"]], "count": m["count"] - (floor[m["name"]] - m["first"])}
index = sum(1 for a in inputs if a == "-i")
inputs += ["-framerate", FPS, "-start_number", m["first"], "-i", m["dir"] / "%05d.png"]
hold = ",tpad=stop_mode=clone:stop=1" if m["name"] == "opening" else ""
graph.append(f"[{index}:v]format=rgba{hold},settb=1/{FPS},setpts=N+{m['first']}[l{j}]")
graph.append(f"[{last}][l{j}]overlay=format=rgb:eof_action=pass:repeatlast=0[base{j + 1}]")
print(f"layer {m['name']}: {m['kind']} {m['first']}..{m['first'] + m['count'] - 1}")
last = f"base{j + 1}"
music = sum(1 for a in inputs if a == "-i")
inputs += ["-i", OUT / "music.wav"]
joined = "".join(f"[{i}:v]" for i in range(len(order)))
graph = (f"{joined}concat=n={len(order)}:v=1:a=0,"
"scale=out_color_matrix=bt709:out_range=tv,format=yuv420p[v]")
run([*inputs, "-filter_complex", graph, "-map", "[v]", "-map", f"{len(order)}:a",
"-frames:v", 2700, "-c:v", "libx264", "-profile:v", "high", "-preset", "slow", "-crf", 18,
graph.append(f"[{last}]setpts=N/{FPS}/TB,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p[v]")
run([*inputs, "-filter_complex", ";".join(graph), "-map", "[v]", "-map", f"{music}:a",
"-frames:v", TOTAL, "-c:v", "libx264", "-profile:v", "high", "-preset", "slow", "-crf", 18,
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
"-r", FPS, "-c:a", "aac", "-b:a", "192k", "-t", "90", "-movflags", "+faststart", FILM])
@@ -166,7 +271,7 @@ def main():
if not only or s["app"] in only:
target = section(s, scenes[i], scenes[i + 1]["first"], plan)
made.append(target)
if not only:
if not only or "film" in only:
film(made)
+4 -11
View File
@@ -1,25 +1,21 @@
{
"window": { "x": 144, "y": 40, "w": 1632, "h": 918 },
"push": 1.16,
"sections": [
{
"app": "doc", "title": "ElyDoc", "en": "Write. Offline.", "zh": "离线写作。",
"focus": [780, 470],
"pieces": [
{ "src": "doc", "anchors": [[0, 1.0], [4, 3.4], [9, 8.0], [10, 8.8], [12, 10.3], [15, 12.7], [16, 13.6]] }
{ "src": "doc", "anchors": [[0, 1.0], [4, 3.4], [9, 8.0], [10, 8.8], [12, 10.3], [15, 12.233], [16, 13.6]] }
]
},
{
"app": "table", "title": "ElyTable", "en": "Sum it. Chart it.", "zh": "求和,出图。",
"focus": [700, 430],
"pieces": [
{ "src": "table", "anchors": [[0, 1.0], [4, 3.9], [8, 7.3]] },
{ "src": "table", "anchors": [[8, 17.0], [12, 20.3], [16, 23.6]] }
{ "src": "table", "anchors": [[8, 17.0], [12, 19.933], [15, 22.683], [16, 23.6]] }
]
},
{
"app": "show", "title": "ElyShow", "en": "Animate. Present.", "zh": "加动画,去放映。",
"focus": [900, 470],
"pieces": [
{ "src": "show", "anchors": [[0, 0.6], [4, 4.2], [7, 6.6]] },
{ "src": "show", "anchors": [[7, 11.8], [12, 15.0]] },
@@ -28,14 +24,12 @@
},
{
"app": "pdf", "title": "ElyPDF", "en": "Mark up. Sign.", "zh": "批注,签名。",
"focus": [1090, 540],
"pieces": [
{ "src": "pdf", "anchors": [[0, 0.8], [4, 4.8], [6, 6.3], [8, 8.2], [9, 9.2], [12, 12.7], [15, 15.4], [16, 16.6]] }
{ "src": "pdf", "anchors": [[0, 0.8], [4, 4.6], [5, 5.083], [8, 8.2], [9, 9.0], [13, 13.0], [14, 13.9], [16, 15.9]] }
]
},
{
"app": "tree", "title": "ElyTree", "en": "Stage. Commit. Look back.", "zh": "暂存,提交,回看历史。",
"focus": [830, 420],
"pieces": [
{ "src": "tree", "anchors": [[0, 0.8], [4, 3.6], [5, 4.1], [7, 7.2]] },
{ "src": "tree", "anchors": [[7, 10.4], [8, 11.4], [9, 13.2], [11, 15.5], [16, 19.9]] }
@@ -43,9 +37,8 @@
},
{
"app": "mark", "title": "ElyMark", "en": "Markdown, two ways.", "zh": "Markdown,可视与源码。",
"focus": [1000, 460],
"pieces": [
{ "src": "mark", "anchors": [[0, 0.6], [4, 2.8], [7, 5.9], [12, 10.4], [15, 12.7], [16, 13.6]] }
{ "src": "mark", "anchors": [[0, 0.6], [4, 2.8], [7, 5.9], [12, 10.267], [15, 12.55], [16, 13.6]] }
]
}
]
+200
View File
@@ -0,0 +1,200 @@
// SVG for callout slabs, leaders and the margin grid.
import { BEAT, LAND, WINDOW, glide, place, seconds, settle, view } from "./plan.mjs";
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
const ACCENT = "#3d7fe5";
const STONE = "#efefec";
const INK = "#1d1d1f";
const MUTED = "#6e6e73";
const W = 1920;
const H = 1080;
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const f = (v) => v.toFixed(2);
const pts = (list) => list.map(([x, y]) => `${f(x)},${f(y)}`).join(" ");
/** A grid point to screen pixels around an origin. */
const iso = (u, [ox, oy]) => (x, y, z) => [ox + (x - y) * COS * u, oy + ((x + y) * SIN - z) * u];
/** A three-tone block, like site/src/iso.ts, with a CSS tone. */
function block(p, { w, d, h }, tone) {
const face = (list, shade) => {
const fill = shade ? `color-mix(in oklab, ${tone}, black ${shade}%)` : tone;
return `<polygon points="${pts(list)}" style="fill:${fill}"/>`;
};
return [
face([p(0, d, 0), p(w, d, 0), p(w, 0, 0), p(w, 0, h), p(0, 0, h), p(0, d, h)], 9),
face([p(0, d, 0), p(w, d, 0), p(w, d, h), p(0, d, h)], 9),
face([p(w, d, 0), p(w, 0, 0), p(w, 0, h), p(w, d, h)], 20),
face([p(0, 0, h), p(w, 0, h), p(w, d, h), p(0, d, h)], 0),
].join("");
}
const SLAB_U = 18;
const LEFT = { w: 4, d: 2, h: 1, x: WINDOW.x - 14 };
const RIGHT = { w: 2, d: 4, h: 1, x: WINDOW.x + WINDOW.w + 14 };
/** Points along an elbow path, cut at progress l. */
function elbow(a, b, l) {
const corner = [b[0], a[1]];
const first = Math.abs(corner[0] - a[0]);
const total = first + Math.abs(b[1] - corner[1]);
let left = total * l;
if (left <= first) return [a, [a[0] + Math.sign(corner[0] - a[0]) * left, a[1]]];
left -= first;
return [a, corner, [corner[0], corner[1] + Math.sign(b[1] - corner[1]) * left]];
}
/** Which margin a callout stands in and how high. */
function stance(a, c) {
const mid = view(a, (seconds(a, c.at[0]) + seconds(a, c.at[1])) / 2);
const [bx, by, bw, bh] = c.box;
const side = c.side ?? (place([bx + bw / 2, 0], mid)[0] < W / 2 ? "left" : "right");
const edge = [side === "left" ? bx : bx + bw, by + bh / 2];
return { side, edge, y: clamp(place(edge, mid)[1], 132, 1036) };
}
/** Corner ticks around a frame-space box. */
function ticks([x0, y0], [x1, y1]) {
const pad = 6;
const [l, t, r, b] = [x0 - pad, y0 - pad, x1 + pad, y1 + pad];
const n = Math.min(16, (r - l) / 3, (b - t) / 3);
return [
[[l, t + n], [l, t], [l + n, t]],
[[r - n, t], [r, t], [r, t + n]],
[[r, b - n], [r, b], [r - n, b]],
[[l + n, b], [l, b], [l, b - n]],
]
.map((c) => `<polyline points="${pts(c)}"/>`)
.join("");
}
/** One callout at time t, or nothing outside its life. */
function callout(a, c, t) {
const born = seconds(a, c.at[0]);
const gone = seconds(a, c.at[1]);
if (t < born - LAND || t > gone) return "";
const rise = settle((t - (born - LAND)) / LAND);
const fall = settle((t - (gone - LAND)) / LAND);
const alpha = rise * (1 - fall);
const drop = SLAB_U * (1 - rise) + SLAB_U * fall;
const { side, edge, y } = stance(a, c);
const shape = side === "left" ? LEFT : RIGHT;
const base = iso(SLAB_U, [0, 0]);
const tip = side === "left" ? base(shape.w, 0, shape.h) : base(0, shape.d, shape.h);
const origin = [shape.x - tip[0], y + drop - tip[1]];
const p = iso(SLAB_U, origin);
const anchor = [shape.x, y + drop];
const top = p(0, 0, shape.h)[1];
const cx = (p(0, shape.d, shape.h)[0] + p(shape.w, 0, shape.h)[0]) / 2;
const now = view(a, t);
const target = place(edge, now);
const [bx, by, bw, bh] = c.box;
const frame = ticks(place([bx, by], now), place([bx + bw, by + bh], now));
const reach = settle(clamp((t - born) / (BEAT / 2)));
const path = elbow(anchor, target, reach);
const mark = clamp((reach - 0.85) / 0.15);
const font = `font:600 22px 'SF Pro Text','SF Pro Display',system-ui`;
const [mx, my] = target;
return `<g opacity="${f(alpha)}">
<polyline points="${pts(path)}" fill="none" stroke="${ACCENT}" stroke-width="1.5"/>
<g opacity="${f(mark)}" fill="none" stroke="${ACCENT}" stroke-width="2">${frame}</g>
<polygon opacity="${f(mark)}" fill="${ACCENT}"
points="${pts([[mx, my - 5], [mx + 8.66, my], [mx, my + 5], [mx - 8.66, my]])}"/>
${block(p, shape, ACCENT)}
<text x="${f(cx)}" y="${f(top - 35)}" text-anchor="middle" fill="${INK}" style="${font}">${c.en}</text>
<text x="${f(cx)}" y="${f(top - 11)}" text-anchor="middle" fill="${MUTED}"
style="font:300 15px 'PingFang SC',system-ui;letter-spacing:0.04em">${c.zh}</text>
</g>`;
}
const svg = (body) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${body}</svg>`;
/** The callout layer of one app at time t. */
export function calloutFrame(a, t) {
return svg(a.callouts.map((c) => callout(a, c, t)).join(""));
}
const GRID_U = 24;
const GAP = 18;
const HOLE = { x: WINDOW.x - GAP, y: WINDOW.y - GAP, x1: WINDOW.x + WINDOW.w + GAP };
const GROUND = iso(GRID_U, [W / 2, -GRID_U * 20]);
/** Whether a frame point lies in the margin the grid may use. */
const free = ([x, y]) => x > 4 && x < W - 4 && y > 4 && y < H - 4 && !(x > HOLE.x - 4 && x < HOLE.x1 + 4 && y > HOLE.y - 4);
/** Tiles whose lifted cube stays inside the margin. */
export const TILES = (() => {
const out = [];
for (let i = -60; i < 160; i++) {
for (let j = -60; j < 160; j++) {
const corners = [GROUND(i, j, 0), GROUND(i + 1, j, 0), GROUND(i + 1, j + 1, 0), GROUND(i, j + 1, 0)];
const up = corners.map(([x, y]) => [x, y - GRID_U]);
if ([...corners, ...up].every(free)) out.push([i, j]);
}
}
return out;
})();
/** A seeded generator, mulberry32. */
function random(seed) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let r = Math.imul(s ^ (s >>> 15), 1 | s);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
/** Lifts per section: tile, landing beat and settling beat. */
export function lifts(a, seed) {
const next = random(seed);
const busy = [];
const out = [];
for (let beat = 5; beat <= 15; beat++) {
const n = 2 + Math.floor(next() * 2);
for (let k = 0; k < n; k++) {
const tile = TILES[Math.floor(next() * TILES.length)];
const until = Math.min(16, beat + 1 + Math.floor(next() * 3));
const key = tile.join(",");
if (busy.some((b) => b.key === key && b.until > beat)) continue;
busy.push({ key, until });
out.push({ tile, up: seconds(a, beat), down: seconds(a, until) });
}
}
return out;
}
const LINES = (() => {
const segs = [];
for (let k = -60; k < 160; k++) {
segs.push(`M${pts([GROUND(k, -60, 0)])}L${pts([GROUND(k, 160, 0)])}`);
segs.push(`M${pts([GROUND(-60, k, 0)])}L${pts([GROUND(160, k, 0)])}`);
}
return segs.join("");
})();
const CLIP = `<clipPath id="m"><path clip-rule="evenodd" d="M0,0H${W}V${H}H0Z
M${HOLE.x},${HOLE.y}H${HOLE.x1}V${H}H${HOLE.x}Z"/></clipPath>`;
/** The grid layer at time t for the given lifts. */
export function gridFrame(list, t) {
const cubes = list
.map(({ tile, up, down }) => {
const rise = settle((t - (up - LAND)) / LAND);
const sink = glide((t - (down - LAND)) / LAND);
return { tile, h: rise * (1 - sink) };
})
.filter((c) => c.h > 0.001)
.sort((p, q) => p.tile[0] + p.tile[1] - (q.tile[0] + q.tile[1]));
const body = cubes
.map(({ tile: [i, j], h }) => block((x, y, z) => GROUND(i + x, j + y, z), { w: 1, d: 1, h }, STONE))
.join("");
return svg(`<defs>${CLIP}</defs><g clip-path="url(#m)">
<path d="${LINES}" stroke="#e7e7e4" stroke-width="1" fill="none"/>
<g opacity="0.75">${body}</g></g>`);
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Composites fx layers over a mock letterboxed recording for review."""
import io
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from PIL import Image
HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "out"
PLAN = json.loads((HERE.parent / "edit" / "plan.json").read_text())
FPS = 30
BEAT = 60 / 112
STARTS = {"doc": 32, "table": 48, "show": 64, "pdf": 80, "tree": 96, "mark": 112}
def lerp(points, x):
"""Piecewise linear through points, extended past both ends."""
i = 0
while i < len(points) - 2 and x > points[i + 1][0]:
i += 1
(x0, y0), (x1, y1) = points[i], points[i + 1]
return y0 + (x - x0) * (y1 - y0) / (x1 - x0)
def source(app, frame):
"""Recording file and time shown at a global frame."""
pieces = next(s for s in PLAN["sections"] if s["app"] == app)["pieces"]
beat = frame / FPS / BEAT - STARTS[app]
piece = pieces[0]
for p in pieces[1:]:
if beat >= p["anchors"][0][0]:
piece = p
return OUT / "rec" / f"{piece['src']}.mp4", lerp(piece["anchors"], beat)
def grab(path, t):
png = subprocess.run(
["ffmpeg", "-v", "error", "-ss", f"{max(t, 0):.3f}", "-i", str(path), "-frames:v", "1",
"-f", "image2pipe", "-vcodec", "png", "-"],
check=True, capture_output=True).stdout
return Image.open(io.BytesIO(png)).convert("RGB")
def layer(name, frame):
path = OUT / "extra" / name / f"{frame:05d}.png"
return Image.open(path).convert("RGBA") if path.exists() else None
def compose(app, frame, rect):
"""One mock frame: recording through the view, cards, then layers."""
win = PLAN["window"]
x, y, w, h = rect
rec = grab(*source(app, frame)).crop((round(x), round(y), round(x + w), round(y + h)))
canvas = Image.new("RGBA", (1920, 1080), "#fbfbfa")
canvas.paste(rec.resize((win["w"], win["h"]), Image.LANCZOS), (win["x"], win["y"]))
for card in (OUT / "edit" / "cards" / "frame.png", OUT / "edit" / "cards" / f"{app}.png"):
canvas.alpha_composite(Image.open(card).convert("RGBA"))
for name in ("fx-grid", f"{app}-fx"):
top = layer(name, frame)
if top is not None:
canvas.alpha_composite(top)
return canvas.convert("RGB")
def main():
app, target = sys.argv[1], Path(sys.argv[2])
picks = [int(f) for f in sys.argv[3:]]
track = json.loads((OUT / "extra" / f"{app}-fx" / "view.json").read_text())
first, rects = track["first"], track["rects"]
frames = picks or list(range(first, first + len(rects)))
target.mkdir(parents=True, exist_ok=True)
def one(frame):
compose(app, frame, rects[frame - first]).save(target / f"{app}-{frame:05d}.png")
with ThreadPoolExecutor(8) as pool:
list(pool.map(one, frames))
if not picks:
subprocess.run(
["ffmpeg", "-v", "error", "-y", "-framerate", str(FPS), "-start_number", str(first),
"-i", str(target / f"{app}-%05d.png"), "-c:v", "libx264", "-crf", "18",
"-pix_fmt", "yuv420p", str(target / f"{app}-mock.mp4")],
check=True)
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
// Beats, punch-ins and callouts for the six app sections.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
export const EDIT_PLAN = resolve(here, "../edit/plan.json");
export const FPS = 30;
export const BEAT = 60 / 112;
export const LAND = 0.2;
/** The letterbox window, read from the integrator's plan. */
export const WINDOW = JSON.parse(readFileSync(EDIT_PLAN, "utf8")).window;
export const REC = { w: 1920, h: 1080 };
/** CSS cubic-bezier timing, solved by bisection. */
function bezier(x1, y1, x2, y2) {
const at = (a, b, u) => 3 * a * u * (1 - u) ** 2 + 3 * b * u * u * (1 - u) + u ** 3;
return (x) => {
if (x <= 0) return 0;
if (x >= 1) return 1;
let [lo, hi] = [0, 1];
for (let i = 0; i < 50; i++) {
const mid = (lo + hi) / 2;
if (at(x1, x2, mid) < x) lo = mid;
else hi = mid;
}
return at(y1, y2, (lo + hi) / 2);
};
}
export const settle = bezier(0.2, 0.8, 0.3, 1);
export const glide = bezier(0.65, 0, 0.35, 1);
/**
* Per app: first beat of its section, punch-ins and callouts.
* Beats count from the section's bar 1; rects and boxes are recording pixels.
* A punch-in eases in over `in`, holds, eases out over `out`;
* when one's `out` equals the next one's `in`, the view pans directly.
*/
export const APPS = [
{
app: "doc",
start: 32,
zooms: [
{ rect: [280, 190, 960, 540], in: [4.5, 5], out: [8, 8.5] },
{ rect: [250, 187, 900, 506.25], in: [10, 10.5], out: [13.5, 14] },
],
callouts: [
{ en: "Type", zh: "离线输入", at: [5, 8], box: [385, 405, 750, 100] },
{ en: "Bold ⌘B", zh: "加粗", at: [11, 14], box: [553, 407, 52, 28] },
{ en: "125%", zh: "缩放", at: [15, 16], box: [1828, 1054, 54, 24] },
],
},
{
app: "table",
start: 48,
zooms: [
{ rect: [0, 70, 1140, 641.25], in: [4.5, 5], out: [7.5, 8] },
{ rect: [145, 110, 900, 506.25], in: [12, 12.5], out: [14.5, 15] },
],
callouts: [
{ en: "=SUM", zh: "求和", at: [5, 7.5], box: [178, 92, 110, 24] },
{ en: "Fill right", zh: "向右填充", at: [6, 8], box: [229, 414, 892, 36] },
{ en: "Chart", zh: "插入图表", at: [13, 15], box: [232, 158, 726, 408] },
],
},
{
app: "show",
start: 64,
zooms: [
{ rect: [920, 252, 1000, 562.5], in: [5, 5.5], out: [7.5, 8] },
{ rect: [360, 202, 1200, 675], in: [12.5, 13], out: [15.5, 16] },
],
callouts: [
{ en: "Fly In", zh: "飞入", at: [5.5, 7], box: [1580, 340, 200, 30] },
{ en: "Preview", zh: "预览动画", at: [8, 10], box: [1722, 768, 158, 30] },
{ en: "Fly In", zh: "飞入", at: [13, 15], box: [425, 423, 1068, 92], side: "left" },
{ en: "Fade In", zh: "淡入", at: [15, 16], box: [585, 612, 750, 54], side: "right" },
],
},
{
app: "pdf",
start: 80,
zooms: [
{ rect: [605, 360, 960, 540], in: [6.5, 7], out: [8.5, 9] },
{ rect: [480, 270, 960, 540], in: [10, 10.5], out: [12.5, 13] },
{ rect: [760, 540, 960, 540], in: [14.5, 15], out: [15.5, 16] },
],
callouts: [
{ en: "Highlight", zh: "高亮", at: [7, 9], box: [822, 617, 525, 38] },
{ en: "Draw", zh: "手写签名", at: [10, 13], box: [727, 478, 466, 190] },
{ en: "Sign", zh: "签名", at: [15, 16], box: [1148, 848, 184, 78] },
],
},
{
app: "tree",
start: 96,
zooms: [
{ rect: [960, 120, 960, 540], in: [4.5, 5], out: [6, 6.5] },
{ rect: [0, 517.5, 1000, 562.5], in: [6.5, 7], out: [8.5, 9] },
{ rect: [0, 140, 900, 506.25], in: [9.5, 10], out: [12.5, 13] },
],
callouts: [
{ en: "Stage hunk", zh: "暂存此块", at: [5, 6.5], box: [1805, 168, 98, 26] },
{ en: "Commit", zh: "提交", at: [7, 9], box: [508, 1013, 129, 29] },
{ en: "History", zh: "提交历史", at: [10, 13], box: [2, 170, 640, 605] },
],
},
{
app: "mark",
start: 112,
zooms: [
{ rect: [603, 150, 960, 540], in: [8.5, 9], out: [10.5, 11] },
{ rect: [603, 530, 960, 540], in: [10.5, 11], out: [11.5, 12] },
],
callouts: [
{ en: "Math", zh: "公式", at: [9, 10.5], box: [652, 291, 862, 245] },
{ en: "Mermaid", zh: "流程图", at: [11, 12], box: [672, 880, 820, 100] },
{ en: "Source ⌘2", zh: "源码", at: [13, 15], box: [1845, 1056, 26, 22] },
],
},
];
/** Global seconds of a section beat. */
export const seconds = (a, beat) => (a.start + beat) * BEAT;
/** Global frames of bars 2–4 of a section. */
export function frames(a) {
const first = Math.ceil(seconds(a, 4) * FPS - 1e-6);
const end = Math.ceil(seconds(a, 16) * FPS - 1e-6);
return { first, count: end - first };
}
const FULL = [0, 0, REC.w, REC.h];
const mix = (a, b, p) => a.map((v, i) => v + (b[i] - v) * p);
/** Eased moves of the view rect, as [t0, t1, from, to, ease]. */
function moves(a) {
const out = [];
a.zooms.forEach((z, i) => {
const prev = a.zooms[i - 1];
const panned = prev && prev.out[0] === z.in[0] && prev.out[1] === z.in[1];
if (!panned) out.push([seconds(a, z.in[0]), seconds(a, z.in[1]), FULL, z.rect, settle]);
const next = a.zooms[i + 1];
const pans = next && next.in[0] === z.out[0] && next.in[1] === z.out[1];
out.push([seconds(a, z.out[0]), seconds(a, z.out[1]), z.rect, pans ? next.rect : FULL, glide]);
});
return out;
}
/** The recording rect shown in the window at time t. */
export function view(a, t) {
let rect = FULL;
for (const [t0, t1, from, to, ease] of moves(a)) {
if (t >= t1) rect = to;
else if (t > t0) return mix(from, to, ease((t - t0) / (t1 - t0)));
else break;
}
return rect;
}
/** Recording pixel to frame pixel through a view rect. */
export function place([rx, ry], [vx, vy, vw, vh]) {
return [WINDOW.x + ((rx - vx) / vw) * WINDOW.w, WINDOW.y + ((ry - vy) / vh) * WINDOW.h];
}
/** The punch-ins in the integrator's terms, global seconds. */
export function zoomList(a) {
const r = (v) => Math.round(v * 10000) / 10000;
return a.zooms.map((z) => ({
t0: r(seconds(a, z.in[0])),
t1: r(seconds(a, z.out[1])),
rect: z.rect,
in: z.in.map((b) => r(seconds(a, b))),
out: z.out.map((b) => r(seconds(a, b))),
}));
}
+118
View File
@@ -0,0 +1,118 @@
// Renders the app callout layers, punch-in tracks and margin grid.
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { calloutFrame, gridFrame, lifts } from "./draw.mjs";
import { APPS, EDIT_PLAN, FPS, frames, view, zoomList } from "./plan.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const require = createRequire(resolve(here, "../scenes/package.json"));
const { chromium } = require("playwright");
const extra = resolve(here, "../out/extra");
const PAGES = 6;
const browser = await chromium.launch();
/** A transparent film-sized page that shows one SVG. */
async function open() {
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
page.on("pageerror", (e) => {
throw e;
});
await page.setContent(
`<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:transparent}
svg{display:block;-webkit-font-smoothing:antialiased}</style><body></body>`,
);
return page;
}
const pages = await Promise.all(Array.from({ length: PAGES }, open));
/** Writes PNGs for global frames, spreading work over pages. */
async function paint(dir, list, draw) {
let next = 0;
await Promise.all(
pages.map(async (page) => {
while (next < list.length) {
const frame = list[next++];
await page.evaluate((s) => {
document.body.innerHTML = s;
return document.fonts.ready;
}, draw(frame / FPS));
await page.screenshot({ path: join(dir, `${String(frame).padStart(5, "0")}.png`), omitBackground: true });
}
}),
);
}
function ffmpeg(args) {
const run = spawnSync("ffmpeg", ["-hide_banner", "-v", "error", "-y", ...args], { stdio: "inherit" });
if (run.status !== 0) throw new Error(`ffmpeg failed: ${args.join(" ")}`);
}
/** An H.264 preview of an over layer on the light field. */
function preview(name, first, count) {
const dir = join(extra, name);
ffmpeg([
"-f", "lavfi", "-i", `color=c=0xfbfbfa:s=1920x1080:r=${FPS}`,
"-framerate", String(FPS), "-start_number", String(first), "-i", join(dir, "%05d.png"),
"-filter_complex", "[0][1]overlay=format=auto:shortest=1,format=yuv420p",
"-frames:v", String(count), "-c:v", "libx264", "-crf", "18", join(extra, `${name}.mp4`),
]);
}
function fresh(name) {
const dir = join(extra, name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
return dir;
}
const manifest = (dir, name, first, count) =>
writeFileSync(join(dir, "manifest.json"), JSON.stringify({ name, first, count, fps: FPS, kind: "over" }, null, 1));
const wanted = process.argv.slice(2);
const want = (name) => !wanted.length || wanted.includes(name);
console.log(`plan: ${EDIT_PLAN}`);
for (const a of APPS) {
const name = `${a.app}-fx`;
if (!want(name)) continue;
const { first, count } = frames(a);
const dir = fresh(name);
const list = Array.from({ length: count }, (_, k) => first + k);
writeFileSync(join(dir, "zoom.json"), JSON.stringify(zoomList(a), null, 1));
const rects = list.map((fr) => view(a, fr / FPS).map((v) => Math.round(v * 100) / 100));
writeFileSync(join(dir, "view.json"), JSON.stringify({ first, rects }));
await paint(dir, list, (t) => calloutFrame(a, t));
manifest(dir, name, first, count);
preview(name, first, count);
console.log(`${name}: frames ${first}–${first + count - 1}`);
}
if (want("fx-grid")) {
const dir = fresh("fx-grid");
const spans = APPS.map((a, i) => ({ ...frames(a), lifts: lifts(a, 0x5eed + i) }));
const first = spans[0].first;
const last = spans.at(-1).first + spans.at(-1).count - 1;
for (const s of spans) {
const list = Array.from({ length: s.count }, (_, k) => s.first + k);
await paint(dir, list, (t) => gridFrame(s.lifts, t));
}
const empty = join(dir, "empty.png");
await pages[0].evaluate(() => (document.body.innerHTML = ""));
await pages[0].screenshot({ path: empty, omitBackground: true });
for (let fr = first; fr <= last; fr++) {
if (!spans.some((s) => fr >= s.first && fr < s.first + s.count)) {
copyFileSync(empty, join(dir, `${String(fr).padStart(5, "0")}.png`));
}
}
rmSync(empty);
manifest(dir, "fx-grid", first, last - first + 1);
preview("fx-grid", first, last - first + 1);
console.log(`fx-grid: frames ${first}–${last}`);
}
await browser.close();
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Ely Office montage</title>
<style>
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #fbfbfa;
}
canvas {
display: block;
width: 1920px;
height: 1080px;
}
</style>
</head>
<body>
<canvas id="frame" width="3840" height="2160"></canvas>
<script type="module" src="./scene.ts"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
// Renders the montage: frames, manifest and preview MP4.
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "../scenes/node_modules/playwright/index.mjs";
import { createServer } from "vite";
const here = dirname(fileURLToPath(import.meta.url));
const site = resolve(here, "../..");
const extra = resolve(here, "../out/extra");
const out = join(extra, "montage");
const PAGES = 4;
function ffmpeg(args) {
const run = spawnSync("ffmpeg", ["-y", "-v", "error", ...args], { stdio: "inherit" });
if (run.status !== 0) throw new Error(`ffmpeg failed: ${args.join(" ")}`);
}
const server = await createServer({ root: site, configFile: false, logLevel: "error", server: { port: 0 } });
await server.listen();
const url = `${server.resolvedUrls.local[0]}video/montage/index.html`;
const browser = await chromium.launch({ args: ["--use-angle=metal", "--ignore-gpu-blocklist"] });
const recs = mkdtempSync(join(tmpdir(), "montage-"));
/** A page whose rec/ requests read the extracted frames. */
async function open() {
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
page.on("pageerror", (e) => {
throw e;
});
page.on("console", (m) => {
if (m.type() === "error") throw new Error(m.text());
});
await page.route("**/rec/**", (route) => {
const path = new URL(route.request().url()).pathname.split("/rec/")[1];
route.fulfill({ body: readFileSync(join(recs, path)), contentType: "image/jpeg" });
});
await page.goto(url, { waitUntil: "networkidle" });
await page.waitForFunction(() => window.montage);
return page;
}
const pages = await Promise.all(Array.from({ length: PAGES }, open));
const { FIRST, COUNT, apps } = await pages[0].evaluate(() => {
const { FIRST, COUNT, apps } = window.montage;
return { FIRST, COUNT, apps };
});
for (const { rec, from } of apps) {
mkdirSync(join(recs, rec));
const src = resolve(here, `../out/rec/${rec}.mp4`);
const args = ["-ss", String(from), "-i", src, "-frames:v", String(COUNT), "-vf", "fps=30,scale=1280:720:flags=lanczos"];
ffmpeg([...args, "-q:v", "2", join(recs, rec, "%05d.jpg")]);
}
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
const faces = [];
const queue = Array.from({ length: COUNT }, (_, i) => FIRST + i);
await Promise.all(
pages.map(async (page) => {
for (let f = queue.shift(); f !== undefined; f = queue.shift()) {
const face = await page.evaluate((f) => window.montage.render(f), f);
await page.screenshot({ path: join(out, `${String(f).padStart(5, "0")}.png`) });
faces.push({ frame: f, ...face });
}
}),
);
faces.sort((a, b) => a.frame - b.frame);
const manifest = { name: "montage", first: FIRST, count: COUNT, fps: 30, kind: "base", faces };
writeFileSync(join(out, "manifest.json"), JSON.stringify(manifest, null, 1));
await browser.close();
await server.close();
rmSync(recs, { recursive: true, force: true });
const music = resolve(here, "../out/music.wav");
const video = ["-framerate", "30", "-start_number", String(FIRST), "-i", join(out, "%05d.png")];
const audio = ["-ss", String(FIRST / 30), "-t", String(COUNT / 30), "-i", music];
const codec = ["-c:v", "libx264", "-preset", "slow", "-crf", "14", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"];
ffmpeg([...video, ...audio, ...codec, "-movflags", "+faststart", join(extra, "montage.mp4")]);
console.log(`montage: frames ${FIRST}..${FIRST + COUNT - 1} (${COUNT})`);
+256
View File
@@ -0,0 +1,256 @@
// Montage: six screens on the turning model, in WebGL.
import { model } from "../../src/model.ts";
import { type Box, SCREEN, screens } from "../scenes/parts.ts";
import { BEAT, glide, toward } from "../scenes/time.ts";
const WIDTH = 1920;
const HEIGHT = 1080;
const FPS = 30;
/** Bar 33 downbeat to the frame before bar 35. */
export const FIRST = 2057;
export const COUNT = 129;
const TURN: [number, number] = [128, 136];
const PIVOT = [8, 5.5];
const GROW = 1.5;
const TOP = model[0].blocks[0].h;
/** Recording file, its start second, and landing beat. */
export const apps = [
{ app: "ElyMark", rec: "mark", from: 7.0, beat: 128 },
{ app: "ElyTable", rec: "table", from: 1.6, beat: 128.25 },
{ app: "ElyShow", rec: "show", from: 12.2, beat: 128.5 },
{ app: "ElyDoc", rec: "doc", from: 3.6, beat: 128.75 },
{ app: "ElyPDF", rec: "pdf", from: 8.8, beat: 129 },
{ app: "ElyTree", rec: "tree", from: 11.0, beat: 129 },
];
/** A screen grown about its bottom centre; Show on its base. */
function slab(app: string): Box {
if (app === "ElyShow") return { x: 8.6, y: 2, z: TOP + 0.25, w: 4.8, d: 0.08, h: 2.7, tone: SCREEN };
const b = screens[app];
const w = b.w * GROW;
return { ...b, x: b.x + (b.w - w) / 2, w, h: b.h * GROW };
}
const slabs = apps.map((a) => slab(a.app));
const ground = model.flatMap((p) => p.blocks).filter((b) => b !== screens.ElyShow);
type Rgb = [number, number, number];
/** sRGB hex to linear channels. */
function linear(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16);
return [16, 8, 0].map((s) => {
const c = ((n >> s) & 255) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}) as Rgb;
}
const encode = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
/** Mixes with black in oklab, like CSS color-mix. */
function shade(hex: string, black: number): Rgb {
const [r, g, b] = linear(hex);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const k = 1 - black;
const [L, A, B] = [
k * (0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s),
k * (1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s),
k * (0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s),
];
const l3 = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
const m3 = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
const s3 = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
return [
4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
-0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
].map((c) => encode(Math.min(1, Math.max(0, c)))) as Rgb;
}
/** Side darkness: +y faces 9%, +x faces 20%, light fixed. */
function darkness(nx: number, ny: number): number {
const u = Math.atan2(ny, nx) - Math.PI / 2;
return 0.145 - 0.055 * (Math.cos(u) + Math.sin(u));
}
const COS = Math.cos(Math.PI / 6);
/** World point turned by `a`, to iso units and depth. */
function iso(a: number, x: number, y: number, z: number): [number, number, number] {
const [dx, dy] = [x - PIVOT[0], y - PIVOT[1]];
const [rx, ry] = [dx * Math.cos(a) - dy * Math.sin(a), dx * Math.sin(a) + dy * Math.cos(a)];
return [(rx - ry) * COS, (rx + ry) / 2 - z, rx + ry + z];
}
const corners = (b: Box) =>
[0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => [b.x + i * b.w, b.y + j * b.d, b.z + k * b.h])));
/** Fixed scale and offset fitting every angle of the turn. */
const camera = (() => {
const pts = [...ground, ...slabs, ...slabs.map((b) => ({ ...b, z: b.z + 1 }))].flatMap(corners);
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i <= 90; i++) {
for (const [x, y, z] of pts) {
const [px, py] = iso((-i * Math.PI) / 180, x, y, z);
xs.push(px);
ys.push(py);
}
}
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const s = Math.min(1800 / (x1 - x0), 1000 / (y1 - y0));
return { s, x: WIDTH / 2 - (s * (x0 + x1)) / 2, y: HEIGHT / 2 - (s * (y0 + y1)) / 2 };
})();
/** Frame pixels to clip space; nearer is smaller depth. */
function clip(a: number, x: number, y: number, z: number): [number, number, number] {
const [px, py, k] = iso(a, x, y, z);
return [((camera.x + camera.s * px) / WIDTH) * 2 - 1, 1 - ((camera.y + camera.s * py) / HEIGHT) * 2, -k / 64];
}
type V = [number, number, number];
/** Box faces except the bottom, as corner lists with normals. */
function faces(b: Box): { n: V; c: V[] }[] {
const { x, y, z, w, d, h } = b;
const [X, Y, Z] = [x + w, y + d, z + h];
return [
{ n: [0, 0, 1], c: [[x, y, Z], [X, y, Z], [X, Y, Z], [x, Y, Z]] },
{ n: [1, 0, 0], c: [[X, y, Z], [X, Y, Z], [X, Y, z], [X, y, z]] },
{ n: [-1, 0, 0], c: [[x, Y, Z], [x, y, Z], [x, y, z], [x, Y, z]] },
{ n: [0, 1, 0], c: [[X, Y, Z], [x, Y, Z], [x, Y, z], [X, Y, z]] },
{ n: [0, -1, 0], c: [[x, y, Z], [X, y, Z], [X, y, z], [x, y, z]] },
];
}
const canvas = document.querySelector<HTMLCanvasElement>("#frame")!;
const gl = canvas.getContext("webgl2", { alpha: false, antialias: true, preserveDrawingBuffer: true })!;
if (!gl) throw new Error("no WebGL2");
const aniso = gl.getExtension("EXT_texture_filter_anisotropic");
function program(vs: string, fs: string): WebGLProgram {
const p = gl.createProgram()!;
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]] as const) {
const sh = gl.createShader(type)!;
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh) ?? "shader");
gl.attachShader(p, sh);
}
gl.bindAttribLocation(p, 0, "p");
gl.bindAttribLocation(p, 1, "v");
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? "link");
return p;
}
const HEAD = "#version 300 es\nprecision highp float;\nlayout(location=0) in vec3 p;layout(location=1) in vec4 v;";
const flat = program(
`${HEAD}out vec4 c;void main(){c=v;gl_Position=vec4(p,1.);}`,
"#version 300 es\nprecision highp float;in vec4 c;out vec4 o;void main(){o=c;}",
);
const film = program(
`${HEAD}out vec2 u;out float q;void main(){u=v.xy;q=v.z;gl_Position=vec4(p,1.);}`,
"#version 300 es\nprecision highp float;uniform sampler2D t;in vec2 u;in float q;out vec4 o;void main(){o=vec4(texture(t,u).rgb,q);}",
);
const buffer = gl.createBuffer();
const textures = apps.map(() => gl.createTexture()!);
/** Draws quads as triangles: 7 floats per vertex. */
function draw(prog: WebGLProgram, data: number[]) {
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STREAM_DRAW);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 28, 0);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 28, 12);
gl.drawArrays(gl.TRIANGLES, 0, data.length / 7);
}
/** Two triangles from four clip points and per-corner values. */
function quad(out: number[], pts: V[], vals: number[][]) {
for (const i of [0, 1, 2, 0, 2, 3]) out.push(...pts[i], ...vals[i]);
}
/** Pushes a box's faces; `skip` leaves the screen face. */
function solid(out: number[], a: number, b: Box, alpha: number, skip: boolean) {
for (const { n, c } of faces(b)) {
if (skip && n[1] === 1) continue;
const [nx, ny] = [n[0] * Math.cos(a) - n[1] * Math.sin(a), n[0] * Math.sin(a) + n[1] * Math.cos(a)];
if (n[2] === 0 && nx + ny <= 1e-9) continue;
const rgb = shade(b.tone, n[2] ? 0 : darkness(nx, ny));
quad(out, c.map(([x, y, z]) => clip(a, x, y, z)), c.map(() => [...rgb, alpha]));
}
}
async function load(i: number, url: string) {
const img = new Image();
img.src = url;
await img.decode();
gl.bindTexture(gl.TEXTURE_2D, textures[i]);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, 8);
}
/** Renders global frame `f`; returns each visible screen's corners. */
async function render(f: number) {
if (f < FIRST || f >= FIRST + COUNT) throw new Error(`frame ${f} is outside the montage`);
const t = f / FPS;
const a = (-Math.PI / 2) * glide((t - TURN[0] * BEAT) / ((TURN[1] - TURN[0]) * BEAT));
await Promise.all(apps.map((s, i) => load(i, `rec/${s.rec}/${String(f - FIRST + 1).padStart(5, "0")}.jpg`)));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(...shade("#fbfbfa", 0), 1);
gl.depthMask(true);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.disable(gl.BLEND);
const opaque: number[] = [];
for (const b of ground) solid(opaque, a, b, 1, false);
const shown = apps.map((s, i) => ({ s, i, land: t > s.beat * BEAT - 0.2 ? toward(t, s.beat) : -1 }));
const standing = shown.filter((x) => x.land >= 1);
const landing = shown.filter((x) => x.land >= 0 && x.land < 1);
for (const { i } of standing) solid(opaque, a, slabs[i], 1, true);
draw(flat, opaque);
const faceOf = (i: number, lift: number, alpha: number) => {
const b = slabs[i];
const [x, X, Y, z, Z] = [b.x, b.x + b.w, b.y + b.d, b.z + lift, b.z + b.h + lift];
const c: V[] = [[x, Y, Z], [X, Y, Z], [X, Y, z], [x, Y, z]];
const data: number[] = [];
quad(data, c.map(([px, py, pz]) => clip(a, px, py, pz)), [[0, 0, alpha, 0], [1, 0, alpha, 0], [1, 1, alpha, 0], [0, 1, alpha, 0]]);
gl.bindTexture(gl.TEXTURE_2D, textures[i]);
draw(film, data);
return c.map(([px, py, pz]) => {
const [ix, iy] = iso(a, px, py, pz);
return [camera.x + camera.s * ix, camera.y + camera.s * iy];
});
};
const pins: Record<string, number[][]> = {};
for (const { s, i } of standing) pins[s.app] = faceOf(i, 0, 1);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
for (const { i, land } of landing) {
const sides: number[] = [];
solid(sides, a, { ...slabs[i], z: slabs[i].z + 1 - land }, land, true);
draw(flat, sides);
faceOf(i, 1 - land, land);
}
gl.finish();
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return { angle: (a * 180) / Math.PI, pins };
}
declare global {
interface Window {
montage: { FIRST: number; COUNT: number; apps: typeof apps; render: typeof render };
}
}
window.montage = { FIRST, COUNT, apps, render };
+246
View File
@@ -0,0 +1,246 @@
// Orthographic boxes turning about the desk's vertical axis.
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
const EPS = 1e-6;
/** A box in grid units, with its light tone. */
export interface Box {
x: number;
y: number;
z: number;
w: number;
d: number;
h: number;
tone: string;
}
/** A box as drawn: alpha and seam stroke. */
export interface Shown {
box: Box;
alpha: number;
seam?: boolean;
}
/** World turn and the camera's scale and offset. */
export interface View {
turn: number;
cx: number;
cy: number;
s: number;
ox: number;
oy: number;
}
type Point = [number, number];
/** Grid point to frame pixels. */
export function project(v: View, x: number, y: number, z: number): Point {
const [c, s] = [Math.cos(v.turn), Math.sin(v.turn)];
const [dx, dy] = [x - v.cx, y - v.cy];
const X = c * dx - s * dy;
const Y = s * dx + c * dy;
return [v.ox + v.s * (X - Y) * COS, v.oy + v.s * ((X + Y) * SIN - z)];
}
/** Frame pixel to the ground plane at height z. */
export function ground(v: View, px: number, py: number, z = 0): Point {
const a = (px - v.ox) / (v.s * COS);
const b = ((py - v.oy) / v.s + z) / SIN;
const [X, Y] = [(a + b) / 2, (b - a) / 2];
const [c, s] = [Math.cos(v.turn), Math.sin(v.turn)];
return [v.cx + c * X + s * Y, v.cy - s * X + c * Y];
}
/** Hex colour to linear oklab. */
function oklab(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
const lin = (u: number) => (u <= 0.04045 ? u / 12.92 : ((u + 0.055) / 1.055) ** 2.4);
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((u) => lin(u / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const q = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * q,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * q,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * q,
];
}
function rgb([L, A, B]: [number, number, number]): string {
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
const q = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
const out = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * q,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * q,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * q,
].map((u) => {
const c = Math.min(1, Math.max(0, u));
const s = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
return Math.round(s * 255);
});
return `rgb(${out.join(",")})`;
}
const shades = new Map<string, string>();
/** A tone mixed toward black by `k`, in oklab. */
export function shade(hex: string, k: number): string {
const key = `${hex}:${k.toFixed(3)}`;
let found = shades.get(key);
if (!found) {
found = rgb(oklab(hex).map((u) => u * (1 - k)) as [number, number, number]);
shades.set(key, found);
}
return found;
}
/** Two tones mixed in oklab. */
export function mix(a: string, b: string, u: number): string {
const [p, q] = [oklab(a), oklab(b)];
return rgb(p.map((v, i) => v + (q[i] - v) * u) as [number, number, number]);
}
/** Side darkness: 9% facing +y, 20% facing +x. */
function darkness(nx: number, ny: number): number {
return 0.145 - 0.0778 * Math.cos(Math.atan2(ny, nx) - (3 * Math.PI) / 4);
}
function hull(points: Point[]): Point[] {
const p = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const cross = (o: Point, a: Point, b: Point) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const half = (list: Point[]) => {
const out: Point[] = [];
for (const q of list) {
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
out.push(q);
}
out.pop();
return out;
};
return [...half(p), ...half([...p].reverse())];
}
function fill(ctx: CanvasRenderingContext2D, pts: Point[], color: string, seam = 0) {
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (seam) {
ctx.strokeStyle = color;
ctx.lineWidth = seam;
ctx.stroke();
}
}
/** One box: silhouette, then its visible faces. */
function paint(ctx: CanvasRenderingContext2D, v: View, { box: b, alpha, seam }: Shown) {
if (b.h <= EPS || alpha <= 0) return;
const p = (i: number, j: number, k: number) => project(v, b.x + i * b.w, b.y + j * b.d, b.z + k * b.h);
const [c, s] = [Math.cos(v.turn), Math.sin(v.turn)];
const sides: [number, number, Point[]][] = [
[1, 0, [p(1, 0, 0), p(1, 1, 0), p(1, 1, 1), p(1, 0, 1)]],
[-1, 0, [p(0, 0, 0), p(0, 1, 0), p(0, 1, 1), p(0, 0, 1)]],
[0, 1, [p(0, 1, 0), p(1, 1, 0), p(1, 1, 1), p(0, 1, 1)]],
[0, -1, [p(0, 0, 0), p(1, 0, 0), p(1, 0, 1), p(0, 0, 1)]],
];
const seen = sides.flatMap(([x, y, pts]) => {
const [nx, ny] = [c * x - s * y, s * x + c * y];
return nx + ny > EPS ? [{ pts, k: darkness(nx, ny) }] : [];
});
ctx.globalAlpha = alpha;
if (alpha >= 1) {
const all = [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => p(i, j, k))));
fill(ctx, hull(all), shade(b.tone, Math.max(...seen.map((f) => f.k))));
}
for (const f of seen) fill(ctx, f.pts, shade(b.tone, f.k), seam ? 1 : 0);
fill(ctx, [p(0, 0, 1), p(1, 0, 1), p(1, 1, 1), p(0, 1, 1)], b.tone, seam ? 1.2 : 0);
ctx.globalAlpha = 1;
}
function bounds(v: View, b: Box): [number, number, number, number] {
let [x0, x1, y0, y1] = [Infinity, -Infinity, Infinity, -Infinity];
for (const i of [0, 1])
for (const j of [0, 1])
for (const k of [0, 1]) {
const [x, y] = project(v, b.x + i * b.w, b.y + j * b.d, b.z + k * b.h);
[x0, x1, y0, y1] = [Math.min(x0, x), Math.max(x1, x), Math.min(y0, y), Math.max(y1, y)];
}
return [x0, x1, y0, y1];
}
/** True if `a` draws first, false if `b`, null if unknown. */
function behind(a: Box, b: Box, vx: number, vy: number): boolean | null {
const axes: [number, number, number, number, number][] = [
[a.x, a.w, b.x, b.w, vx],
[a.y, a.d, b.y, b.d, vy],
[a.z, a.h, b.z, b.h, 1],
];
for (const [p, pw, q, qw, dir] of axes) {
if (Math.abs(dir) < EPS) continue;
if (p + pw <= q + EPS) return dir > 0;
if (q + qw <= p + EPS) return dir < 0;
}
return null;
}
/** Nearest bottom corner's depth toward the viewer. */
function depth(v: View, b: Box): number {
const [c, s] = [Math.cos(v.turn), Math.sin(v.turn)];
let best = -Infinity;
for (const i of [0, 1])
for (const j of [0, 1]) {
const [dx, dy] = [b.x + i * b.w - v.cx, b.y + j * b.d - v.cy];
best = Math.max(best, (c + s) * dx + (c - s) * dy);
}
return best + b.z;
}
/** Back to front: overlaps decide, depth breaks ties. */
function order(v: View, list: Shown[]): Shown[] {
const [c, s] = [Math.cos(v.turn), Math.sin(v.turn)];
const [vx, vy] = [c + s, c - s];
const rects = list.map((e) => bounds(v, e.box));
const keys = list.map((e) => depth(v, e.box));
const after: number[][] = list.map(() => []);
const waits = list.map(() => 0);
for (let i = 0; i < list.length; i++)
for (let j = i + 1; j < list.length; j++) {
const [a, b] = [rects[i], rects[j]];
if (a[1] <= b[0] + 0.5 || b[1] <= a[0] + 0.5 || a[3] <= b[2] + 0.5 || b[3] <= a[2] + 0.5) continue;
const first = behind(list[i].box, list[j].box, vx, vy);
if (first === null) continue;
const [from, to] = first ? [i, j] : [j, i];
after[from].push(to);
waits[to]++;
}
const out: Shown[] = [];
const done = list.map(() => false);
while (out.length < list.length) {
let next = -1;
for (let i = 0; i < list.length; i++)
if (!done[i] && waits[i] === 0 && (next < 0 || keys[i] < keys[next])) next = i;
if (next < 0) {
for (let i = 0; i < list.length; i++) if (!done[i] && (next < 0 || keys[i] < keys[next])) next = i;
waits[next] = 0;
}
done[next] = true;
out.push(list[next]);
for (const k of after[next]) waits[k]--;
}
return out;
}
/** Draws every box in painter's order. */
export function boxes(ctx: CanvasRenderingContext2D, v: View, list: Shown[]): void {
for (const e of order(v, list)) paint(ctx, v, e);
}
/** A ground cell's diamond, squashed about its centre. */
export function cell(ctx: CanvasRenderingContext2D, v: View, i: number, j: number, u: number, color: string): void {
const pts = [project(v, i, j, 0), project(v, i + 1, j, 0), project(v, i + 1, j + 1, 0), project(v, i, j + 1, 0)];
const my = pts.reduce((sum, [, y]) => sum + y, 0) / 4;
fill(ctx, pts.map(([x, y]) => [x, my + (y - my) * u] as Point), color, 1);
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Ely Office opening</title>
<style>
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #252525;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="frame" width="1920" height="1080"></canvas>
<script type="module" src="./page.ts"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
// Draws one global frame of the opening onto the canvas.
import { boxes, cell, ground } from "./draw.ts";
import { END, FIRST, HEIGHT, WIDTH, flip, flips, pieces, view } from "./plan.ts";
const DARK = "#252525";
const LIGHT = "#fbfbfa";
const canvas = document.querySelector<HTMLCanvasElement>("#frame");
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) throw new Error("index.html lacks #frame");
/** Field cells flip light ring by ring until bar 5. */
function field(f: number, v: ReturnType<typeof view>) {
const done = f >= flips[flips.length - 1];
ctx!.fillStyle = done ? LIGHT : DARK;
ctx!.fillRect(0, 0, WIDTH, HEIGHT);
if (done) return;
const corners = [[0, 0], [WIDTH, 0], [0, HEIGHT], [WIDTH, HEIGHT]].map(([x, y]) => ground(v, x, y));
const xs = corners.map(([x]) => x);
const ys = corners.map(([, y]) => y);
for (let i = Math.floor(Math.min(...xs)) - 1; i <= Math.ceil(Math.max(...xs)); i++)
for (let j = Math.floor(Math.min(...ys)) - 1; j <= Math.ceil(Math.max(...ys)); j++) {
const u = flip(f, i, j);
if (u > 0) cell(ctx!, v, i, j, u, LIGHT);
}
}
/** Renders global frame `f`. */
function render(f: number): void {
if (f < FIRST || f >= END) throw new Error(`frame ${f} is outside the opening`);
const v = view(f);
field(f, v);
boxes(ctx!, v, pieces(f, v));
}
declare global {
interface Window {
opening: { first: number; end: number; render: typeof render };
}
}
window.opening = { first: FIRST, end: END, render };
+265
View File
@@ -0,0 +1,265 @@
// What the opening shows at each global frame.
import { ACCENT, model } from "../../src/model.ts";
import { bezier } from "../scenes/time.ts";
import { type Box, type Shown, type View, ground, project } from "./draw.ts";
export const FPS = 30;
export const WIDTH = 1920;
export const HEIGHT = 1080;
const BEAT = 60 / 112;
const LAND = 0.2 * FPS;
/** Bars 3 to 8: beats 8 to 32. */
export const FIRST = frameAt(8);
export const END = frameAt(32);
const settle = bezier(0.2, 0.8, 0.3, 1);
const glide = bezier(0.65, 0, 0.35, 1);
/** The frame nearest a global beat. */
export function frameAt(beat: number): number {
return Math.round(beat * BEAT * FPS);
}
/** Progress of a move of `span` frames ending on `beat`. */
function toward(f: number, beat: number, span = LAND, ease = settle): number {
const end = frameAt(beat);
return ease(Math.min(1, Math.max(0, (f - (end - span)) / span)));
}
const desk = model[0].blocks[0];
const TOP = desk.h;
const CX = desk.w / 2;
const CY = desk.d / 2;
const SEED: [number, number] = [8, 5];
/** Chebyshev ring of a cell around the seed. */
const ring = (i: number, j: number) => Math.max(Math.abs(i - SEED[0]), Math.abs(j - SEED[1]));
/** Tiles landing per sixteenth, from beat 8; the rest land on 16. */
const RAIN: Record<number, number> = {
2: 1, 4: 1, 6: 2, 8: 2, 10: 2, 12: 3, 13: 1, 14: 3, 15: 2, 16: 3, 17: 3, 18: 4, 19: 4,
20: 5, 21: 5, 22: 6, 23: 6, 24: 7, 25: 8, 26: 9, 27: 10, 28: 11, 29: 12, 30: 13, 31: 14,
};
interface Tile {
box: Box;
lands: number;
falls: number;
}
const tiles: Tile[] = (() => {
const cells = Array.from({ length: desk.w * desk.d }, (_, n) => [n % desk.w, Math.floor(n / desk.w)]);
const angle = ([i, j]: number[]) => Math.atan2(j - SEED[1], i - SEED[0]);
cells.sort((a, b) => ring(a[0], a[1]) - ring(b[0], b[1]) || angle(a) - angle(b));
const slots = Object.entries(RAIN).flatMap(([k, n]) => Array<number>(n).fill(Number(k)));
return cells.map(([i, j], n) => ({
box: { x: i, y: j, z: 0, w: 1, d: 1, h: TOP, tone: desk.tone },
lands: frameAt(8 + (slots[n] ?? 32) / 4),
falls: Math.round(20 - (11 * n) / cells.length),
}));
})();
/** Rise order follows the roads: Doc, Mark, Tree, Table, Show, PDF. */
const RISE: [string, number][] = [
["ElyDoc", 16.25], ["ElyMark", 17.5], ["ElyTree", 18.5], ["ElyTable", 19.5], ["ElyShow", 20.75], ["ElyPDF", 22],
];
const parts = RISE.map(([name, beat]) => {
const part = model.find((p) => p.name === name);
if (!part) throw new Error(`no part ${name}`);
return part.blocks.map((box, i) => ({ box, beat: beat + i / 4 }));
}).flat();
const roads = model.find((p) => p.name === "Roads")?.blocks;
if (!roads) throw new Error("model has no roads");
/** A road cut into cells, laid from one end on sixteenths. */
function lay(road: Box, n: number, beat: number, reverse = false): { box: Box; beat: number }[] {
const long = road.w >= road.d;
return Array.from({ length: n }, (_, i) => {
const k = reverse ? n - 1 - i : i;
const box = long
? { ...road, x: road.x + (k * road.w) / n, w: road.w / n }
: { ...road, y: road.y + (k * road.d) / n, d: road.d / n };
return { box, beat: beat + i / 4 };
});
}
const main = lay(roads[3], 18, 19);
const paving = [
...lay(roads[0], 1, 16.75),
{ ...main[0], beat: 17 },
...lay(roads[4], 3, 17.25),
...lay(roads[5], 4, 18),
...main.slice(1).map((c, i) => ({ ...c, beat: 19 + i / 4 })),
...lay(roads[1], 1, 20.75),
...lay(roads[2], 3, 22.5, true),
];
/** Files hop one cell a beat toward their app. */
const STEP = 0.6;
const FILE = 0.36;
const streams: { path: [number, number][]; spawns: number[] }[] = [
{ path: [[4.3, 7.9], [6.2, 7.9]], spawns: [20, 23, 26, 29] },
{ path: [[2.3, 4.3], [2.3, 4.9], [12.7, 4.9]], spawns: [22, 25, 28] },
{ path: [[6.3, 4.2], [6.3, 4.9], [12.7, 4.9]], spawns: [24, 27, 30] },
{ path: [[11, 3.15], [11, 4.9], [12.7, 4.9]], spawns: [25, 28, 31] },
];
function along(path: [number, number][], s: number): [number, number] {
for (let i = 1; i < path.length; i++) {
const [[ax, ay], [bx, by]] = [path[i - 1], path[i]];
const len = Math.hypot(bx - ax, by - ay);
if (s <= len || i === path.length - 1) {
const u = Math.min(s / len, 1);
return [ax + (bx - ax) * u, ay + (by - ay) * u];
}
s -= len;
}
throw new Error("a path needs two points");
}
const length = (path: [number, number][]) =>
path.slice(1).reduce((sum, [x, y], i) => sum + Math.hypot(x - path[i][0], y - path[i][1]), 0);
function files(f: number): Shown[] {
const out: Shown[] = [];
for (const { path, spawns } of streams) {
const total = length(path);
const hops = Math.ceil(total / STEP);
for (const spawn of spawns) {
if (f <= frameAt(spawn) - LAND) continue;
let [s, lift] = [0, 0];
for (let k = 1; k <= hops; k++) {
const u = toward(f, spawn + k);
s += STEP * u;
if (u > 0 && u < 1) lift = 0.35 * Math.sin(Math.PI * toward(f, spawn + k, LAND, (x) => x));
}
const sink = 1 - toward(f, spawn + hops + 1);
if (sink <= 0) continue;
const [x, y] = along(path, Math.min(s, total));
const drop = 1 - toward(f, spawn);
const box = { x: x - FILE / 2, y: y - FILE / 2, z: TOP + 0.08 + lift + drop, w: FILE, d: FILE, h: FILE * sink, tone: ACCENT };
out.push({ box, alpha: 1 - drop });
}
}
return out;
}
/** World turn: a quarter on bar 6, back on bar 7. */
function turn(f: number): number {
const out = toward(f, 22, 2 * BEAT * FPS, glide);
const back = toward(f, 28, 4 * BEAT * FPS, glide);
return (Math.PI / 2) * (out - back);
}
const all = model.flatMap((p) => p.blocks);
const built = all.filter((b) => b !== desk);
const flat = (t: number): View => ({ turn: t, cx: CX, cy: CY, s: 1, ox: 0, oy: 0 });
const TURNS = Array.from({ length: 19 }, (_, i) => (i * Math.PI) / 36);
function extent(list: Box[], turns: number[]): [number, number, number, number] {
let [x0, x1, y0, y1] = [Infinity, -Infinity, Infinity, -Infinity];
for (const t of turns)
for (const b of list)
for (const [i, j, k] of [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => [i, j, k])))) {
const [x, y] = project(flat(t), b.x + i * b.w, b.y + j * b.d, b.z + k * b.h);
[x0, x1, y0, y1] = [Math.min(x0, x), Math.max(x1, x), Math.min(y0, y), Math.max(y1, y)];
}
return [x0, x1, y0, y1];
}
interface Frame {
s: number;
ox: number;
oy: number;
}
/** Bars 5 and 6: the desk fills 70% of the width. */
const wide: Frame = (() => {
const [x0, x1] = extent(all, [0]);
const [u0, u1, v0, v1] = extent(all, TURNS);
const s = Math.min((0.7 * WIDTH) / (x1 - x0), (0.92 * HEIGHT) / (v1 - v0));
return { s, ox: WIDTH / 2 - (s * (u0 + u1)) / 2, oy: HEIGHT / 2 - (s * (v0 + v1)) / 2 };
})();
/** Bars 7 and 8: buildings above the lower 35%. */
const titled: Frame = (() => {
const [, , top] = extent(all, TURNS);
const [u0, u1, , bottom] = extent(built, TURNS);
const [high, low] = [70, 0.63 * HEIGHT];
const s = Math.min(wide.s, (low - high) / (bottom - top));
return { s, ox: WIDTH / 2 - (s * (u0 + u1)) / 2, oy: low - s * bottom };
})();
/** Bar 3 opens close on the seed, low in frame. */
const close: Frame = (() => {
const s = wide.s * 2.6;
const [x, y] = project(flat(0), SEED[0] + 0.5, SEED[1] + 0.5, TOP);
return { s, ox: WIDTH / 2 - s * x, oy: 0.66 * HEIGHT - s * y };
})();
function blend(a: Frame, b: Frame, u: number): Frame {
const s = a.s * (b.s / a.s) ** u;
const at = (o: number, fs: number) => (WIDTH / 2 - o) / fs;
const py = (o: number, fs: number) => (HEIGHT / 2 - o) / fs;
const [wx, wy] = [at(a.ox, a.s) + (at(b.ox, b.s) - at(a.ox, a.s)) * u, py(a.oy, a.s) + (py(b.oy, b.s) - py(a.oy, a.s)) * u];
return { s, ox: WIDTH / 2 - s * wx, oy: HEIGHT / 2 - s * wy };
}
/** The camera and turn for frame `f`. */
export function view(f: number): View {
const pull = toward(f, 16, 8 * BEAT * FPS, glide);
const rest = toward(f, 24, 2 * BEAT * FPS, glide);
const cam = blend(blend(close, wide, pull), titled, rest);
return { turn: turn(f), cx: CX, cy: CY, ...cam };
}
/** Frame when field ring `r` finishes flipping light. */
export const flips: number[] = (() => {
const fronts = Array.from({ length: 33 }, (_, k) => {
const v = view(frameAt(8 + k / 4));
const corners = [[0, 0], [WIDTH, 0], [0, HEIGHT], [WIDTH, HEIGHT]].map(([x, y]) => ground(v, x, y));
const reach = Math.max(...corners.map(([i, j]) => ring(Math.floor(i), Math.floor(j)))) + 1;
return k < 4 ? -1 : k === 32 ? Infinity : reach * ((k - 3) / 29) ** 2.4;
});
const out: number[] = [];
for (let r = 0; out.length === r; r++) {
const k = fronts.findIndex((front) => front >= r);
out.push(frameAt(8 + k / 4));
if (k === 32) break;
}
return out;
})();
/** Progress of field cell (i, j) toward light. */
export function flip(f: number, i: number, j: number): number {
const r = ring(i, j);
const end = flips[Math.min(r, flips.length - 1)];
return settle(Math.min(1, Math.max(0, (f - (end - LAND)) / LAND)));
}
/** Every box on the desk at frame `f`. */
export function pieces(f: number, v: View): Shown[] {
const out: Shown[] = [];
if (f >= frameAt(16)) out.push({ box: desk, alpha: 1 });
else
for (const t of tiles) {
if (f <= t.lands - t.falls) continue;
const u = Math.min(1, (f - (t.lands - t.falls)) / t.falls);
const [, y] = project(v, t.box.x + 0.5, t.box.y + 0.5, TOP);
const off = ((y + 80) * (1 - u * u)) / v.s;
out.push({ box: { ...t.box, z: off }, alpha: 1, seam: true });
}
for (const { box, beat } of parts) {
const u = toward(f, beat);
if (u <= 0) continue;
if (box.z <= TOP + 1e-6) out.push({ box: { ...box, h: box.h * u }, alpha: 1 });
else out.push({ box: { ...box, z: box.z + 1 - u }, alpha: u });
}
for (const { box, beat } of paving) {
const u = toward(f, beat);
if (u > 0) out.push({ box: { ...box, z: box.z + 0.6 * (1 - u) }, alpha: u });
}
return [...out, ...files(f)];
}
+57
View File
@@ -0,0 +1,57 @@
// Renders the opening's global frames to PNG, manifest and preview.
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "../scenes/node_modules/playwright/index.mjs";
import { createServer } from "vite";
const here = dirname(fileURLToPath(import.meta.url));
const site = resolve(here, "../..");
const extra = resolve(here, "../out/extra");
const dir = join(extra, "opening");
const PAGES = 6;
const server = await createServer({ root: site, configFile: false, logLevel: "error", server: { port: 0 } });
await server.listen();
const url = `${server.resolvedUrls.local[0]}video/opening/index.html`;
const browser = await chromium.launch();
/** A film-sized page; any page error stops the run. */
async function open() {
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
page.on("pageerror", (e) => {
throw e;
});
await page.goto(url, { waitUntil: "networkidle" });
return page;
}
const pages = await Promise.all(Array.from({ length: PAGES }, open));
const { first, end } = await pages[0].evaluate(() => ({ first: window.opening.first, end: window.opening.end }));
const only = process.argv.slice(2).map(Number);
const frames = only.length ? only : Array.from({ length: end - first }, (_, i) => first + i);
if (!only.length) rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const queue = [...frames];
await Promise.all(
pages.map(async (page) => {
for (let f = queue.shift(); f !== undefined; f = queue.shift()) {
await page.evaluate((f) => window.opening.render(f), f);
await page.screenshot({ path: join(dir, `${String(f).padStart(5, "0")}.png`) });
}
}),
);
await browser.close();
await server.close();
if (!only.length) {
const manifest = { name: "opening", first, count: end - first, fps: 30, kind: "base" };
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 1));
const args = ["-y", "-v", "error", "-framerate", "30", "-start_number", String(first), "-i", join(dir, "%05d.png")];
args.push("-c:v", "libx264", "-preset", "slow", "-crf", "16", "-pix_fmt", "yuv420p", "-movflags", "+faststart");
const run = spawnSync("ffmpeg", [...args, join(extra, "opening.mp4")], { stdio: "inherit" });
if (run.status !== 0) throw new Error("ffmpeg failed");
console.log(`opening: frames ${first}..${end - 1} (${end - first})`);
}
+27 -7
View File
@@ -36,25 +36,45 @@
line-height: 1;
}
.version {
top: 232px;
font-size: 40px;
font-weight: 300;
top: 936px;
font-size: 30px;
font-weight: 400;
}
.url {
top: 292px;
font-size: 28px;
top: 1000px;
font-size: 24px;
font-weight: 300;
letter-spacing: 0.01em;
}
.cap {
top: 846px;
}
.cap .en {
margin: 0;
font-size: 48px;
font-weight: 600;
letter-spacing: -0.015em;
}
.cap .zh {
margin: 14px 0 0;
font-family: "PingFang SC", sans-serif;
font-size: 30px;
font-weight: 400;
letter-spacing: 0.04em;
opacity: 0.7;
}
</style>
</head>
<body>
<svg id="frame" width="1920" height="1080" viewBox="0 0 1920 1080"><g id="world"></g></svg>
<div class="words">
<h1 data-text="title">Ely Office</h1>
<h1 data-text="name">Ely Office</h1>
<p class="version" data-text="version" data-soft>0.1.0 · macOS</p>
<h1 data-text="name">Ely Office 0.1.0</h1>
<p class="version" data-text="version" data-soft>Out now for macOS · Windows and Linux soon macOS 现已推出 · Windows 与 Linux 即将推出</p>
<p class="url" data-text="url" data-soft>github.com/ZacharyZhang-NY/ElyOffice/releases</p>
<div class="cap" data-text="offline"><p class="en">Works with the network off.</p><p class="zh">断网照常工作。</p></div>
<div class="cap" data-text="open"><p class="en">Open source, AGPL.</p><p class="zh">开源,AGPL 许可。</p></div>
<div class="cap" data-text="local"><p class="en">Your files stay in your folders.</p><p class="zh">文件留在你自己的文件夹里。</p></div>
</div>
<script type="module" src="./page.ts"></script>
</body>
+105 -29
View File
@@ -2,9 +2,11 @@
import "../../src/iso.css";
import { UNIT, block, order, project } from "../../src/iso.ts";
import { ACCENT, model } from "../../src/model.ts";
import { type Box, DESK, PAPER, SCREEN, props, screens, tiles } from "./parts.ts";
import { type Box, DESK, PAPER, SCREEN, SHEET, lamp, props, screens, tiles } from "./parts.ts";
import { BAR, BEAT, glide, toward } from "./time.ts";
import { type Moment, moment } from "./timeline.ts";
import { DESK_END, type Moment, SEED, TURN, moment } from "./timeline.ts";
import { montage, turnedBlock, turnedOrder } from "./turn.ts";
import { view } from "../opening/plan.ts";
export const WIDTH = 1920;
export const HEIGHT = 1080;
@@ -39,6 +41,7 @@ const DARK: Record<string, string> = {
[DESK]: "#3a3a3a",
[PAPER]: "#5c5c5a",
[SCREEN]: "#3a3a3c",
[SHEET]: "#7a7a78",
[ACCENT]: ACCENT,
};
const PAGE = ["#fbfbfa", "#252525"];
@@ -64,15 +67,62 @@ interface Camera {
const corners = (b: Box): [number, number][] =>
[0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => project(b.x + i * b.w, b.y + j * b.d, b.z + k * b.h))));
/** Fits the whole film's model below the title. */
const base: Camera = (() => {
const all = [...model.flatMap((p) => p.blocks), ...props.flatMap((p) => p.blocks)].flatMap(corners);
/** Frames `boxes` inside w by h, centred at (cx, cy). */
function fit(boxes: Box[], w: number, h: number, cx: number, cy: number): Camera {
const all = boxes.flatMap(corners);
const xs = all.map(([x]) => x);
const ys = all.map(([, y]) => y);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const s = Math.min(1500 / (x1 - x0), 620 / (y1 - y0));
return { s, x: WIDTH / 2 - (s * (x0 + x1)) / 2, y: 690 - (s * (y0 + y1)) / 2 };
const s = Math.min(w / (x1 - x0), h / (y1 - y0));
return { s, x: cx - (s * (x0 + x1)) / 2, y: cy - (s * (y0 + y1)) / 2 };
}
/** Eases between cameras by log scale and centre point. */
function blend(a: Camera, b: Camera, u: number): Camera {
if (u <= 0) return a;
if (u >= 1) return b;
const s = a.s * (b.s / a.s) ** u;
const ca = [(WIDTH / 2 - a.x) / a.s, (HEIGHT / 2 - a.y) / a.s];
const cb = [(WIDTH / 2 - b.x) / b.s, (HEIGHT / 2 - b.y) / b.s];
const [cx, cy] = [ca[0] + (cb[0] - ca[0]) * u, ca[1] + (cb[1] - ca[1]) * u];
return { s, x: WIDTH / 2 - s * cx, y: HEIGHT / 2 - s * cy };
}
const everything = [...model.flatMap((p) => p.blocks), ...lamp, ...props.flatMap((p) => p.blocks)];
/** Model under the title; whole model; end card. */
const hero = fit(everything, 1720, 790, WIDTH / 2, 640);
const wide = fit(everything, 1800, 960, WIDTH / 2, 545);
/** End card: below the title, or above the delivered voxel type. */
const ending = () => (hidden.has("name") ? fit(everything, 1500, 340, WIDTH / 2, 235) : fit(everything, 1440, 600, WIDTH / 2, 575));
/** The opening's last framing, in this renderer's terms. */
const opened = (() => {
const v = view(514);
const [cx, cy] = project(v.cx, v.cy, 0);
return { s: v.s / UNIT, x: v.ox - (v.s / UNIT) * cx, y: v.oy - (v.s / UNIT) * cy };
})();
const close = props.map((p) => fit(p.blocks, 1200, 640, WIDTH / 2, 420));
const seed = (() => {
const [px, py] = project(SEED[0] + 0.5, SEED[1] + 0.5, model[0].blocks[0].h);
return { s: 10, x: WIDTH / 2 - 10 * px, y: HEIGHT / 2 - 10 * py };
})();
/** The camera for model scenes at time `t`. */
function camera(t: number): Camera {
const beat = t / BEAT;
if (beat < 16) {
const x = Math.min(beat / (DESK_END + 2), 1);
const v = 1 - (1 - x) ** 1.6;
const s = 1 / (1 / seed.s + (1 / hero.s - 1 / seed.s) * v);
const c = blend(seed, hero, Math.log(s / seed.s) / Math.log(hero.s / seed.s));
return { ...c, s };
}
if (beat < 128) return blend(hero, { ...hero, s: hero.s * 1.05 }, (beat - 16) / 16);
if (beat < TURN[1]) return blend(montage, wide, glide((beat - TURN[0]) / (TURN[1] - TURN[0])));
if (beat < 144) return blend(wide, close[0], toward(t, 144, BEAT / 2, glide));
if (beat < 148) return blend(close[0], close[1], toward(t, 148, BEAT / 2, glide));
if (beat < 152) return blend(close[1], close[2], toward(t, 152, BEAT / 2, glide));
return blend(close[2], ending(), toward(t, 160, BAR, glide));
}
/** Pinned face corners: TL, TR, BL, BR of +y side. */
function face(b: Box): [number, number][] {
@@ -80,18 +130,36 @@ function face(b: Box): [number, number][] {
return [project(b.x, y, b.z + b.h), project(b.x + b.w, y, b.z + b.h), project(b.x, y, b.z), project(b.x + b.w, y, b.z)];
}
/** Pushes from the base view until the screen fills frame. */
function push(app: string, t: number, beat: number): Camera {
/** The flat window the recording lands in; matches edit/plan.json. */
const WINDOW = { x: 144, y: 40, w: 1632, h: 918 };
const RECT: [number, number][] = [
[WINDOW.x, WINDOW.y],
[WINDOW.x + WINDOW.w, WINDOW.y],
[WINDOW.x, WINDOW.y + WINDOW.h],
[WINDOW.x + WINDOW.w, WINDOW.y + WINDOW.h],
];
/** Face bounds in world pixels. */
function bounds(app: string) {
const pts = face(screens[app]);
const xs = pts.map(([x]) => x);
const ys = pts.map(([, y]) => y);
const [cx, cy] = [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
const end = Math.min((0.78 * HEIGHT) / (Math.max(...ys) - Math.min(...ys)), (0.8 * WIDTH) / (Math.max(...xs) - Math.min(...xs)));
const u = toward(t, beat + 2, 2 * BEAT, glide);
const s = base.s * (end / base.s) ** u;
const [px, py] = [base.x + base.s * cx, base.y + base.s * cy];
const [qx, qy] = [px + (WIDTH / 2 - px) * u, py + (HEIGHT / 2 - py) * u];
return { s, x: qx - s * cx, y: qy - s * cy };
return { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
}
/** Pushes toward the screen, then into it on the last beat. */
function push(app: string, t: number, beat: number): { cam: Camera; flat: number } {
const f = bounds(app);
const [cx, cy] = [(f.x0 + f.x1) / 2, (f.y0 + f.y1) / 2];
const at = (s: number, px: number, py: number): Camera => ({ s, x: px - s * cx, y: py - s * cy });
const near = Math.min((0.62 * HEIGHT) / (f.y1 - f.y0), 1060 / (f.x1 - f.x0));
const into = WINDOW.w / (f.x1 - f.x0);
const start = opened;
const aim = at(near, 1250, 520);
const full = at(into, WINDOW.x + WINDOW.w / 2, WINDOW.y + WINDOW.h / 2);
const flat = toward(t, beat + 4, BEAT, glide);
const cam = flat > 0 ? blend(aim, full, flat) : blend(start, aim, toward(t, beat + 3, 3 * BEAT, glide));
return { cam, flat };
}
const svg = document.querySelector<SVGSVGElement>("#frame");
@@ -99,12 +167,13 @@ const world = document.querySelector<SVGGElement>("#world");
if (!svg || !world) throw new Error("index.html lacks #frame or #world");
function draw(m: Moment, cam: Camera) {
const page = mix(mix("#000000", PAGE[0], m.light), PAGE[1], m.dark);
const page = mix(PAGE[0], PAGE[1], m.dark);
document.body.style.background = page;
svg!.style.background = page;
world!.setAttribute("transform", `matrix(${cam.s} 0 0 ${cam.s} ${cam.x} ${cam.y})`);
const groups = m.pieces.map(({ box, land }) => {
const g = block({ ...box, tone: tone(box.tone, m.dark) });
const groups = m.pieces.map(({ box, land, dark }) => {
const toned = { ...box, tone: tone(box.tone, dark) };
const g = m.angle ? turnedBlock(toned, m.angle) : block(toned);
if (tiles.includes(box)) g.classList.add("tile");
if (land < 1) {
g.setAttribute("transform", `translate(0 ${(-UNIT * (1 - land)).toFixed(3)})`);
@@ -112,18 +181,23 @@ function draw(m: Moment, cam: Camera) {
}
return g;
});
world!.replaceChildren(...order(m.pieces.map((p) => p.box)).map((i) => groups[i]));
const boxes = m.pieces.map((p) => p.box);
world!.replaceChildren(...(m.angle ? turnedOrder(boxes, m.angle) : order(boxes)).map((i) => groups[i]));
for (const el of document.querySelectorAll<HTMLElement>("[data-text]")) {
const shown = m.texts[el.dataset.text!] ?? 0;
const shown = hidden.has(el.dataset.text!) ? 0 : (m.texts[el.dataset.text!] ?? 0);
el.style.opacity = shown.toFixed(4);
const [day, dark] = el.dataset.soft === undefined ? INK : SOFT;
el.style.color = mix(day, dark, m.dark);
}
}
/** Screen-face corners in frame pixels, with the landing's state. */
/** Texts another layer draws instead. */
const hidden = new Set<string>();
/** Screen-face corners in frame pixels, and the flattening. */
export interface Face {
opacity: number;
flat: number;
corners: [number, number][];
}
@@ -135,23 +209,25 @@ function render(name: string, f: number): Face | null {
if (f < first || f >= end) throw new Error(`frame ${f} is outside ${name}`);
const t = f / FPS;
const app = scene.app;
const beat = scene.from * 4 + 1;
const m = moment(t, app ? { app, beat } : undefined);
const cam = app ? push(app, t, beat) : base;
const beat = scene.from * 4;
const m = moment(t, app ? { app, beat: beat + 1 } : undefined);
const { cam, flat } = app ? push(app, t, beat) : { cam: camera(t), flat: 0 };
draw(m, cam);
if (!app) return null;
const piece = m.pieces.find((p) => p.box === screens[app]);
if (!piece) return { opacity: 0, corners: [] };
if (!piece) return { opacity: 0, flat, corners: [] };
const lift = -UNIT * (1 - piece.land);
const pinned = face(piece.box).map(([x, y]) => [cam.x + cam.s * x, cam.y + cam.s * (y + lift)]);
return {
opacity: piece.land,
corners: face(piece.box).map(([x, y]) => [cam.x + cam.s * x, cam.y + cam.s * (y + lift)]),
flat,
corners: pinned.map(([x, y], i) => [x + (RECT[i][0] - x) * flat, y + (RECT[i][1] - y) * flat]),
};
}
declare global {
interface Window {
ely: { scenes: Scene[]; frames: typeof frames; render: typeof render };
ely: { scenes: Scene[]; frames: typeof frames; render: typeof render; hide: (names: string[]) => void };
}
}
window.ely = { scenes, frames, render };
window.ely = { scenes, frames, render, hide: (names) => names.forEach((n) => hidden.add(n)) };
+87 -34
View File
@@ -27,7 +27,8 @@ export const screens: Record<string, Box> = {
ElyTable: screen(5, 1.4, 2.9, 2.4),
ElyShow: on(9.4, 1.25, 3.2, 0.05, 1.8, 0.4, SCREEN),
ElyPDF: screen(13, 4.3, 2.4, 1),
ElyTree: screen(6.5, 7.9, 2.9, 2.8),
// Flush with the trunk front: hides the road edge behind.
ElyTree: screen(6.5, 8.22, 2.9, 2.8),
ElyMark: screen(1, 7.4, 3, 2),
};
@@ -43,11 +44,11 @@ const ROAD = TOP + 0.08;
/** Mark to Tree first, then four exports queue at PDF. */
export const trips: Trip[] = [
{ path: [[4.26, 7.9], [5.2, 7.9]], lands: 129, hops: [130, 131] },
{ path: [[11, 3.1], [11, 4.9], [12.74, 4.9]], lands: 130, hops: [132, 133] },
{ path: [[6.3, 4.16], [6.3, 4.9], [12.14, 4.9]], lands: 130.25, hops: [132, 133, 134, 135] },
{ path: [[2.3, 4.26], [2.3, 4.9], [11.54, 4.9]], lands: 130.5, hops: [132, 133, 134, 135, 136, 137] },
{ path: [[2.3, 6.74], [2.3, 4.9], [10.94, 4.9]], lands: 130.75, hops: [132, 133, 134, 135, 136, 137] },
{ path: [[4.26, 7.9], [5.2, 7.9]], lands: 136.5, hops: [137, 138] },
{ path: [[11, 3.1], [11, 4.9], [12.74, 4.9]], lands: 136.75, hops: [139, 140] },
{ path: [[6.3, 4.16], [6.3, 4.9], [12.14, 4.9]], lands: 137, hops: [139, 140, 141, 142] },
{ path: [[2.3, 4.26], [2.3, 4.9], [11.54, 4.9]], lands: 137.5, hops: [139, 140, 141, 142, 143, 144] },
{ path: [[2.3, 6.74], [2.3, 4.9], [10.94, 4.9]], lands: 137.75, hops: [139, 140, 141, 142, 143, 144] },
];
/** The point `s` along a polyline. */
@@ -75,38 +76,90 @@ export function file(trip: Trip, s: number): Box {
return { x: x - FILE / 2, y: y - FILE / 2, z: ROAD, w: FILE, d: FILE, h: FILE, tone: ACCENT };
}
/** Three props, each landing block by block from its bar. */
export const props: { bar: number; blocks: Box[] }[] = [
/** A lighter tone for paper sheets. */
export const SHEET = "#fbfbfa";
/** The lamp the dark spreads from; lands in bar 35. */
export const LAMP = { beat: 138, centre: [14.4, 1.9] as [number, number] };
export const lamp: Box[] = [
on(14.4, 1.4, 1, 1, 0.15),
on(14.8, 1.8, 0.2, 0.2, 2.4, 0.15),
on(13.9, 1.8, 0.9, 0.2, 0.15, 2.4),
on(13.6, 1.55, 0.7, 0.7, 0.45, 1.95),
];
/** Three props on plinths before the desk, built on sixteenths. */
export interface Prop {
bar: number;
caption: string;
blocks: Box[];
}
/** A prop's blocks, placed on a 4 by 4 plinth at (ox, oy). */
function plinth(ox: number, oy: number, list: [number, number, number, number, number, number, string?][]): Box[] {
const base: Box = { x: ox, y: oy, z: 0, w: 4, d: 4, h: TOP, tone: DESK };
const boxes = [base, ...list.map(([x, y, w, d, h, z, tone]) => on(ox + x, oy + y, w, d, h, z, tone ?? PAPER))];
return boxes.map(snap);
}
/** Snaps to 1/64 so touching faces compare exactly. */
function snap(b: Box): Box {
const r = (v: number) => Math.round(v * 64) / 64;
const [x, y, z] = [r(b.x), r(b.y), r(b.z)];
return { x, y, z, w: r(b.x + b.w) - x, d: r(b.y + b.d) - y, h: r(b.z + b.h) - z, tone: b.tone };
}
export const props: Prop[] = [
{
bar: 36,
caption: "offline",
blocks: plinth(1.5, 12.2, [
[0.3, 0.4, 0.3, 1.8, 1, 0, SHEET],
[0.3, 0.4, 0.3, 1.8, 1, 1, SHEET],
[0.6, 1, 0.2, 0.6, 0.6, 0.3, SCREEN],
[3.4, 3.45, 0.3, 0.45, 0.4, 0, ACCENT],
[3.4, 2.45, 0.3, 1, 0.4, 0, ACCENT],
[3.4, 1.45, 0.3, 1, 0.4, 0, ACCENT],
[2.9, 1.15, 0.8, 0.3, 0.4, 0, ACCENT],
[1.85, 0.95, 1.05, 0.7, 1.2, 0, SHEET],
[1.6, 1.1, 0.25, 0.4, 0.4, 0.4, SCREEN],
]),
},
{
bar: 37,
blocks: [
on(10, 7.2, 1.2, 0.6, 1.3),
on(10.2, 8.5, 0.8, 0.8, 0.6),
on(10.38, 8.15, 0.12, 0.35, 0.12, 0.25),
on(10.7, 8.15, 0.12, 0.35, 0.12, 0.25),
on(10.45, 9.3, 0.3, 1.2, 0.2),
on(10.75, 10.2, 1.4, 0.3, 0.2),
],
caption: "open",
blocks: plinth(6.7, 12.2, [
[0.6, 0.6, 1.3, 1.3, 0.15, 0],
[1.9, 0.6, 1.3, 1.3, 0.15, 0],
[0.6, 1.9, 1.3, 1.3, 0.15, 0],
[1.9, 1.9, 1.3, 1.3, 0.15, 0],
[0.6, 0.6, 2.6, 0.15, 1.3, 0.15],
[0.6, 0.75, 0.15, 2.45, 1.3, 0.15],
[3.05, 0.75, 0.15, 2.3, 1.3, 0.15],
[0.75, 3.05, 2.45, 0.15, 1.3, 0.15],
[1.4, 1.4, 1, 1, 1.7, 0.15, ACCENT],
[0.6, 0.45, 2.6, 0.15, 2, 1.45, SHEET],
[0.75, 3.2, 2.45, 0.75, 0.06, 0],
[3.2, 0.75, 0.75, 2.3, 0.06, 0],
]),
},
{
bar: 38,
blocks: [
on(12.4, 7.2, 1.9, 1.9, 0.1),
on(12.4, 7.2, 1.9, 0.12, 1.1, 0.1),
on(12.4, 7.32, 0.12, 1.78, 1.1, 0.1),
on(14.18, 7.32, 0.12, 1.66, 1.1, 0.1),
on(12.52, 8.98, 1.78, 0.12, 1.1, 0.1),
on(11.5, 7.32, 0.9, 1.66, 0.06),
on(12.52, 9.1, 1.78, 0.9, 0.06),
],
},
{
bar: 39,
blocks: [
on(14.6, 8.2, 1.3, 0.12, 1.5),
on(14.6, 8.2, 0.55, 0.12, 0.2, 1.5),
on(14.75, 8.32, 1, 0.08, 1.4, 0, ACCENT),
on(14.6, 8.4, 1.3, 0.12, 1.15),
],
caption: "local",
blocks: plinth(11.9, 12.2, [
[0.4, 1.6, 0.8, 0.2, 2.6, 0],
[1.2, 1.6, 0.8, 0.2, 2.6, 0],
[2, 1.6, 0.8, 0.2, 2.6, 0],
[2.8, 1.6, 0.8, 0.2, 2.6, 0],
[0.4, 1.6, 1.2, 0.2, 0.35, 2.6, ACCENT],
[0.7, 1.8, 2.6, 0.15, 2.9, 0, SHEET],
[1, 1.95, 2, 0.03, 0.1, 2.6],
[1, 1.95, 1.6, 0.03, 0.1, 2.35],
[1, 1.95, 1.8, 0.03, 0.1, 2.1],
[0.4, 1.98, 0.8, 0.2, 2, 0],
[1.2, 1.98, 0.8, 0.2, 2, 0],
[2, 1.98, 0.8, 0.2, 2, 0],
[2.8, 1.98, 0.8, 0.2, 2, 0],
]),
},
];
+8 -1
View File
@@ -1,6 +1,6 @@
// Renders iso scenes to PNG frames, faces and MP4s.
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
@@ -10,6 +10,11 @@ const here = dirname(fileURLToPath(import.meta.url));
const site = resolve(here, "../..");
const out = resolve(here, "../out/scenes");
const PAGES = 6;
/** Texts that delivered voxel type replaces. */
const extra = resolve(here, "../out/extra");
const hide = [["type-title", ["title"]], ["type-end", ["name", "version", "url"]]]
.filter(([d]) => existsSync(join(extra, d)))
.flatMap(([, t]) => t);
const server = await createServer({ root: site, configFile: false, logLevel: "error", server: { port: 0 } });
await server.listen();
@@ -27,12 +32,14 @@ async function open() {
});
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
await page.evaluate((names) => window.ely.hide(names), hide);
return page;
}
const pages = await Promise.all(Array.from({ length: PAGES }, open));
const all = await pages[0].evaluate(() => window.ely.scenes.map((s) => ({ ...s, range: window.ely.frames(s) })));
const wanted = process.argv.slice(2);
if (hide.length) console.log(`hidden: ${hide.join(", ")}`);
const unknown = wanted.filter((name) => !all.some((s) => s.name === name));
if (unknown.length) throw new Error(`unknown scenes: ${unknown.join(", ")}`);
const scenes = wanted.length ? all.filter((s) => wanted.includes(s.name)) : all;
+57 -33
View File
@@ -1,74 +1,95 @@
// The model at any moment, as pieces and texts.
import { model } from "../../src/model.ts";
import { type Box, file, length, props, screens, tiles, trips } from "./parts.ts";
import { LAMP, type Box, file, lamp, length, props, screens, tiles, trips } from "./parts.ts";
import { BEAT, LAND, begun, glide, toward } from "./time.ts";
import { slabs } from "./turn.ts";
/** A box and its landing progress, 0 to 1. */
/** A box, its landing progress, and whether it is dark. */
export interface Piece {
box: Box;
land: number;
dark: number;
}
/** Everything one frame shows, before the camera. */
export interface Moment {
pieces: Piece[];
/** 0 is black, 1 is the page. */
light: number;
/** 0 is the light palette, 1 the dark one. */
/** The page and texts: 0 light, 1 dark. */
dark: number;
/** The model's turn about the desk centre, radians. */
angle: number;
texts: Record<string, number>;
}
/** The desk grows in rings around this tile. */
const SEED = [8, 5];
/** The desk grows outward from this tile. */
export const SEED = [8, 5];
const ring = (b: Box) => Math.abs(b.x - SEED[0]) + Math.abs(b.y - SEED[1]);
const angle = (b: Box) => Math.atan2(b.y - SEED[1], b.x - SEED[0]);
/** Ring k lands from beat k + 1, by quarters. */
const tileBeats = tiles.map((tile) => {
const k = ring(tile);
const mates = tiles.filter((t) => ring(t) === k).sort((a, b) => angle(a) - angle(b));
const quarter = Math.floor((mates.indexOf(tile) * 4) / mates.length);
return k + 1 + quarter / 4;
/** Tiles land faster and faster: count grows as time squared. */
export const DESK_END = 12;
const byRing = tiles.map((_, i) => i).sort((a, b) => ring(tiles[a]) - ring(tiles[b]) || angle(tiles[a]) - angle(tiles[b]));
const tileBeats: number[] = [];
byRing.forEach((tile, n) => {
tileBeats[tile] = Math.round(DESK_END * Math.sqrt(n / (tiles.length - 1)) * 4) / 4;
});
const DESK_DONE = 15;
const DESK_DONE = DESK_END + 1;
/** Beat each app starts on; its blocks follow on sixteenths. */
const partBeats = [16, 18, 21, 23, 25, 27, 29];
const partBeats = [16, 18, 20, 22, 24, 26, 28];
/** Beats of each text's fade, ending on that beat. */
/** Fade in ending on the first beat; gone at the second. */
const TEXTS: Record<string, [number, number]> = {
title: [31, 32],
name: [161, 168],
version: [161.5, 168],
url: [162, 168],
title: [24, 32],
offline: [144.5, 147.5],
open: [148.5, 151.5],
local: [152.5, 155.5],
name: [160, 168.1],
version: [160.5, 168.1],
url: [161, 168.1],
};
const DARK = 136;
/** Bar 35 turns back from the montage's -90°. */
export const TURN: [number, number] = [136, 140];
/** The dark wave: from the lamp, one step per sixteenth. */
export const WAVE = 140;
export const NIGHT = 144;
const lampFar = Math.max(...tiles.map((t) => Math.hypot(t.x + 0.5 - LAMP.centre[0], t.y + 0.5 - LAMP.centre[1])));
function flip(b: Box): number {
const d = Math.hypot(b.x + b.w / 2 - LAMP.centre[0], b.y + b.d / 2 - LAMP.centre[1]);
return WAVE + Math.min(15, Math.floor((16 * d) / lampFar)) / 4;
}
function landed(t: number, box: Box, beat: number, out: Piece[]) {
if (begun(t, beat)) out.push({ box, land: toward(t, beat) });
if (begun(t, beat)) out.push({ box, land: toward(t, beat), dark: t >= flip(box) * BEAT ? 1 : 0 });
}
/** A trip's file: dropped at its start, then hopping. */
function travel(t: number, trip: (typeof trips)[number]): Piece | null {
if (!begun(t, trip.lands)) return null;
function travel(t: number, trip: (typeof trips)[number], out: Piece[]) {
if (!begun(t, trip.lands)) return;
const step = length(trip.path) / trip.hops.length;
const s = trip.hops.reduce((sum, beat) => sum + step * toward(t, beat), 0);
return { box: file(trip, s), land: toward(t, trip.lands) };
out.push({ box: file(trip, s), land: toward(t, trip.lands), dark: 0 });
}
/** The model at `t`, plus a plate's screen. */
export function moment(t: number, screen?: { app: string; beat: number }): Moment {
const pieces: Piece[] = [];
if (t < DESK_DONE * BEAT) tiles.forEach((tile, i) => landed(t, tile, tileBeats[i], pieces));
else pieces.push({ box: model[0].blocks[0], land: 1 });
const wave = t >= (WAVE - 0.01) * BEAT && t < NIGHT * BEAT;
if (t < DESK_DONE * BEAT || wave) tiles.forEach((tile, i) => landed(t, tile, tileBeats[i], pieces));
else landed(t, model[0].blocks[0], 0, pieces);
model.slice(1).forEach((part, p) => part.blocks.forEach((b, i) => landed(t, b, partBeats[p] + i / 4, pieces)));
for (const trip of trips) {
const piece = travel(t, trip);
if (piece) pieces.push(piece);
lamp.forEach((b, i) => landed(t, b, LAMP.beat + i / 4, pieces));
for (const trip of trips) travel(t, trip, pieces);
for (const prop of props) {
prop.blocks.forEach((box, i) => {
if (begun(t, prop.bar * 4 + i / 4)) pieces.push({ box, land: toward(t, prop.bar * 4 + i / 4), dark: 1 });
});
}
if (t >= TURN[0] * BEAT - 1e-6 && t < 138 * BEAT) {
slabs.forEach((box, i) => pieces.push({ box, land: 1 - toward(t, 136.5 + i / 4), dark: 0 }));
}
for (const prop of props) prop.blocks.forEach((b, i) => landed(t, b, (prop.bar - 1) * 4 + i / 4, pieces));
if (screen) {
const box = screens[screen.app];
if (!box) throw new Error(`no screen for ${screen.app}`);
@@ -76,7 +97,10 @@ export function moment(t: number, screen?: { app: string; beat: number }): Momen
}
const texts: Record<string, number> = {};
for (const [name, [from, to]] of Object.entries(TEXTS)) {
if (!screen && t < to * BEAT) texts[name] = toward(t, from, LAND, glide);
if (screen || t >= to * BEAT) continue;
const out = to < 168 ? 1 - toward(t, to, LAND, glide) : 1;
texts[name] = toward(t, from, LAND, glide) * out;
}
return { pieces, light: toward(t, 0.5, LAND, glide), dark: toward(t, DARK, LAND, glide), texts };
const turn = t < TURN[0] * BEAT || t >= TURN[1] * BEAT ? 0 : 1 - glide((t / BEAT - TURN[0]) / (TURN[1] - TURN[0]));
return { pieces, dark: t >= NIGHT * BEAT ? 1 : 0, angle: (-Math.PI / 2) * turn, texts };
}
+136
View File
@@ -0,0 +1,136 @@
// The model turned about the desk centre, as the montage draws it.
import { project } from "../../src/iso.ts";
import { model } from "../../src/model.ts";
import { type Box, SCREEN, screens } from "./parts.ts";
const SVG = "http://www.w3.org/2000/svg";
export const PIVOT = [8, 5.5];
const COS = Math.cos(Math.PI / 6);
const TOP = model[0].blocks[0].h;
/** A world point turned by `a`, then projected. */
export function turned(a: number, x: number, y: number, z: number): [number, number] {
const [dx, dy] = [x - PIVOT[0], y - PIVOT[1]];
return project(PIVOT[0] + dx * Math.cos(a) - dy * Math.sin(a), PIVOT[1] + dx * Math.sin(a) + dy * Math.cos(a), z);
}
/** The montage's grown screens, which leave in bar 35. */
export const slabs: Box[] = ["ElyMark", "ElyTable", "ElyShow", "ElyDoc", "ElyPDF", "ElyTree"].map((app) => {
if (app === "ElyShow") return { x: 8.6, y: 2, z: TOP + 0.25, w: 4.8, d: 0.08, h: 2.7, tone: SCREEN };
// The montage drew Tree's screen at its old depth.
const b = app === "ElyTree" ? { ...screens[app], y: 7.9 } : screens[app];
return { ...b, x: b.x - b.w / 4, w: b.w * 1.5, h: b.h * 1.5 };
});
/** The montage's fixed framing, in this renderer's camera terms. */
export const montage = (() => {
const boxes = [...model.flatMap((p) => p.blocks), ...slabs, ...slabs.map((b) => ({ ...b, z: b.z + 1 }))];
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i <= 90; i++) {
const a = (-i * Math.PI) / 180;
for (const b of boxes) for (const [x, y, z] of corners(b)) {
const [dx, dy] = [x - PIVOT[0], y - PIVOT[1]];
const [rx, ry] = [dx * Math.cos(a) - dy * Math.sin(a), dx * Math.sin(a) + dy * Math.cos(a)];
xs.push((rx - ry) * COS);
ys.push((rx + ry) / 2 - z);
}
}
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const S = Math.min(1800 / (x1 - x0), 1000 / (y1 - y0));
const [X, Y] = [960 - (S * (x0 + x1)) / 2, 540 - (S * (y0 + y1)) / 2];
const [cx, cy] = [(PIVOT[0] - PIVOT[1]) * COS, (PIVOT[0] + PIVOT[1]) / 2];
const [, unit] = project(0, 0, -1);
return { s: S / unit, x: X - S * cx, y: Y - S * cy };
})();
function corners(b: Box): [number, number, number][] {
return [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => [b.x + i * b.w, b.y + j * b.d, b.z + k * b.h] as [number, number, number])));
}
/** Side darkness by turned normal: +y 9%, +x 20%. */
function darkness(nx: number, ny: number): number {
const u = Math.atan2(ny, nx) - Math.PI / 2;
return 0.145 - 0.055 * (Math.cos(u) + Math.sin(u));
}
function polygon(points: [number, number][], fill: string): SVGPolygonElement {
const el = document.createElementNS(SVG, "polygon");
el.setAttribute("points", points.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "));
el.style.fill = fill;
return el;
}
/** Convex hull of points, for the seam-hiding outline. */
function hull(points: [number, number][]): [number, number][] {
const p = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const cross = (o: number[], a: number[], b: number[]) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const half = (list: [number, number][]) => {
const out: [number, number][] = [];
for (const q of list) {
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
out.push(q);
}
return out.slice(0, -1);
};
return [...half(p), ...half([...p].reverse())];
}
/** A block turned by `a`: visible sides, then the top. */
export function turnedBlock(b: Box, a: number): SVGGElement {
const P = (x: number, y: number, z: number) => turned(a, x, y, z);
const [x, y, z, X, Y, Z] = [b.x, b.y, b.z, b.x + b.w, b.y + b.d, b.z + b.h];
const sides: { n: [number, number]; c: [number, number, number][] }[] = [
{ n: [1, 0], c: [[X, y, Z], [X, Y, Z], [X, Y, z], [X, y, z]] },
{ n: [-1, 0], c: [[x, Y, Z], [x, y, Z], [x, y, z], [x, Y, z]] },
{ n: [0, 1], c: [[X, Y, Z], [x, Y, Z], [x, Y, z], [X, Y, z]] },
{ n: [0, -1], c: [[x, y, Z], [X, y, Z], [X, y, z], [x, y, z]] },
];
const shade = (d: number) => `color-mix(in oklab, ${b.tone}, black ${(d * 100).toFixed(2)}%)`;
const g = document.createElementNS(SVG, "g");
g.append(polygon(hull(corners(b).map(([px, py, pz]) => P(px, py, pz))), shade(0.09)));
for (const { n, c } of sides) {
const [nx, ny] = [n[0] * Math.cos(a) - n[1] * Math.sin(a), n[0] * Math.sin(a) + n[1] * Math.cos(a)];
if (nx + ny <= 1e-9) continue;
g.append(polygon(c.map(([px, py, pz]) => P(px, py, pz)), shade(darkness(nx, ny))));
}
g.append(polygon([P(x, y, Z), P(X, y, Z), P(X, Y, Z), P(x, Y, Z)], b.tone));
return g;
}
/** Back to front for a view turned by `a`. */
export function turnedOrder(list: Box[], a: number): number[] {
const view = [Math.cos(a) + Math.sin(a), Math.cos(a) - Math.sin(a)];
const rect = (b: Box) => {
const pts = corners(b).map(([x, y, z]) => turned(a, x, y, z));
const xs = pts.map(([px]) => px);
const ys = pts.map(([, py]) => py);
return [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
};
const rects = list.map(rect);
const meet = (i: number, j: number) =>
rects[i][0] < rects[j][1] - 0.01 && rects[j][0] < rects[i][1] - 0.01 && rects[i][2] < rects[j][3] - 0.01 && rects[j][2] < rects[i][3] - 0.01;
const lo = (b: Box) => [b.x, b.y, b.z];
const hi = (b: Box) => [b.x + b.w, b.y + b.d, b.z + b.h];
// Separating axes agree when the blocks truly overlap.
const behind = (p: Box, q: Box): boolean | null => {
const sign = [view[0], view[1], 1];
const votes = new Set<boolean>();
for (let k = 0; k < 3; k++) {
if (Math.abs(sign[k]) < 1e-6) continue;
if (hi(p)[k] <= lo(q)[k]) votes.add(sign[k] > 0);
if (hi(q)[k] <= lo(p)[k]) votes.add(sign[k] < 0);
}
return votes.size === 1 ? [...votes][0] : null;
};
const before = list.map((q, i) => list.flatMap((p, j) => (j !== i && meet(i, j) && behind(p, q) === true ? [j] : [])));
const done = new Set<number>();
const out: number[] = [];
while (out.length < list.length) {
const next = list.findIndex((_, i) => !done.has(i) && before[i].every((j) => done.has(j)));
if (next < 0) throw new Error("turned blocks occlude in a cycle");
done.add(next);
out.push(next);
}
return out;
}
+127
View File
@@ -0,0 +1,127 @@
// Cubes and flat text on a 2× canvas.
/** Film clock: 112 BPM. */
export const BEAT = 60 / 112;
export const BAR = 4 * BEAT;
export const SIX = BEAT / 4;
/** Start of 1-based bar `b`, in seconds. */
export const bar = (b) => (b - 1) * BAR;
/** CSS cubic-bezier timing, solved by bisection. */
function bezier(x1, y1, x2, y2) {
const at = (a, b, u) => 3 * a * u * (1 - u) ** 2 + 3 * b * u * u * (1 - u) + u ** 3;
return (x) => {
if (x <= 0) return 0;
if (x >= 1) return 1;
let [lo, hi] = [0, 1];
for (let i = 0; i < 40; i++) {
const mid = (lo + hi) / 2;
if (at(x1, x2, mid) < x) lo = mid;
else hi = mid;
}
return at(y1, y2, (lo + hi) / 2);
};
}
export const settle = bezier(0.2, 0.8, 0.3, 1);
export const glide = bezier(0.65, 0, 0.35, 1);
export const clamp = (u) => Math.min(1, Math.max(0, u));
const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const gam = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
/** `hex` mixed with black `p` in oklab, as CSS rgb. */
function shade(hex, p) {
const [r, g, b] = [1, 3, 5].map((i) => lin(parseInt(hex.slice(i, i + 2), 16) / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) * (1 - p);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) * (1 - p);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) * (1 - p);
const [L, M, S] = [l ** 3, m ** 3, s ** 3];
const out = [
4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
-0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S,
];
return `rgb(${out.map((c) => Math.round(clamp(gam(c)) * 255)).join(",")})`;
}
const tones = new Map();
/** Top, left and right fills of a tone. */
function faces(tone) {
if (!tones.has(tone)) tones.set(tone, [tone, shade(tone, 0.09), shade(tone, 0.2)]);
return tones.get(tone);
}
/** Orthographic view: isometric pitch, chosen yaw about z. */
export function view(yaw, cell, ox = 0, oy = 0) {
const pitch = Math.atan(1 / Math.SQRT2);
const [ca, sa, se] = [Math.cos(yaw), Math.sin(yaw), Math.sin(pitch)];
const k = 1 / Math.cos(pitch);
return {
cell,
ox,
oy,
p: (x, y, z) => [ox + (x * ca - y * sa) * k * cell, oy + ((x * sa + y * ca) * se * k - z) * cell],
depth: (x, y, z) => x * sa + y * ca + z * Math.SQRT1_2,
};
}
/** Screen bounds of boxes under view `v`. */
export function bounds(v, boxes) {
const pts = boxes.flatMap((b) =>
[0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => v.p(b.x + i * b.w, b.y + j * b.d, b.z + k * b.h)))),
);
const xs = pts.map(([x]) => x);
const ys = pts.map(([, y]) => y);
return { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
}
function poly(ctx, pts) {
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.closePath();
ctx.fill();
}
/** One box: outline, left, right, top; seams hidden. */
function cube(ctx, v, b) {
const [x0, y0, z0, x1, y1, z1] = [b.x, b.y, b.z, b.x + b.w, b.y + b.d, b.z + b.h];
const [top, left, right] = faces(b.tone);
const p = v.p;
ctx.globalAlpha = b.a ?? 1;
if (ctx.globalAlpha >= 1) {
ctx.fillStyle = left;
poly(ctx, [p(x0, y1, z0), p(x1, y1, z0), p(x1, y0, z0), p(x1, y0, z1), p(x0, y0, z1), p(x0, y1, z1)]);
}
ctx.fillStyle = left;
poly(ctx, [p(x0, y1, z0), p(x1, y1, z0), p(x1, y1, z1), p(x0, y1, z1)]);
ctx.fillStyle = right;
poly(ctx, [p(x1, y1, z0), p(x1, y0, z0), p(x1, y0, z1), p(x1, y1, z1)]);
ctx.fillStyle = top;
poly(ctx, [p(x0, y0, z1), p(x1, y0, z1), p(x1, y1, z1), p(x0, y1, z1)]);
ctx.globalAlpha = 1;
}
/** Boxes back to front under view `v`. */
export function cubes(ctx, v, boxes) {
const key = (b) => v.depth(b.x + b.w / 2, b.y + b.d / 2, b.z + b.h / 2);
[...boxes]
.filter((b) => (b.a ?? 1) > 0.002)
.sort((a, b) => key(a) - key(b))
.forEach((b) => cube(ctx, v, b));
}
/** Flat system text; CJK falls to PingFang SC. */
export function text(ctx, { s, x, y, size, weight = 200, color, a = 1, align = "center", track = 0 }) {
if (a <= 0) return;
ctx.globalAlpha = a;
ctx.fillStyle = color;
ctx.font = `${weight} ${size}px "Ely SF", "PingFang SC", sans-serif`;
ctx.letterSpacing = `${track}px`;
ctx.textAlign = align;
ctx.textBaseline = "alphabetic";
ctx.fillText(s, x, y);
ctx.globalAlpha = 1;
}
+56
View File
@@ -0,0 +1,56 @@
// A cube font: caps 7 high, x-height 5, descender 2.
/** Rows 0–8, baseline row 6; '#' marks a cube. */
const SHAPES = {
E: ["#####", "#....", "#....", "####.", "#....", "#....", "#####"],
L: ["#....", "#....", "#....", "#....", "#....", "#....", "#####"],
Y: ["#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."],
O: [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
D: ["####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."],
T: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."],
S: [".####", "#....", "#....", ".###.", "....#", "....#", "####."],
P: ["####.", "#...#", "#...#", "####.", "#....", "#....", "#...."],
F: ["#####", "#....", "#....", "####.", "#....", "#....", "#...."],
M: ["#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#", "#...#"],
l: ["##.", ".#.", ".#.", ".#.", ".#.", ".#.", ".##"],
y: ["....", "....", "#..#", "#..#", "#..#", "#..#", ".###", "...#", "###."],
f: ["..##", ".#..", "####", ".#..", ".#..", ".#..", ".#.."],
i: ["#", ".", "#", "#", "#", "#", "#"],
c: ["....", "....", ".###", "#...", "#...", "#...", ".###"],
e: ["....", "....", ".##.", "#..#", "####", "#...", ".###"],
o: ["....", "....", ".##.", "#..#", "#..#", "#..#", ".##."],
a: ["....", "....", ".##.", "...#", ".###", "#..#", ".###"],
b: ["#...", "#...", "###.", "#..#", "#..#", "#..#", "###."],
h: ["#...", "#...", "###.", "#..#", "#..#", "#..#", "#..#"],
w: [".....", ".....", "#...#", "#...#", "#.#.#", "#.#.#", ".#.#."],
r: ["....", "....", "#.##", "##..", "#...", "#...", "#..."],
k: ["#...", "#...", "#..#", "#.#.", "##..", "#.#.", "#..#"],
0: [".##.", "#..#", "#..#", "#..#", "#..#", "#..#", ".##."],
1: [".#.", "##.", ".#.", ".#.", ".#.", ".#.", "###"],
".": [".", ".", ".", ".", ".", ".", "#"],
"·": [".", ".", ".", "#", ".", ".", "."],
};
/** Columns a space takes. */
const SPACE = 3;
/** Cells of `text`: col, row from top, glyph index. */
export function cells(text) {
const out = [];
let col = 0;
[...text].forEach((ch, glyph) => {
if (ch === " ") {
col += SPACE - 1;
return;
}
const rows = SHAPES[ch];
if (!rows) throw new Error(`no glyph for ${ch}`);
rows.forEach((line, row) =>
[...line].forEach((c, i) => {
if (c === "#") out.push({ col: col + i, row, glyph });
}),
);
col += rows[0].length + 1;
});
return { cells: out, width: col - 1 };
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Ely voxel type</title>
<style>
body {
margin: 0;
font-family: -apple-system, "PingFang SC", sans-serif;
}
</style>
</head>
<body>
<span style="font-weight: 300">Ely 六个桌面应用</span>
<script type="module" src="./main.js"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
// Page entry: renders one scene frame to PNG data.
import { SCENES } from "./scenes.js";
const sf = new FontFace("Ely SF", "url(/SFNS.ttf)", { weight: "1 1000" });
document.fonts.add(await sf.load());
await document.fonts.load('300 20px "PingFang SC"', "六");
const big = document.createElement("canvas");
big.width = 3840;
big.height = 2160;
const out = document.createElement("canvas");
out.width = 1920;
out.height = 1080;
/** Global frame `f` of scene `name`, as base64 PNG. */
function render(name, f) {
const scene = SCENES.find((s) => s.name === name);
if (!scene) throw new Error(`unknown scene ${name}`);
const ctx = big.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, big.width, big.height);
ctx.setTransform(2, 0, 0, 2, 0, 0);
scene.draw(ctx, f / 30);
const small = out.getContext("2d");
small.clearRect(0, 0, 1920, 1080);
small.imageSmoothingEnabled = true;
small.imageSmoothingQuality = "high";
small.drawImage(big, 0, 0, 1920, 1080);
return out.toDataURL("image/png").split(",")[1];
}
window.type = { scenes: SCENES.map(({ draw, ...s }) => s), render };
+88
View File
@@ -0,0 +1,88 @@
// Renders voxel titles to PNG frames, manifests and previews.
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "../scenes/node_modules/playwright/index.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const out = resolve(here, "../out/extra");
const PAGES = 4;
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".ttf": "font/ttf" };
const SF = "/System/Library/Fonts/SFNS.ttf";
const server = createServer((req, res) => {
const name = new URL(req.url, "http://x").pathname;
if (name === "/SFNS.ttf") {
res.writeHead(200, { "content-type": TYPES[".ttf"] }).end(readFileSync(SF));
return;
}
const path = join(here, name);
const type = TYPES[extname(path)];
if (!type || !path.startsWith(here)) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": type }).end(readFileSync(path));
});
await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
const url = `http://127.0.0.1:${server.address().port}/index.html`;
const browser = await chromium.launch();
/** A page; any page error stops the run. */
async function open() {
const page = await browser.newPage({ viewport: { width: 400, height: 200 } });
page.on("pageerror", (e) => {
throw e;
});
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(() => window.type);
return page;
}
const pages = await Promise.all(Array.from({ length: PAGES }, open));
const all = await pages[0].evaluate(() => window.type.scenes);
const png = async (page, name, f) => Buffer.from(await page.evaluate(([n, f]) => window.type.render(n, f), [name, f]), "base64");
/** Each scene's frames, manifest and preview MP4. */
async function scene(page, s) {
const dir = join(out, s.name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
for (let f = s.first; f < s.first + s.count; f++) writeFileSync(join(dir, `${String(f).padStart(5, "0")}.png`), await png(page, s.name, f));
const manifest = { name: s.name, first: s.first, count: s.count, fps: 30, kind: s.kind };
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 1));
const input = ["-framerate", "30", "-start_number", String(s.first), "-i", join(dir, "%05d.png")];
const paper = ["-f", "lavfi", "-i", `color=c=0xfbfbfa:s=1920x1080:r=30:d=${s.count / 30}`];
const args = s.kind === "over" ? [...paper, ...input, "-filter_complex", "[0][1]overlay=shortest=1"] : input;
const enc = ["-c:v", "libx264", "-preset", "slow", "-crf", "14", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
const run = spawnSync("ffmpeg", ["-y", "-v", "error", ...args, ...enc, join(out, `${s.name}.mp4`)], { stdio: "inherit" });
if (run.status !== 0) throw new Error(`ffmpeg failed for ${s.name}`);
console.log(`${s.name}: ${s.kind} frames ${s.first}..${s.first + s.count - 1}`);
}
/** Stills of chosen global frames, for looking. */
async function stills(dir, frames) {
mkdirSync(dir, { recursive: true });
for (const f of frames) {
const s = all.find((s) => f >= s.first && f < s.first + s.count);
if (!s) throw new Error(`no scene at frame ${f}`);
writeFileSync(join(dir, `${s.name}-${f}.png`), await png(pages[0], s.name, f));
}
}
const args = process.argv.slice(2);
if (args[0] === "--stills") {
await stills(resolve(args[1]), args.slice(2).map(Number));
} else {
const unknown = args.filter((n) => !all.some((s) => s.name === n));
if (unknown.length) throw new Error(`unknown scenes: ${unknown.join(", ")}`);
const queue = all.filter((s) => !args.length || args.includes(s.name));
await Promise.all(pages.map(async (page) => {
for (let s = queue.shift(); s; s = queue.shift()) await scene(page, s);
}));
}
await browser.close();
server.close();
+180
View File
@@ -0,0 +1,180 @@
// The film's voxel titles, timed on sixteenths.
import { cells } from "./glyphs.js";
import { BAR, BEAT, SIX, bar, bounds, clamp, cubes, settle, text, view } from "./draw.js";
const DARK_FIELD = "#252525";
const LIGHT = "#e9e9e6";
const INK = "#4d4d51";
const ACCENT = "#3d7fe5";
const SOFT = "#6e6e73";
const BODY = "#48484a";
const YAW = (12 * Math.PI) / 180;
const GAP = 0.06;
const LAND = 0.2;
/** Cubes of `s`; `tone` picks a tone per glyph. */
function word(s, tone) {
return cells(s).cells.map((c) => ({ ...c, tone: tone(c.glyph) }));
}
/** A cell's box, raised `dz`, height scaled `h`. */
function box(c, dz = 0, a = 1, h = 1) {
const s = 1 - 2 * GAP;
return { x: c.col + GAP, y: GAP, z: 6 - c.row + GAP + dz, w: s, d: s, h: s * h, tone: c.tone, a };
}
/** Each cell's slot: a rising sweep, `n` sixteenths wide. */
function slots(list, n, lean = 0.5) {
const key = (c) => c.col + lean * (6 - c.row);
const keys = list.map(key);
const [lo, hi] = [Math.min(...keys), Math.max(...keys)];
return list.map((c) => Math.min(n - 1, Math.floor(((key(c) - lo) / (hi - lo + 1e-6)) * n)));
}
/** Drop one cell, landing at `land`; null before. */
function arrive(t, land) {
const p = (t - (land - LAND)) / LAND;
if (p <= 0) return null;
const e = settle(clamp(p));
return { dz: 1 - e, a: e };
}
/** Lift one cell and fade, ending at `end`. */
function lift(t, end, span) {
const e = settle(clamp((t - (end - span)) / span));
return { dz: e, a: 1 - e };
}
/** View placing `list` with its bounds at an anchor. */
function place(list, cell, { cx, left, cy }) {
const b = bounds(view(YAW, cell), list.map((c) => box(c)));
const ox = left !== undefined ? left - b.x0 : cx - (b.x0 + b.x1) / 2;
return view(YAW, cell, ox, cy - (b.y0 + b.y1) / 2);
}
/** Scales view `v` by `s` about screen point (px, py). */
function zoom(v, s, px, py) {
return view(YAW, v.cell * s, px + (v.ox - px) * s, py + (v.oy - py) * s);
}
/** Screen y just below the word, left of screen `x`. */
function under(v, x) {
let c = 0;
while (v.p(c, 1, -2)[0] < x) c++;
return v.p(c, 1, -2)[1];
}
/** Small stable noise per cell. */
const hash = (c, k) => {
const x = Math.sin(c.col * 127.1 + c.row * 311.7 + k * 74.7) * 43758.5453;
return x - Math.floor(x);
};
/** Bars 1–2: ELY builds, then breaks into falling tiles. */
function ely(ctx, t) {
ctx.fillStyle = DARK_FIELD;
ctx.fillRect(0, 0, 1920, 1080);
const list = word("ELY", () => LIGHT);
const last = list.find((c) => c.col === 14 && c.row === 6);
last.tone = ACCENT;
const rest = list.filter((c) => c !== last);
const slot = new Map(rest.map((c, i) => [c, slots(rest, 20)[i]]));
slot.set(last, 23);
const end = 2 * BAR;
const u = clamp(t / end);
const v = zoom(place(list, 46, { cx: 960, cy: 520 }), 1.42 - 0.42 * (1 - (1 - u) ** 1.6), 960, 540);
const boxes = list.flatMap((c) => {
const at = arrive(t, (slot.get(c) + 1) * SIX);
if (!at) return [];
const start = (28 + Math.floor((c.col / 17) * 4)) * SIX;
const τ = t - start;
if (τ <= 0) return [box(c, at.dz, at.a)];
const flat = settle(clamp(τ / 0.1));
const b = box(c, -48 * τ * τ, 1, 1 - 0.78 * flat);
b.x += (hash(c, 1) - 0.5) * 5 * τ;
b.y += (0.4 + hash(c, 2)) * 9 * τ * τ;
return [b];
});
cubes(ctx, v, boxes);
}
/** Bars 7–8: "Ely Office" stands in the lower third. */
function title(ctx, t) {
const t0 = bar(7);
const end = bar(9);
const list = word("Ely Office", () => INK);
const slot = slots(list, 6);
const v = place(list, 22, { cx: 960, cy: 780 });
const left = bounds(v, list.map((c) => box(c))).x0;
const gone = lift(t, end, SIX);
const boxes = list.flatMap((c, i) => {
const at = arrive(t, t0 + (slot[i] + 1) * SIX);
return at ? [box(c, at.dz + gone.dz, at.a * gone.a)] : [];
});
cubes(ctx, v, boxes);
const a = settle(clamp((t - (bar(8) - LAND)) / LAND)) * gone.a;
const y = under(v, left + 300) + 52;
text(ctx, { s: "Six desktop apps", x: left, y, size: 34, color: BODY, a, align: "left" });
text(ctx, { s: "六个桌面应用", x: left, y: y + 46, size: 28, color: SOFT, a, align: "left", track: 2 });
}
/** An app's first bar: its name builds at left. */
function app(name, b) {
return (ctx, t) => {
const t0 = bar(b);
const list = word(name, () => INK);
const build = slots(list, 8);
const out = slots(list, 4, 0);
const v = place(list, 16, { left: 112, cy: 800 });
const boxes = list.flatMap((c, i) => {
const at = arrive(t, t0 + (build[i] + 1) * SIX);
if (!at) return [];
const gone = lift(t, t0 + (13 + out[i]) * SIX, LAND);
return [box(c, at.dz + gone.dz, at.a * gone.a)];
});
cubes(ctx, v, boxes);
};
}
/** Bars 41–42: name, version, availability, address. */
function end(ctx, t) {
const t0 = bar(41);
const list = word("Ely Office 0.1.0", (g) => (g >= 11 ? ACCENT : LIGHT));
const name = list.filter((c) => c.glyph < 11);
const version = list.filter((c) => c.glyph >= 11);
const slot = new Map([
...name.map((c, i) => [c, slots(name, 8)[i]]),
...version.map((c, i) => [c, 8 + slots(version, 4)[i]]),
]);
const v = place(list, 21, { cx: 960, cy: 616 });
const left = bounds(v, list.map((c) => box(c))).x0;
const boxes = list.flatMap((c) => {
const at = arrive(t, t0 + (slot.get(c) + 1) * SIX);
return at ? [box(c, at.dz, at.a)] : [];
});
cubes(ctx, v, boxes);
const fade = (when) => settle(clamp((t - (when - LAND)) / LAND));
const a = fade(t0 + 3 * BEAT);
const y = under(v, left + 620) + 56;
const line = { x: left, align: "left", a };
text(ctx, { ...line, s: "macOS now · Windows and Linux soon", y, size: 34, color: "#c4c2c0" });
text(ctx, { ...line, s: "macOS 现已推出 · Windows 与 Linux 即将推出", y: y + 46, size: 28, color: "#acaaa8" });
const url = "github.com/ZacharyZhang-NY/ElyOffice";
text(ctx, { ...line, s: url, y: y + 122, size: 30, color: "#f5f5f7", a: fade(t0 + BAR), weight: 400 });
}
const frame = (s) => Math.round(s * 30);
/** One range of global frames per bar span. */
function span(name, kind, from, to, draw, first = frame(bar(from))) {
return { name, kind, first, count: frame(bar(to)) - first, draw };
}
const APPS = ["Doc", "Table", "Show", "PDF", "Tree", "Mark"];
export const SCENES = [
{ name: "type-ely", kind: "base", first: 0, count: 129, draw: ely },
{ name: "type-title", kind: "over", first: 386, count: 129, draw: title },
...APPS.map((a, i) => span(`type-${a.toLowerCase()}`, "over", 9 + 4 * i, 10 + 4 * i, app(`Ely${a}`, 9 + 4 * i))),
{ name: "type-end", kind: "over", first: 2572, count: 128, draw: end },
];