diff --git a/site/e2e/touch.spec.ts b/site/e2e/touch.spec.ts index 96c1802..a98d9eb 100644 --- a/site/e2e/touch.spec.ts +++ b/site/e2e/touch.spec.ts @@ -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(".stage")!; + const svg = document.querySelector(".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(".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); }); diff --git a/site/index.html b/site/index.html index 9a71a91..c977ff9 100644 --- a/site/index.html +++ b/site/index.html @@ -7,7 +7,7 @@ -
+
diff --git a/site/public/shots/doc.jpg b/site/public/shots/doc.jpg new file mode 100644 index 0000000..1e5aad4 Binary files /dev/null and b/site/public/shots/doc.jpg differ diff --git a/site/public/shots/mark.jpg b/site/public/shots/mark.jpg new file mode 100644 index 0000000..8aec127 Binary files /dev/null and b/site/public/shots/mark.jpg differ diff --git a/site/public/shots/pdf.jpg b/site/public/shots/pdf.jpg new file mode 100644 index 0000000..77a0782 Binary files /dev/null and b/site/public/shots/pdf.jpg differ diff --git a/site/public/shots/show.jpg b/site/public/shots/show.jpg new file mode 100644 index 0000000..cba9598 Binary files /dev/null and b/site/public/shots/show.jpg differ diff --git a/site/public/shots/table.jpg b/site/public/shots/table.jpg new file mode 100644 index 0000000..1b8a62d Binary files /dev/null and b/site/public/shots/table.jpg differ diff --git a/site/public/shots/tree.jpg b/site/public/shots/tree.jpg new file mode 100644 index 0000000..970b2e5 Binary files /dev/null and b/site/public/shots/tree.jpg differ diff --git a/site/src/beat.ts b/site/src/beat.ts new file mode 100644 index 0000000..bc3fcc4 --- /dev/null +++ b/site/src/beat.ts @@ -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); + } +} diff --git a/site/src/iso.css b/site/src/iso.css index 6ae69c5..14d3284 100644 --- a/site/src/iso.css +++ b/site/src/iso.css @@ -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; +} diff --git a/site/src/iso.ts b/site/src/iso.ts index 14f7a68..dbbc953 100644 --- a/site/src/iso.ts +++ b/site/src/iso.ts @@ -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); +} diff --git a/site/src/main.ts b/site/src/main.ts index 2acdf71..24bced4 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -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("#app"); if (!app) throw new Error("index.html has no #app"); const apps = model.slice(1, -1); -const parts = new Map(); -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); diff --git a/site/src/regions.ts b/site/src/regions.ts new file mode 100644 index 0000000..f6480f6 --- /dev/null +++ b/site/src/regions.ts @@ -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[] { + 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); +} diff --git a/site/src/style.css b/site/src/style.css index e58a5ce..edc159e 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -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; + } +} diff --git a/site/src/text.ts b/site/src/text.ts new file mode 100644 index 0000000..2acb763 --- /dev/null +++ b/site/src/text.ts @@ -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 = { en, zh }; + +/** An element whose text follows the language. */ +export function said(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("[data-t]")) { + el.textContent = TEXT[lang][el.dataset.t as Key]; + } + for (const el of document.querySelectorAll("[data-shot]")) { + el.setAttribute("aria-label", `${el.dataset.shot} ${TEXT[lang].shot}`); + } +} diff --git a/site/src/touch.ts b/site/src/touch.ts index 3344857..ff332b2 100644 --- a/site/src/touch.ts +++ b/site/src/touch.ts @@ -10,12 +10,13 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map 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) => { 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): SVGPolygonElement[] { + const layer = document.createElementNS(SVG, "g"); + layer.setAttribute("class", "hits"); + const hits: SVGPolygonElement[] = []; + for (const b of svg.querySelectorAll(".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`; } diff --git a/site/video/edit/__pycache__/edit.cpython-314.pyc b/site/video/edit/__pycache__/edit.cpython-314.pyc new file mode 100644 index 0000000..c050563 Binary files /dev/null and b/site/video/edit/__pycache__/edit.cpython-314.pyc differ diff --git a/site/video/edit/cards.mjs b/site/video/edit/cards.mjs new file mode 100644 index 0000000..db09c6c --- /dev/null +++ b/site/video/edit/cards.mjs @@ -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(`${html}`); + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: join(out, file), omitBackground: true }); +} + +await shot(`
`, "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 = `
${s.en}
${s.zh}
`; + await shot(`
${s.title}
${body}
${count}
`, `${s.app}.png`); +} +await browser.close(); diff --git a/site/video/edit/edit.py b/site/video/edit/edit.py new file mode 100644 index 0000000..4426a76 --- /dev/null +++ b/site/video/edit/edit.py @@ -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() diff --git a/site/video/edit/plan.json b/site/video/edit/plan.json new file mode 100644 index 0000000..e222fc3 --- /dev/null +++ b/site/video/edit/plan.json @@ -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]] } + ] + } + ] +} diff --git a/site/video/scenes/index.html b/site/video/scenes/index.html new file mode 100644 index 0000000..3fe4a67 --- /dev/null +++ b/site/video/scenes/index.html @@ -0,0 +1,61 @@ + + + + + Ely Office film scenes + + + + +
+

Ely Office

+

Ely Office

+

0.1.0 · macOS

+

github.com/ZacharyZhang-NY/ElyOffice/releases

+
+ + + diff --git a/site/video/scenes/package-lock.json b/site/video/scenes/package-lock.json new file mode 100644 index 0000000..eb76e02 --- /dev/null +++ b/site/video/scenes/package-lock.json @@ -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" + } + } + } +} diff --git a/site/video/scenes/package.json b/site/video/scenes/package.json new file mode 100644 index 0000000..a8eafc5 --- /dev/null +++ b/site/video/scenes/package.json @@ -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" + } +} diff --git a/site/video/scenes/page.ts b/site/video/scenes/page.ts new file mode 100644 index 0000000..5f6726d --- /dev/null +++ b/site/video/scenes/page.ts @@ -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 = { + [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("#frame"); +const world = document.querySelector("#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("[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 }; diff --git a/site/video/scenes/parts.ts b/site/video/scenes/parts.ts new file mode 100644 index 0000000..000f98a --- /dev/null +++ b/site/video/scenes/parts.ts @@ -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; + +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 = { + 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), + ], + }, +]; diff --git a/site/video/scenes/render.mjs b/site/video/scenes/render.mjs new file mode 100644 index 0000000..9e9ab3e --- /dev/null +++ b/site/video/scenes/render.mjs @@ -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(); diff --git a/site/video/scenes/time.ts b/site/video/scenes/time.ts new file mode 100644 index 0000000..0e99b2b --- /dev/null +++ b/site/video/scenes/time.ts @@ -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; +} diff --git a/site/video/scenes/timeline.ts b/site/video/scenes/timeline.ts new file mode 100644 index 0000000..00f01b3 --- /dev/null +++ b/site/video/scenes/timeline.ts @@ -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; +} + +/** 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 = { + 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 = {}; + 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 }; +} diff --git a/site/video/scenes/tsconfig.json b/site/video/scenes/tsconfig.json new file mode 100644 index 0000000..61ce39b --- /dev/null +++ b/site/video/scenes/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["*.ts", "../../src"] +}