Site: app regions with screenshots, props, downloads, English and Chinese, narrow layout; hit layer follows draw order

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:57:34 -04:00
1 parent 29e4e7f70f
commit 07719a1fd3
29 files changed
+1423 -97

No files matched your search

Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
// Renders the letterbox frame and caption overlays as PNGs.
import { mkdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const require = createRequire(resolve(here, "../scenes/package.json"));
const { chromium } = require("playwright");
const plan = JSON.parse(readFileSync(join(here, "plan.json"), "utf8"));
const out = resolve(here, "../out/edit/cards");
mkdirSync(out, { recursive: true });
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; }
.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; }
.body { display: flex; flex-direction: column; gap: 8px; }
.en { font: 300 30px/1.05 "SF Pro Display", system-ui; letter-spacing: -0.005em; color: #1d1d1f; }
.zh { font: 300 22px/1.1 "PingFang SC", system-ui; color: #6e6e73; letter-spacing: 0.04em; }
.count { margin-left: auto; font: 400 15px/1 "SF Mono", ui-monospace, monospace; color: #9a9a9e; letter-spacing: 0.08em; }
`;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
/** Screenshots one body of markup with transparent ground. */
async function shot(html, file) {
await page.setContent(`<!doctype html><meta charset="utf-8"><style>${css}</style>${html}`);
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: join(out, file), omitBackground: true });
}
await shot(`<div class="hole"></div>`, "frame.png");
for (const [i, s] of plan.sections.entries()) {
const count = `${String(i + 1).padStart(2, "0")} / ${String(plan.sections.length).padStart(2, "0")}`;
const body = `<div class="body"><div class="en">${s.en}</div><div class="zh">${s.zh}</div></div>`;
await shot(`<div class="cap"><div class="name">${s.title}</div>${body}<div class="count">${count}</div></div>`, `${s.app}.png`);
}
await browser.close();
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Composites the launch film from scenes, recordings and captions."""
import json
import math
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "out"
SCENES = OUT / "scenes"
REC = OUT / "rec"
WORK = OUT / "edit"
FILM = OUT / "ElyOffice-launch.mp4"
FPS = 30
BEAT = 60 / 112
RGB_IN = "scale=in_color_matrix=bt709:in_range=tv,format=rgb24"
def run(args):
print("+", " ".join(str(a) for a in args[:6]), "...", flush=True)
subprocess.run(["ffmpeg", "-hide_banner", "-v", "warning", "-y", *map(str, args)], check=True)
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 piecewise(points, var):
"""An ffmpeg expression for lerp over var."""
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})"
return expr
def frames_expr(values):
"""Per-frame value by input frame count."""
return "+".join(f"eq(in,{k})*{v:.3f}" for k, v in enumerate(values))
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"]]
inverse = [(s, t) for t, s in anchors]
u0 = max(lerp(anchors, f0 / FPS) - 0.1, 0)
u1 = lerp(anchors, f1 / FPS) + 0.1
pts = piecewise(inverse, f"({u0:.6f}+T)")
n = f1 - f0
return (
f"[{label}:v]trim=start={u0:.4f}:end={u1:.4f},setpts=PTS-STARTPTS,"
f"setpts='({pts}-{f0 / FPS:.6f})/TB',fps={FPS}:start_time=0,"
f"trim=end_frame={n},tpad=stop_mode=clone:stop={n},trim=end_frame={n},setpts=N/{FPS}/TB,"
f"{RGB_IN}[{out}]"
)
def section(s, scene, end, plan):
"""Renders one app section to a lossless file."""
app, first, n_plate = s["app"], scene["first"], scene["frames"]
length = end - first
start = scene["bars"][0] * 4 - first / FPS / BEAT
inputs, chains, parts = [], [], []
bounds = []
for i, p in enumerate(s["pieces"]):
a = p["anchors"]
f0 = 0 if i == 0 else math.ceil((start + a[0][0]) * BEAT * FPS - 1e-6)
bounds.append(f0)
bounds.append(length)
for i, p in enumerate(s["pieces"]):
inputs += ["-i", REC / f"{p['src']}.mp4"]
chains.append(piece_chain(i, p, start, bounds[i], bounds[i + 1], f"p{i}"))
parts.append(f"[p{i}]")
k = len(inputs) // 2
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"]
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("[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,
"-c:v", "libx264rgb", "-qp", 0, "-preset", "ultrafast", target])
return target
def plate_chain(scene, rec, k, out):
"""Pins the recording onto the plate's screen face."""
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]
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")
)
fades = "".join(
f",colorchannelmixer=aa={f['opacity']:.3f}:enable='eq(n,{f['local']})'"
for f in data if f["opacity"] < 0.999
)
return (
f"{rec},scale=800:450:flags=area,scale=1912:1072:flags=bicubic,format=rgba,"
f"pad=1920:1080:4:4:color=black@0,perspective={pins}:sense=destination:eval=frame:interpolation=cubic"
f"{fades}[pin];[{k}:v]format=rgba[bg];[bg][pin]overlay=format=auto:shortest=1,format=rgb24,setsar=1[{out}]"
)
def full_chain(s, plan, rec, n, k, out):
"""Letterboxes the recording with a slow push 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}'")
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,format=rgba[lb];"
f"[{k + 1}:v]format=rgba[fr];[lb][fr]overlay=format=auto:shortest=0[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=auto:eval=frame,trim=end_frame={n},format=rgb24,setsar=1[{out}]"
)
def scene_input(name):
return ["-framerate", FPS, "-i", SCENES / name / "%05d.png"]
def film(made):
"""Joins every part and the music into the final MP4."""
order = ["opening", "rise", *made, "files", "props", "end"]
inputs = []
for part in order:
inputs += ["-i", part] if isinstance(part, Path) else scene_input(part)
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,
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
"-r", FPS, "-c:a", "aac", "-b:a", "192k", "-t", "90", "-movflags", "+faststart", FILM])
def main():
plan = json.loads((HERE / "plan.json").read_text())
scenes = json.loads((SCENES / "scenes.json").read_text())["scenes"]
WORK.mkdir(parents=True, exist_ok=True)
subprocess.run(["node", HERE / "cards.mjs"], check=True)
only = sys.argv[1:]
made = []
for s in plan["sections"]:
i = next(j for j, sc in enumerate(scenes) if sc["name"] == f"plate-{s['app']}")
target = WORK / f"{s['app']}.mkv"
if not only or s["app"] in only:
target = section(s, scenes[i], scenes[i + 1]["first"], plan)
made.append(target)
if not only:
film(made)
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
{
"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]] }
]
},
{
"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]] }
]
},
{
"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]] },
{ "src": "show-play", "anchors": [[12, 1.2], [13, 1.5], [15, 3.2], [16, 4.0]] }
]
},
{
"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]] }
]
},
{
"app": "tree", "title": "ElyTree", "en": "Stage. Commit. Look back.", "zh": "暂存,提交,回看历史。",
"focus": [960, 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]] }
]
},
{
"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]] }
]
}
]
}