Site: app regions with screenshots, props, downloads, English and Chinese, narrow layout; hit layer follows draw order
This commit is contained in:
29 files changed
+1423
-97
No files matched your search
+78
-29
@@ -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
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
@@ -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();
|
||||
@@ -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()
|
||||
@@ -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]] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+40
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["*.ts", "../../src"]
|
||||
}
|
||||
Reference in new issue
Block a user