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

+78 -29
View File
@@ -8,16 +8,37 @@ test.use({ reducedMotion: "reduce" });
/** Screen top of the building's first block, in SVG pixels. */
const top = (page: Page, name: string) =>
page.evaluate((n) => {
const svg = document.querySelector<SVGSVGElement>(".stage")!;
const svg = document.querySelector<SVGSVGElement>(".hero .stage")!;
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
return document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top / scale;
return (document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top + scrollY) / scale;
}, name);
const centre = async (page: Page, name: string) => {
const box = await page.locator(`.hit[data-part="${name}"]`).boundingBox();
if (!box) throw new Error(`${name} has no hit area`);
return { x: box.x + box.width / 2, y: box.y + box.height * 0.75 };
};
/** A point where the building is hit; `low` takes the lowest. */
const spot = (page: Page, name: string, low = false) =>
page.evaluate(
([n, low]) => {
const faces = [...document.querySelectorAll(`.hit-face[data-part="${n}"]`)];
const r = faces.map((f) => f.getBoundingClientRect());
const box = {
left: Math.min(...r.map((b) => b.left)),
right: Math.max(...r.map((b) => b.right)),
top: Math.min(...r.map((b) => b.top)),
bottom: Math.max(...r.map((b) => b.bottom)),
};
const ys = [];
for (let y = Math.floor(box.top); y < box.bottom; y += 2) ys.push(y);
if (low) ys.reverse();
for (const y of ys)
for (let x = Math.floor(box.left); x < box.right; x += 2) {
const hit = document.elementFromPoint(x, y);
if (hit instanceof SVGElement && hit.dataset.part === n) return { x, y };
}
throw new Error(`${n} has no hit point`);
},
[name, low] as const,
);
const caption = (page: Page, name: string) => page.locator(".caption", { hasText: name });
let errors: string[] = [];
@@ -25,7 +46,7 @@ test.beforeEach(async ({ page }) => {
errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto("/");
await expect(page.locator(".hit")).toHaveCount(6);
await expect(page.locator(".hero .hit-face:not(.ground)").first()).toBeAttached();
});
test.afterEach(() => expect(errors).toEqual([]));
@@ -36,58 +57,86 @@ test("six links in reading order; reduced motion skips growth", async ({ page })
expect(anim).toBe("none");
});
test("focus lifts one cell and survives the mouse passing", async ({ page }) => {
const rest = (await top(page, "ElyDoc"));
test("focus lifts one cell, shows the name, survives the mouse", async ({ page }) => {
const rest = await top(page, "ElyDoc");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await expect(page.locator(".doors a").first()).toBeFocused();
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
await expect(page.locator(".caption.shown")).toHaveText("ElyDoc");
const at = await centre(page, "ElyDoc");
await expect(caption(page, "ElyDoc")).toHaveCSS("opacity", "1");
const at = await spot(page, "ElyDoc");
await page.mouse.move(at.x, at.y);
await page.mouse.move(5, 5);
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
await expect(page.locator(".caption.shown")).toHaveText("ElyDoc");
await expect(caption(page, "ElyDoc")).toHaveCSS("opacity", "1");
});
test("hover at the bottom edge holds still", async ({ page }) => {
const box = (await page.locator('.hit[data-part="ElyTable"]').boundingBox())!;
const at = await spot(page, "ElyTable", true);
await page.evaluate(() => {
(window as unknown as { flips: number }).flips = 0;
const hit = document.querySelector('.hit[data-part="ElyTable"]')!;
for (const kind of ["pointerenter", "pointerleave"])
hit.addEventListener(kind, () => (window as unknown as { flips: number }).flips++);
for (const face of document.querySelectorAll('.hit-face[data-part="ElyTable"]'))
for (const kind of ["pointerenter", "pointerleave"])
face.addEventListener(kind, (e) => {
const to = (e as PointerEvent).relatedTarget;
if (!(to instanceof SVGElement && to.dataset.part === "ElyTable")) (window as unknown as { flips: number }).flips++;
});
});
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
await page.mouse.move(at.x, at.y);
await page.waitForTimeout(600);
expect(await page.evaluate(() => (window as unknown as { flips: number }).flips)).toBe(1);
await expect(page.locator(".caption.shown")).toHaveText("ElyTable");
await expect(caption(page, "ElyTable")).toHaveCSS("opacity", "1");
});
test("the hit owner is the block you see", async ({ page }) => {
const wrong = await page.evaluate(() => {
const r = document.querySelector(".hero .stage")!.getBoundingClientRect();
const out: string[] = [];
const owner = (x: number, y: number) => {
const face = document.elementsFromPoint(x, y).find((e) => e.classList.contains("face"));
return face?.closest<SVGElement>(".block")!.dataset.part;
};
for (let y = r.top; y < r.bottom; y += 5)
for (let x = r.left; x < r.right; x += 5) {
const seen = owner(x, y);
if (seen === undefined) continue;
const edge = [[-1, 0], [1, 0], [0, -1], [0, 1]].some(([dx, dy]) => owner(x + dx, y + dy) !== seen);
if (edge) continue;
const hit = document.elementsFromPoint(x, y).find((e) => e.classList.contains("hit-face")) as SVGElement | undefined;
if (hit?.dataset.part !== seen) out.push(`${x},${y}: ${hit?.dataset.part} over ${seen}`);
}
return out;
});
expect(wrong).toEqual([]);
});
test("a primary press sinks one cell; only a click starting on it opens", async ({ page }) => {
const rest = (await top(page, "ElyShow"));
const at = await centre(page, "ElyShow");
const rest = await top(page, "ElyShow");
const at = await spot(page, "ElyShow");
await page.mouse.move(at.x, at.y);
await page.mouse.down();
expect((await top(page, "ElyShow"))).toBeCloseTo(rest, 0);
expect(await top(page, "ElyShow")).toBeCloseTo(rest, 0);
await page.mouse.up();
expect(rest - (await top(page, "ElyShow"))).toBeCloseTo(CELL, 0);
await expect(page).toHaveURL(/#elyshow$/);
await page.evaluate(() => (location.hash = ""));
await page.mouse.click(at.x, at.y, { button: "right" });
await page.evaluate(() => history.replaceState(null, "", "/"));
await page.evaluate(() => scrollTo(0, 0));
const again = await spot(page, "ElyShow");
await page.mouse.click(again.x, again.y, { button: "right" });
await page.mouse.move(5, 5);
await page.mouse.down();
await page.mouse.move(at.x, at.y);
await page.mouse.move(again.x, again.y);
await page.mouse.up();
expect(await page.evaluate(() => location.hash)).toBe("");
});
test("holding Enter sinks the focused building until release", async ({ page }) => {
const rest = (await top(page, "ElyDoc"));
const rest = await top(page, "ElyDoc");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.down("Enter");
expect((await top(page, "ElyDoc"))).toBeCloseTo(rest, 0);
expect(await top(page, "ElyDoc")).toBeCloseTo(rest, 0);
await expect(page).toHaveURL(/#elydoc$/);
await page.keyboard.up("Enter");
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
await expect(page.locator(".block.pressed")).toHaveCount(0);
});
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="description" content="Six offline desktop apps for documents, sheets, slides, PDF, Git and Markdown." />
</head>
<body>
<main id="app"></main>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+12
View File
@@ -0,0 +1,12 @@
// The film's tempo: 112 BPM, landings on sixteenths.
const BEAT = 60 / 112;
const STEP = BEAT / 4;
/** Times parts to land beat by beat, blocks on sixteenths. */
export function grow(parts: SVGGElement[][]): void {
let beat = 1;
for (const blocks of parts) {
blocks.forEach((g, i) => g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`));
beat += Math.ceil(blocks.length / 4);
}
}
+9
View File
@@ -42,6 +42,10 @@
cursor: pointer;
}
.hit-face.ground {
cursor: auto;
}
.block.lifted {
translate: 0 calc(-1 * var(--step));
}
@@ -55,3 +59,8 @@
transition: none;
}
}
/* Off-screen models wait until they scroll near. */
.grow:not(.seen) .block {
animation-play-state: paused;
}
+16
View File
@@ -110,3 +110,19 @@ export function stage(blocks: SVGGElement[]): SVGSVGElement {
svg.setAttribute("class", "stage");
return svg;
}
/** Pins an image across a block's left face. */
export function screen(g: SVGGElement, href: string, width: number, height: number): void {
const b = boxes.get(g);
if (!b) throw new Error("not a block");
const [e, f] = project(b.x, b.y + b.d, b.z + b.h);
const sx = (b.w * UNIT) / width;
const sy = (b.h * UNIT) / height;
const image = document.createElementNS(SVG, "image");
image.setAttribute("href", href);
image.setAttribute("width", String(width));
image.setAttribute("height", String(height));
image.setAttribute("preserveAspectRatio", "none");
image.setAttribute("transform", `matrix(${COS * sx} ${SIN * sx} 0 ${sy} ${e} ${f})`);
g.append(image);
}
+48 -19
View File
@@ -1,38 +1,67 @@
import "./style.css";
import "./iso.css";
import { block, stage } from "./iso.ts";
import { grow } from "./beat.ts";
import { model } from "./model.ts";
import { touch } from "./touch.ts";
import { regions, reveal } from "./regions.ts";
import { said, speak, type Lang } from "./text.ts";
/** 112 BPM, the film's tempo; blocks land on sixteenths. */
const BEAT = 60 / 112;
const STEP = BEAT / 4;
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
const app = document.querySelector<HTMLElement>("#app");
if (!app) throw new Error("index.html has no #app");
const apps = model.slice(1, -1);
const parts = new Map<string, SVGGElement[]>();
const blocks: SVGGElement[] = [];
let beat = 1;
for (const part of model) {
const own = part.blocks.map((spec, i) => {
const g = block(spec);
g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`);
return g;
});
if (apps.includes(part)) parts.set(part.name, own);
blocks.push(...own);
beat += Math.ceil(part.blocks.length / 4);
}
const built = model.map((part) => part.blocks.map(block));
grow(built);
const parts = new Map(apps.map((p) => [p.name, built[model.indexOf(p)]]));
const hero = document.createElement("header");
hero.className = "hero";
const title = document.createElement("h1");
title.textContent = "Ely Office";
const host = document.createElement("div");
host.className = "model";
const svg = stage(blocks);
svg.classList.add("grow");
const svg = stage(built.flat());
svg.classList.add("grow", "seen");
svg.setAttribute("aria-hidden", "true");
host.append(svg);
app.append(title, host);
hero.append(title, said("p", "tagline"), said("p", "platforms"), host);
touch(host, svg, parts);
const sections = regions();
const footer = document.createElement("footer");
footer.id = "download";
const version = document.createElement("h2");
version.textContent = "Ely Office 0.1.0";
const list = document.createElement("ul");
for (const p of apps) {
const a = document.createElement("a");
a.href = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`;
a.textContent = p.name;
const li = document.createElement("li");
li.append(a);
list.append(li);
}
const source = said("a", "source");
source.href = `${RELEASE}/ElyOffice-0.1.0-source.tar.gz`;
const li = document.createElement("li");
li.append(source);
list.append(li);
footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), list);
const toggle = said("button", "other");
toggle.className = "lang";
let lang: Lang = navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
toggle.addEventListener("click", () => {
lang = lang === "en" ? "zh" : "en";
speak(lang);
});
const main = document.createElement("main");
main.append(...sections);
app.append(toggle, hero, main, footer);
speak(lang);
reveal(sections);
+120
View File
@@ -0,0 +1,120 @@
// One region per app, then the three props.
import { block, screen, stage, type Block } from "./iso.ts";
import { grow } from "./beat.ts";
import { model, type Part } from "./model.ts";
import { said, type Key } from "./text.ts";
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
const PAPER = "#f4f4f2";
const DESK = "#e6e6e2";
const SCREEN = "#3a3a3c";
const SHOT = [1440, 810];
/** A part's blocks moved to corner (x, y). */
function moved(part: Part, x: number, y: number): Required<Block>[] {
const left = Math.min(...part.blocks.map((b) => b.x));
const back = Math.min(...part.blocks.map((b) => b.y));
const floor = Math.min(...part.blocks.map((b) => b.z));
const snap = (v: number) => Math.round(v * 1000) / 1000;
return part.blocks.map((b) => ({ ...b, x: snap(b.x - left + x), y: snap(b.y - back + y), z: snap(b.z - floor + 0.4) }));
}
/** A plinth, the app's screen, and its building. */
function scene(part: Part): SVGSVGElement {
const plinth = block({ x: 0, y: 0, w: 11.6, d: 5, h: 0.4, tone: DESK });
const slab = block({ x: 0.4, y: 1.2, z: 0.4, w: 7.2, d: 0.25, h: 4.05, tone: SCREEN });
screen(slab, `/shots/${part.name.slice(3).toLowerCase()}.jpg`, SHOT[0], SHOT[1]);
const house = moved(part, 8.4, 1.2).map(block);
grow([[plinth], [slab], house]);
const svg = stage([plinth, slab, ...house]);
svg.classList.add("grow");
svg.setAttribute("role", "img");
svg.dataset.shot = part.name;
return svg;
}
const t = (x: number, y: number, z: number, w: number, d: number, h: number, tone = PAPER) =>
block({ x, y, z, w, d, h, tone });
/** Offline: a plug pulled from its socket. */
const cable = () => [
t(0, 0, 0, 7, 3, 0.3, DESK),
t(0.4, 1.3, 0.3, 2.6, 0.4, 0.4),
t(3, 1.15, 0.3, 0.9, 0.7, 0.7),
t(5, 0.9, 0.3, 1.4, 1.2, 1.2),
];
/** Open source: a box with its lid laid open. */
const box = () => [
t(0, 0, 0, 7, 4, 0.3, DESK),
t(1, 0.6, 0.3, 3, 0.2, 1.6),
t(1, 0.8, 0.3, 0.2, 2.2, 1.6),
t(3.8, 0.8, 0.3, 0.2, 2.2, 1.6),
t(1.2, 0.8, 0.3, 2.6, 2.2, 0.2),
t(1, 3, 0.3, 3, 0.2, 1.6),
t(4.3, 0.6, 0.3, 2.2, 2.6, 0.12),
];
/** Local files: a folder holding one sheet. */
const folder = () => [
t(0, 0, 0, 7, 3, 0.3, DESK),
t(1, 1, 0.3, 4.6, 0.2, 2.6),
t(1, 1, 2.9, 1.6, 0.2, 0.4),
t(1.4, 1.2, 0.3, 3.8, 0.1, 2.3, "#3d7fe5"),
t(1, 1.3, 0.3, 4.6, 0.2, 2),
];
/** A prop with its line. */
function prop(blocks: SVGGElement[], line: Key): HTMLElement {
grow(blocks.map((b) => [b]));
const svg = stage(blocks);
svg.classList.add("grow");
svg.setAttribute("aria-hidden", "true");
const p = said("p", line);
const cell = document.createElement("div");
cell.className = "prop";
cell.append(svg, p);
return cell;
}
/** The app regions and the props row. */
export function regions(): HTMLElement[] {
const apps = model.slice(1, -1).map((part) => {
const section = document.createElement("section");
section.id = part.name.toLowerCase();
section.className = "region";
const h2 = document.createElement("h2");
h2.textContent = part.name;
const get = said("a", "download");
get.className = "get";
get.href = `${RELEASE}/${part.name}-0.1.0-arm64.dmg`;
const text = document.createElement("div");
text.append(h2, said("p", part.name as Key), get);
section.append(scene(part), text);
return section;
});
const props = document.createElement("section");
props.className = "props";
props.append(
prop(cable(), "offline"),
prop(box(), "open"),
prop(folder(), "local"),
);
return [...apps, props];
}
/** Starts each model's growth when it scrolls near. */
export function reveal(sections: HTMLElement[]): void {
const seen = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
for (const svg of e.target.querySelectorAll(".stage")) svg.classList.add("seen");
seen.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -20% 0px" },
);
for (const s of sections) seen.observe(s);
}
+185 -20
View File
@@ -3,30 +3,63 @@
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", sans-serif;
color: #1d1d1f;
background: #fbfbfa;
--quiet: #6e6e73;
--accent: #3d7fe5;
}
body {
margin: 0;
}
#app {
min-height: 100vh;
display: grid;
place-content: center;
justify-items: center;
gap: 6rem;
padding: 9rem 1.5rem;
box-sizing: border-box;
}
h1 {
h1,
h2 {
margin: 0;
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 700;
letter-spacing: -0.03em;
}
h1 {
font-size: clamp(3rem, 9vw, 6.5rem);
}
h2 {
font-size: clamp(2rem, 5vw, 3.5rem);
}
p {
margin: 0;
font-size: clamp(1.0625rem, 1.6vw, 1.3125rem);
font-weight: 300;
line-height: 1.5;
color: var(--quiet);
}
a {
color: var(--accent);
text-decoration: none;
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 2px;
}
.hero {
display: grid;
justify-items: center;
gap: 1.25rem;
padding: 10rem 1.5rem 9rem;
text-align: center;
}
.hero .model {
margin-top: 6rem;
}
.stage {
display: block;
max-width: 100%;
height: auto;
overflow: visible;
@@ -37,13 +70,143 @@ h1 {
max-width: 100%;
}
.region {
display: grid;
grid-template-columns: 1.6fr 1fr;
align-items: center;
gap: 6rem;
max-width: 72rem;
margin: 0 auto;
padding: 9rem 1.5rem;
}
.region > svg {
width: 100%;
}
.region:nth-child(even) {
grid-template-columns: 1fr 1.6fr;
}
.region:nth-child(even) > svg {
order: 2;
}
.region div {
display: grid;
gap: 1rem;
justify-items: start;
}
.get {
font-size: 1.0625rem;
opacity: 0;
transition: opacity 0.15s ease-out;
}
.region:hover .get,
.region:focus-within .get {
opacity: 1;
}
@media (hover: none) {
.get {
opacity: 1;
}
}
.props {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4rem;
max-width: 72rem;
margin: 0 auto;
padding: 9rem 1.5rem;
}
.prop {
display: grid;
justify-items: center;
gap: 2rem;
text-align: center;
}
footer {
display: grid;
justify-items: center;
gap: 1rem;
padding: 9rem 1.5rem 12rem;
text-align: center;
}
footer ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem 2.5rem;
margin: 2rem 0 0;
padding: 0;
list-style: none;
font-size: 1.0625rem;
}
.lang {
position: fixed;
top: 1.5rem;
right: 1.5rem;
z-index: 1;
padding: 0.5rem 0.75rem;
border: 0;
background: none;
font: inherit;
font-size: 0.9375rem;
font-weight: 300;
color: var(--quiet);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease-out;
}
.lang:hover,
.lang:focus-visible,
body:has(.hero:hover) .lang {
opacity: 1;
}
@media (hover: none) {
.lang {
opacity: 1;
}
}
@media (max-width: 760px) {
.region,
.props {
grid-template-columns: 1fr;
gap: 3rem;
padding: 6rem 1.5rem;
}
.region:nth-child(even) {
grid-template-columns: 1fr;
}
.region:nth-child(even) > svg {
order: 0;
}
.hero {
padding: 7rem 1.5rem 6rem;
}
}
.caption {
position: absolute;
margin: 0;
translate: -50% calc(-100% - 0.75rem);
translate: -50% -100%;
font-size: 0.9375rem;
font-weight: 300;
color: #6e6e73;
color: var(--quiet);
white-space: nowrap;
pointer-events: none;
opacity: 0;
@@ -54,12 +217,6 @@ h1 {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.caption {
transition: none;
}
}
/* Keyboard and reader links; focus shows on the model. */
.doors a {
position: absolute;
@@ -69,3 +226,11 @@ h1 {
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.caption,
.get,
.lang {
transition: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
// Every word on the page, in both languages.
export type Lang = "en" | "zh";
const en = {
tagline: "Six desktop apps. They work offline.",
ElyDoc: "Letters, reports, books. Opens and saves Word files.",
ElyTable: "Formulas, filters, charts. Opens and saves Excel files.",
ElyShow: "Slides, animation, a presenter view. Opens PowerPoint files.",
ElyPDF: "Read, mark up, sign, fill forms. Finds text in scans.",
ElyTree: "Stage lines, commit, read the graph. Git without a terminal.",
ElyMark: "Markdown as you read it. Math and diagrams in place.",
offline: "Works with the network off.",
open: "Open source, under the AGPL.",
local: "Your files stay in your folders.",
download: "Download",
platforms: "Out now for macOS. Windows and Linux are coming soon.",
needs: "macOS 14 or later, Apple silicon.",
unsigned: "Not notarized. The first time, Control-click the app and choose Open.",
source: "Source code",
shot: "screenshot",
other: "中文",
};
const zh: typeof en = {
tagline: "六个桌面应用,断网也能用。",
ElyDoc: "信件、报告、书稿。打开并保存 Word 文件。",
ElyTable: "公式、筛选、图表。打开并保存 Excel 文件。",
ElyShow: "幻灯片、动画、演讲者视图。打开 PowerPoint 文件。",
ElyPDF: "阅读、批注、签名、填表。扫描件里也能查字。",
ElyTree: "按行暂存、提交、看提交图。不用终端的 Git。",
ElyMark: "写的就是看到的 Markdown。公式与图表就地显示。",
offline: "断网照常工作。",
open: "开源,AGPL 许可。",
local: "文件留在你自己的文件夹里。",
download: "下载",
platforms: "macOS 版已发布。Windows 与 Linux 版即将推出。",
needs: "macOS 14 或更新,Apple 芯片。",
unsigned: "未经公证。第一次打开时,按住 Control 点按应用,选“打开”。",
source: "源代码",
shot: "截图",
other: "English",
};
export type Key = keyof typeof en;
export const TEXT: Record<Lang, typeof en> = { en, zh };
/** An element whose text follows the language. */
export function said<K extends keyof HTMLElementTagNameMap>(tag: K, key: Key): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
el.dataset.t = key;
return el;
}
/** Writes every keyed text in `lang`. */
export function speak(lang: Lang): void {
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
for (const el of document.querySelectorAll<HTMLElement>("[data-t]")) {
el.textContent = TEXT[lang][el.dataset.t as Key];
}
for (const el of document.querySelectorAll<SVGElement>("[data-shot]")) {
el.setAttribute("aria-label", `${el.dataset.shot} ${TEXT[lang].shot}`);
}
}
+38 -28
View File
@@ -10,12 +10,13 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
nav.className = "doors";
host.append(nav);
const owner = new Map([...parts].flatMap(([name, blocks]) => blocks.map((b) => [b, name] as const)));
const hits = still(svg, owner);
for (const [name, blocks] of parts) {
const target = `#${name.toLowerCase()}`;
const state = { hover: false, focus: false, press: false };
for (const b of blocks) b.dataset.part = name;
const hit = area(blocks, name);
svg.append(hit);
const hit = hits.filter((h) => h.dataset.part === name);
const caption = document.createElement("p");
caption.className = "caption";
caption.textContent = name;
@@ -29,19 +30,22 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
b.classList.toggle("pressed", up && state.press);
}
caption.classList.toggle("shown", up);
if (up) place(caption, host, hit);
if (up) place(caption, host, svg, blocks);
};
const set = (change: Partial<typeof state>) => {
Object.assign(state, change);
show();
};
hit.addEventListener("pointerenter", () => set({ hover: true }));
hit.addEventListener("pointerleave", () => set({ hover: false, press: false }));
hit.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
hit.addEventListener("pointerup", () => set({ press: false }));
hit.addEventListener("pointercancel", () => set({ press: false }));
hit.addEventListener("click", () => (location.hash = target));
const own = (node: EventTarget | null) => node instanceof SVGElement && node.dataset.part === name;
for (const h of hit) {
h.addEventListener("pointerenter", () => set({ hover: true }));
h.addEventListener("pointerleave", (e) => own(e.relatedTarget) || set({ hover: false, press: false }));
h.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
h.addEventListener("pointerup", () => set({ press: false }));
h.addEventListener("pointercancel", () => set({ press: false }));
h.addEventListener("click", () => (location.hash = target));
}
const door = document.createElement("a");
door.href = target;
@@ -54,28 +58,34 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
}
}
/** A still hit area over the building, resting and lifted. */
function area(blocks: SVGGElement[], name: string): SVGGElement {
const hit = document.createElementNS(SVG, "g");
hit.setAttribute("class", "hit");
hit.dataset.part = name;
for (const b of blocks) {
/** Resting outlines over the model, layered as drawn. */
function still(svg: SVGSVGElement, owner: Map<SVGGElement, string>): SVGPolygonElement[] {
const layer = document.createElementNS(SVG, "g");
layer.setAttribute("class", "hits");
const hits: SVGPolygonElement[] = [];
for (const b of svg.querySelectorAll<SVGGElement>(".block")) {
const outline = b.querySelector(".outline");
if (!outline) throw new Error("block has no outline");
for (const dy of [0, -UNIT]) {
const face = outline.cloneNode() as SVGPolygonElement;
face.setAttribute("class", "hit-face");
face.setAttribute("transform", `translate(0 ${dy})`);
hit.append(face);
}
const name = owner.get(b);
b.dataset.part = name ?? "";
const face = outline.cloneNode() as SVGPolygonElement;
face.setAttribute("class", name ? "hit-face" : "hit-face ground");
face.dataset.part = name ?? "";
layer.append(face);
if (name) hits.push(face);
}
return hit;
svg.append(layer);
return hits;
}
/** Puts the caption above the lifted building. */
function place(caption: HTMLElement, host: HTMLElement, hit: SVGGElement) {
/** Puts the caption one step above the building's top. */
function place(caption: HTMLElement, host: HTMLElement, svg: SVGSVGElement, blocks: SVGGElement[]) {
const origin = host.getBoundingClientRect();
const r = hit.getBoundingClientRect();
caption.style.left = `${r.left + r.width / 2 - origin.left}px`;
caption.style.top = `${r.top - origin.top}px`;
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
const rects = blocks.map((b) => b.querySelector(".outline")!.getBoundingClientRect());
const left = Math.min(...rects.map((r) => r.left));
const right = Math.max(...rects.map((r) => r.right));
const top = Math.min(...rects.map((r) => r.top));
caption.style.left = `${(left + right) / 2 - origin.left}px`;
caption.style.top = `${top - origin.top - UNIT * scale}px`;
}
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]] }
]
}
]
}
+61
View File
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Ely Office film scenes</title>
<style>
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
}
svg {
position: absolute;
inset: 0;
}
.tile .face.top {
stroke: var(--tone);
stroke-width: 1.2;
}
.words > * {
position: absolute;
left: 0;
right: 0;
margin: 0;
text-align: center;
white-space: nowrap;
}
h1 {
top: 104px;
font-size: 104px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
}
.version {
top: 232px;
font-size: 40px;
font-weight: 300;
}
.url {
top: 292px;
font-size: 28px;
font-weight: 300;
letter-spacing: 0.01em;
}
</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>
<p class="url" data-text="url" data-soft>github.com/ZacharyZhang-NY/ElyOffice/releases</p>
</div>
<script type="module" src="./page.ts"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "ely-video-scenes",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ely-video-scenes",
"dependencies": {
"playwright": "1.64.0"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "ely-video-scenes",
"private": true,
"type": "module",
"scripts": {
"render": "node render.mjs",
"check": "tsc -p ."
},
"dependencies": {
"playwright": "1.64.0"
}
}
+157
View File
@@ -0,0 +1,157 @@
// Draws one film frame with the site's own iso renderer.
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 { BAR, BEAT, glide, toward } from "./time.ts";
import { type Moment, moment } from "./timeline.ts";
export const WIDTH = 1920;
export const HEIGHT = 1080;
export const FPS = 30;
/** Bars [from, to); plates push into one app. */
export interface Scene {
name: string;
from: number;
to: number;
app?: string;
}
const PLATES = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
export const scenes: Scene[] = [
{ name: "opening", from: 0, to: 4 },
{ name: "rise", from: 4, to: 8 },
...PLATES.map((app, i) => ({ name: `plate-${app.slice(3).toLowerCase()}`, from: 8 + i * 4, to: 9 + i * 4, app })),
{ name: "files", from: 32, to: 36 },
{ name: "props", from: 36, to: 40 },
{ name: "end", from: 40, to: 42 },
];
/** Global frames [first, end) whose time falls in the scene. */
export function frames(scene: Scene): [number, number] {
const first = (bar: number) => Math.ceil((bar * BAR * FPS) - 1e-9);
return [first(scene.from), first(scene.to)];
}
/** Light tone to dark tone; three faces derive from it. */
const DARK: Record<string, string> = {
[DESK]: "#3a3a3a",
[PAPER]: "#5c5c5a",
[SCREEN]: "#3a3a3c",
[ACCENT]: ACCENT,
};
const PAGE = ["#fbfbfa", "#252525"];
const INK = ["#1d1d1f", "#f5f5f7"];
const SOFT = ["#6e6e73", "#a1a1a6"];
const mix = (a: string, b: string, u: number) =>
u <= 0 ? a : u >= 1 ? b : `color-mix(in oklab, ${a}, ${b} ${(u * 100).toFixed(2)}%)`;
function tone(light: string, dark: number): string {
const night = DARK[light];
if (!night) throw new Error(`no dark tone for ${light}`);
return mix(light, night, dark);
}
/** World to frame pixels: scale, then the origin's pixel. */
interface Camera {
s: number;
x: number;
y: number;
}
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);
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 };
})();
/** Pinned face corners: TL, TR, BL, BR of +y side. */
function face(b: Box): [number, number][] {
const y = b.y + b.d;
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 {
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 };
}
const svg = document.querySelector<SVGSVGElement>("#frame");
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);
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) });
if (tiles.includes(box)) g.classList.add("tile");
if (land < 1) {
g.setAttribute("transform", `translate(0 ${(-UNIT * (1 - land)).toFixed(3)})`);
g.setAttribute("opacity", land.toFixed(4));
}
return g;
});
world!.replaceChildren(...order(m.pieces.map((p) => p.box)).map((i) => groups[i]));
for (const el of document.querySelectorAll<HTMLElement>("[data-text]")) {
const shown = 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. */
export interface Face {
opacity: number;
corners: [number, number][];
}
/** Draws global frame `f` of `scene`; plates return the face. */
function render(name: string, f: number): Face | null {
const scene = scenes.find((s) => s.name === name);
if (!scene) throw new Error(`no scene ${name}`);
const [first, end] = frames(scene);
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;
draw(m, cam);
if (!app) return null;
const piece = m.pieces.find((p) => p.box === screens[app]);
if (!piece) return { opacity: 0, corners: [] };
const lift = -UNIT * (1 - piece.land);
return {
opacity: piece.land,
corners: face(piece.box).map(([x, y]) => [cam.x + cam.s * x, cam.y + cam.s * (y + lift)]),
};
}
declare global {
interface Window {
ely: { scenes: Scene[]; frames: typeof frames; render: typeof render };
}
}
window.ely = { scenes, frames, render };
+112
View File
@@ -0,0 +1,112 @@
// Film additions: desk tiles, screens, files, props.
import type { Block } from "../../src/iso.ts";
import { ACCENT, model } from "../../src/model.ts";
export type Box = Required<Block>;
const desk = model[0].blocks[0];
export const DESK = desk.tone;
export const PAPER = model[1].blocks[0].tone;
/** The blank face a recording is later pinned onto. */
export const SCREEN = "#3a3a3c";
const TOP = desk.h;
const on = (x: number, y: number, w: number, d: number, h: number, z = 0, tone = PAPER): Box => ({
x, y, z: TOP + z, w, d, h, tone,
});
/** The desk as one-cell tiles, for growing it. */
export const tiles: Box[] = Array.from({ length: desk.w * desk.d }, (_, i) => ({
x: i % desk.w, y: Math.floor(i / desk.w), z: 0, w: 1, d: 1, h: desk.h, tone: DESK,
}));
/** Upright 16:9 screens on each app; +y face is pinned. */
const screen = (x: number, y: number, w: number, z: number) => on(x, y, w, 0.08, (w * 9) / 16, z, SCREEN);
export const screens: Record<string, Box> = {
ElyDoc: screen(1.24, 2.2, 2.6, 1),
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),
ElyMark: screen(1, 7.4, 3, 2),
};
/** A file travelling a road, one hop per beat. */
export interface Trip {
path: [number, number][];
lands: number;
hops: number[];
}
const FILE = 0.48;
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] },
];
/** The point `s` along a polyline. */
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 length = Math.hypot(bx - ax, by - ay);
if (s <= length || i === path.length - 1) {
const u = Math.min(s / length, 1);
return [ax + (bx - ax) * u, ay + (by - ay) * u];
}
s -= length;
}
throw new Error("a path needs two points");
}
/** Total length of a polyline. */
export function length(path: [number, number][]): number {
return path.slice(1).reduce((sum, [x, y], i) => sum + Math.hypot(x - path[i][0], y - path[i][1]), 0);
}
/** The file box with its centre `s` along the trip. */
export function file(trip: Trip, s: number): Box {
const [x, y] = along(trip.path, s);
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[] }[] = [
{
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),
],
},
{
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),
],
},
];
+77
View File
@@ -0,0 +1,77 @@
// Renders iso scenes to PNG frames, faces and MP4s.
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 "playwright";
import { createServer } from "vite";
const here = dirname(fileURLToPath(import.meta.url));
const site = resolve(here, "../..");
const out = resolve(here, "../out/scenes");
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/scenes/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;
});
page.on("console", (m) => {
if (m.type() === "error") throw new Error(m.text());
});
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
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);
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;
/** Renders one scene's frames on `page`, then encodes it. */
async function scene(page, s) {
const dir = join(out, s.name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const [first, end] = s.range;
const faces = [];
for (let f = first; f < end; f++) {
const face = await page.evaluate(([name, f]) => window.ely.render(name, f), [s.name, f]);
await page.screenshot({ path: join(dir, `${String(f - first).padStart(5, "0")}.png`) });
if (face) faces.push({ frame: f, local: f - first, t: f / 30, ...face });
}
if (s.app) {
const json = { scene: s.name, app: s.app, fps: 30, width: 1920, height: 1080, first, frames: faces };
writeFileSync(join(out, `${s.name}.json`), JSON.stringify(json, null, 1));
}
const mp4 = join(out, `${s.name}.mp4`);
const args = ["-y", "-v", "error", "-framerate", "30", "-i", join(dir, "%05d.png")];
args.push("-c:v", "libx264", "-preset", "slow", "-crf", "14", "-pix_fmt", "yuv420p", "-r", "30", "-movflags", "+faststart", mp4);
const run = spawnSync("ffmpeg", args, { stdio: "inherit" });
if (run.status !== 0) throw new Error(`ffmpeg failed for ${s.name}`);
console.log(`${s.name}: frames ${first}..${end - 1} (${end - first}), ${((end - first) / 30).toFixed(3)} s`);
return { name: s.name, app: s.app ?? null, bars: [s.from, s.to], first, frames: end - first };
}
const queue = [...scenes];
const done = [];
await Promise.all(
pages.map(async (page) => {
for (let s = queue.shift(); s; s = queue.shift()) done.push(await scene(page, s));
}),
);
if (!wanted.length) {
done.sort((a, b) => a.first - b.first);
writeFileSync(join(out, "scenes.json"), JSON.stringify({ fps: 30, bpm: 112, scenes: done }, null, 1));
}
await browser.close();
await server.close();
+38
View File
@@ -0,0 +1,38 @@
// The film's clock: 112 BPM; motion ends on the grid.
/** Seconds per beat and per bar. */
export const BEAT = 60 / 112;
export const BAR = 4 * BEAT;
/** Landings and fades last this long, ending on beats. */
export const LAND = 0.2;
/** CSS cubic-bezier timing, solved by bisection. */
export function bezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
const at = (a: number, b: number, u: number) => 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);
};
}
/** The site's landing curve, from iso.css. */
export const settle = bezier(0.2, 0.8, 0.3, 1);
export const glide = bezier(0.65, 0, 0.35, 1);
/** Progress of a move lasting `span` that ends on `beat`. */
export function toward(t: number, beat: number, span = LAND, ease = settle): number {
return ease((t - (beat * BEAT - span)) / span);
}
/** Whether a move ending on `beat` has started by `t`. */
export function begun(t: number, beat: number, span = LAND): boolean {
return t > beat * BEAT - span;
}
+82
View File
@@ -0,0 +1,82 @@
// 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 { BEAT, LAND, begun, glide, toward } from "./time.ts";
/** A box and its landing progress, 0 to 1. */
export interface Piece {
box: Box;
land: 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. */
dark: number;
texts: Record<string, number>;
}
/** The desk grows in rings around this tile. */
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;
});
const DESK_DONE = 15;
/** Beat each app starts on; its blocks follow on sixteenths. */
const partBeats = [16, 18, 21, 23, 25, 27, 29];
/** Beats of each text's fade, ending on that beat. */
const TEXTS: Record<string, [number, number]> = {
title: [31, 32],
name: [161, 168],
version: [161.5, 168],
url: [162, 168],
};
const DARK = 136;
function landed(t: number, box: Box, beat: number, out: Piece[]) {
if (begun(t, beat)) out.push({ box, land: toward(t, beat) });
}
/** 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;
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) };
}
/** 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 });
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);
}
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}`);
landed(t, box, screen.beat, pieces);
}
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);
}
return { pieces, light: toward(t, 0.5, LAND, glide), dark: toward(t, DARK, LAND, glide), texts };
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["*.ts", "../../src"]
}