From 01397eb1e529c51088e476ad8843bb0cd05654d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 22:59:44 -0400 Subject: [PATCH] Site: isometric geometry, blocks shaded on three faces --- site/TASKS.md | 2 +- site/src/iso.css | 56 ++++++ site/src/iso.ts | 41 +++++ site/src/main.ts | 12 +- site/src/style.css | 6 + site/video/.gitignore | 1 + site/video/STORYBOARD.md | 19 ++ site/video/TASKS.md | 4 +- site/video/demo/guide.swift | 84 +++++++++ site/video/demo/make.py | 266 ++++++++++++++++++++++++++++ site/video/demo/repo.sh | 162 +++++++++++++++++ site/video/music.py | 319 ++++++++++++++++++++++++++++++++++ site/video/record/lib.sh | 167 ++++++++++++++++++ site/video/record/mouse.swift | 70 ++++++++ 14 files changed, 1205 insertions(+), 4 deletions(-) create mode 100644 site/src/iso.css create mode 100644 site/src/iso.ts create mode 100644 site/video/.gitignore create mode 100644 site/video/STORYBOARD.md create mode 100644 site/video/demo/guide.swift create mode 100644 site/video/demo/make.py create mode 100644 site/video/demo/repo.sh create mode 100644 site/video/music.py create mode 100644 site/video/record/lib.sh create mode 100644 site/video/record/mouse.swift diff --git a/site/TASKS.md b/site/TASKS.md index ad5bf88..7651d35 100644 --- a/site/TASKS.md +++ b/site/TASKS.md @@ -7,7 +7,7 @@ - 美学:标题粗、正文细且淡;留白约常规的 1.5 倍;90% 白灰黑,10% 单一强调色;无渐变、高饱和、霓虹与复杂卡片;无大框套小框,无无用说明与小标题;控件在用户表现出意图时才出现;以渲染结果为准,不以计算值为准。 - 地址 workers.dev;中英两种语言;Vite 8 + pnpm;文件不超过 500 行,注释一行十字以内,无 dead code,出错即暴露,不过度设计。 -- [ ] S1 工程骨架:`site/` 下 Vite 8 + pnpm、TypeScript、无框架;`wrangler.jsonc` 只用静态资源(`assets.directory`,`404-page`);`pnpm build` 出 `dist/`,`pnpm preview` 可看;构建失败即报错 +- [x] S1 工程骨架:`site/` 下 Vite 8 + pnpm、TypeScript、无框架;`wrangler.jsonc` 只用静态资源(`assets.directory`,`404-page`);`pnpm build` 出 `dist/`,`pnpm preview` 可看;构建失败即报错 — R1 PASS - [ ] S2 等轴几何:一种投影(真等轴,30°),无透视;积木由同一颜色的顶、左、右三面明暗构成,不用投影阴影;网格单位与高度单位;屏幕面(贴截图的平面)沿同一角度 - [ ] S3 首屏模型:一张桌面上的微缩模型,六个应用是六座积木建筑,按区域排布;高度表达层级;应用之间的真实连接(ElyMark→ElyTree、导出→ElyPDF)是道路;载入时逐格生长、按节拍落位 - [ ] S4 交互:积木悬停上浮一格、按下下沉一格;控件在悬停、聚焦或靠近时才出现;键盘可达,焦点可见;系统减少动效时不播放生长 diff --git a/site/src/iso.css b/site/src/iso.css new file mode 100644 index 0000000..9ecb2b0 --- /dev/null +++ b/site/src/iso.css @@ -0,0 +1,56 @@ +/* True isometric: tilt 54.7356°, turn 45°, no perspective. */ +:root { + --u: 28px; + --hu: 28px; +} + +.stage { + position: relative; + width: calc(var(--w) * var(--u)); + height: calc(var(--d) * var(--u)); + transform: rotateX(54.7356deg) rotateZ(45deg); + transform-style: preserve-3d; +} + +.block { + position: absolute; + left: calc(var(--x) * var(--u)); + top: calc(var(--y) * var(--u)); + width: calc(var(--w) * var(--u)); + height: calc(var(--d) * var(--u)); + transform: translateZ(calc(var(--z) * var(--hu))); + transform-style: preserve-3d; +} + +.face { + position: absolute; + box-sizing: border-box; + overflow: hidden; +} + +/* One colour, three tones; sides overlap seams by 1px. */ +.face.top { + inset: 0; + background: var(--tone); + transform: translateZ(calc(var(--h) * var(--hu))); +} + +.face.left { + left: 0; + top: calc(var(--d) * var(--u)); + width: calc(var(--w) * var(--u) + 1px); + height: calc(var(--h) * var(--hu) + 1px); + background: color-mix(in oklab, var(--tone), black 9%); + transform-origin: top; + transform: rotateX(90deg); +} + +.face.right { + left: calc(var(--w) * var(--u)); + top: 0; + width: calc(var(--h) * var(--hu) + 1px); + height: calc(var(--d) * var(--u) + 1px); + background: color-mix(in oklab, var(--tone), black 20%); + transform-origin: left; + transform: rotateY(-90deg); +} diff --git a/site/src/iso.ts b/site/src/iso.ts new file mode 100644 index 0000000..a7014e7 --- /dev/null +++ b/site/src/iso.ts @@ -0,0 +1,41 @@ +// Isometric blocks: one colour, three shaded faces, no perspective. + +/** A block on the grid, in grid units. */ +export interface Block { + x: number; + y: number; + z?: number; + w: number; + d: number; + h: number; + tone: string; +} + +/** Builds a block: top, left and right faces of one colour. */ +export function block(spec: Block, top?: Node): HTMLElement { + const el = document.createElement("div"); + el.className = "block"; + el.style.setProperty("--x", String(spec.x)); + el.style.setProperty("--y", String(spec.y)); + el.style.setProperty("--z", String(spec.z ?? 0)); + el.style.setProperty("--w", String(spec.w)); + el.style.setProperty("--d", String(spec.d)); + el.style.setProperty("--h", String(spec.h)); + el.style.setProperty("--tone", spec.tone); + for (const face of ["left", "right", "top"]) { + const part = document.createElement("div"); + part.className = `face ${face}`; + if (face === "top" && top) part.append(top); + el.append(part); + } + return el; +} + +/** The ground: a stage `w` by `d` cells that holds blocks. */ +export function stage(w: number, d: number): HTMLElement { + const el = document.createElement("div"); + el.className = "stage"; + el.style.setProperty("--w", String(w)); + el.style.setProperty("--d", String(d)); + return el; +} diff --git a/site/src/main.ts b/site/src/main.ts index 64e80a1..fb2b117 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -1,5 +1,15 @@ import "./style.css"; +import "./iso.css"; +import { block, stage } from "./iso.ts"; const app = document.querySelector("#app"); if (!app) throw new Error("index.html has no #app"); -app.textContent = "Ely Office"; + +const desk = stage(12, 12); +desk.append( + block({ x: 0, y: 0, w: 12, d: 12, h: 0.5, tone: "#e9e9e6" }), + block({ x: 2, y: 2, z: 0.5, w: 3, d: 3, h: 4, tone: "#f4f4f2" }), + block({ x: 7, y: 2, z: 0.5, w: 3, d: 3, h: 2, tone: "#f4f4f2" }), + block({ x: 2, y: 7, z: 0.5, w: 3, d: 3, h: 1, tone: "#3d7fe5" }), +); +app.append(desk); diff --git a/site/src/style.css b/site/src/style.css index f1f2c66..8bcc3bd 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -8,3 +8,9 @@ body { margin: 0; } + +#app { + min-height: 100vh; + display: grid; + place-items: center; +} diff --git a/site/video/.gitignore b/site/video/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/site/video/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/site/video/STORYBOARD.md b/site/video/STORYBOARD.md new file mode 100644 index 0000000..98e63b8 --- /dev/null +++ b/site/video/STORYBOARD.md @@ -0,0 +1,19 @@ +# 分镜 + +112 BPM,4/4,42 小节,共 90.000 秒。一拍 0.5357 秒,一小节 2.1429 秒。所有切点落在拍上。 + +| 小节 | 秒 | 画面 | 音乐 | +| --- | --- | --- | --- | +| 1–4 | 0.00–8.57 | 黑场;一块地砖按拍落下,网格逐格长成一张桌面 | 单音拨弦与节拍器式轻点 | +| 5–8 | 8.57–17.14 | 六座积木建筑按拍升起(Doc、Table、Show、PDF、Tree、Mark),道路连通;字幕 Ely Office | 加入低鼓,和声进入 | +| 9–12 | 17.14–25.71 | ElyDoc:镜头推近其屏幕面,转为全屏录屏:打开文档、键入、缩放 | 主段 A | +| 13–16 | 25.71–34.29 | ElyTable:录屏:公式、筛选、图表 | 主段 A | +| 17–20 | 34.29–42.86 | ElyShow:录屏:幻灯片、动画预览、放映 | 主段 A | +| 21–24 | 42.86–51.43 | ElyPDF:录屏:翻页、批注、签名 | 主段 B | +| 25–28 | 51.43–60.00 | ElyTree:录屏:差异、暂存、提交、历史图 | 主段 B | +| 29–32 | 60.00–68.57 | ElyMark:录屏:可视编辑、公式、Mermaid、切到源码 | 主段 B | +| 33–36 | 68.57–77.14 | 回到模型:文件块沿道路从 Mark 走到 Tree、从 Doc 导出到 PDF;整座模型深浅色切换 | 间奏,减配 | +| 37–40 | 77.14–85.71 | 三件小道具依次落位:拔下的网线(离线)、打开的盒子(开源)、文件夹(文件在本地) | 回升 | +| 41–42 | 85.71–90.00 | 模型合拢;Ely Office 0.1.0 · macOS · 下载地址 | 终止音,余音收尾 | + +字幕中英并列,白灰黑为主,一种强调色。 diff --git a/site/video/TASKS.md b/site/video/TASKS.md index 4d3281c..e73dbb9 100644 --- a/site/video/TASKS.md +++ b/site/video/TASKS.md @@ -2,8 +2,8 @@ 约 90 秒,1080p,可直接导出。真实使用录屏加等轴微缩场景,配乐由代码合成。 -- [ ] V1 分镜与节拍:每个镜头的起止秒数、内容与对应节拍,整片按一个节拍网格剪 -- [ ] V2 配乐:按节拍网格合成原创曲,WAV,无版权问题 +- [x] V1 分镜与节拍:每个镜头的起止秒数、内容与对应节拍,整片按一个节拍网格剪 +- [x] V2 配乐:按节拍网格合成原创曲,WAV,无版权问题 - [ ] V3 真实录屏:六个应用各录真实操作(打开文件、编辑、导出等),1080p 以上,英文界面 - [ ] V4 等轴场景:用官网同一套渲染器逐帧导出(无头浏览器,固定帧时间),开场、转场与结尾 - [ ] V5 合成:录屏贴入等轴屏幕面或全屏,字幕中英,音画按节拍对齐 diff --git a/site/video/demo/guide.swift b/site/video/demo/guide.swift new file mode 100644 index 0000000..4a49244 --- /dev/null +++ b/site/video/demo/guide.swift @@ -0,0 +1,84 @@ +// Writes a four-page sample chapter PDF to argv[1]. +import AppKit +import CoreText + +let out = URL(fileURLWithPath: CommandLine.arguments[1]) +var page = CGRect(x: 0, y: 0, width: 595, height: 842) +let info = [kCGPDFContextTitle: "Coastal Trails Field Guide", kCGPDFContextCreator: "Coastal Trails"] as CFDictionary +let pdf = CGContext(out as CFURL, mediaBox: &page, info)! + +let ink = NSColor(red: 0.12, green: 0.24, blue: 0.21, alpha: 1) +let accent = NSColor(red: 0.18, green: 0.42, blue: 0.36, alpha: 1) +let body = NSColor(red: 0.17, green: 0.2, blue: 0.19, alpha: 1) +let sea = NSColor(red: 0.84, green: 0.91, blue: 0.94, alpha: 1) +let land = NSColor(red: 0.93, green: 0.94, blue: 0.89, alpha: 1) + +func style(_ font: String, _ size: CGFloat, _ color: NSColor, line: CGFloat = 1.0) -> [NSAttributedString.Key: Any] { + let p = NSMutableParagraphStyle() + p.lineHeightMultiple = line + p.paragraphSpacing = size * 0.6 + return [.font: NSFont(name: font, size: size)!, .foregroundColor: color, .paragraphStyle: p] +} + +// Draws text in a box measured from the page top. +func text(_ s: String, _ attrs: [NSAttributedString.Key: Any], x: CGFloat, top: CGFloat, w: CGFloat, h: CGFloat) { + let frame = CTFramesetterCreateFrame( + CTFramesetterCreateWithAttributedString(NSAttributedString(string: s, attributes: attrs)), + CFRange(location: 0, length: 0), CGPath(rect: CGRect(x: x, y: 842 - top - h, width: w, height: h), transform: nil), nil) + CTFrameDraw(frame, pdf) +} + +// A simple hand-drawn style route map. +func map(top: CGFloat, seed: CGFloat) { + let box = CGRect(x: 56, y: 842 - top - 230, width: 483, height: 230) + pdf.setFillColor(sea.cgColor) + pdf.fill(box) + pdf.setFillColor(land.cgColor) + pdf.move(to: CGPoint(x: box.minX, y: box.minY)) + for i in 0...24 { + let x = box.minX + CGFloat(i) / 24 * box.width + pdf.addLine(to: CGPoint(x: x, y: box.minY + 120 + 34 * sin(CGFloat(i) * 0.55 + seed))) + } + pdf.addLine(to: CGPoint(x: box.maxX, y: box.minY)) + pdf.fillPath() + pdf.setStrokeColor(accent.cgColor) + pdf.setLineWidth(3) + pdf.setLineDash(phase: 0, lengths: [8, 5]) + pdf.move(to: CGPoint(x: box.minX + 40, y: box.minY + 40)) + for i in 1...8 { + let x = box.minX + 40 + CGFloat(i) * 52 + pdf.addLine(to: CGPoint(x: x, y: box.minY + 60 + 40 * sin(CGFloat(i) * 0.9 + seed))) + } + pdf.strokePath() + pdf.setLineDash(phase: 0, lengths: []) + pdf.setFillColor(ink.cgColor) + for x in [box.minX + 40, box.minX + 456] { + pdf.fillEllipse(in: CGRect(x: x - 5, y: box.minY + 35, width: 10, height: 10)) + } +} + +let routes = [ + ("Harbor Loop", "4.1 km · 1 h 05 min · Easy", + "A gentle circuit around the old harbor, best walked at low tide. The path follows the sea wall past the fish market, climbs the steps to the lighthouse and returns through the dunes behind the beach.\nAt the lighthouse, take the narrow path on the seaward side for the best view of the bay. In winter the dune path can flood after heavy rain; the road above it is a dry alternative.\nRefreshments at the harbor café, open daily from eight."), + ("Cliff Path North", "9.6 km · 3 h 10 min · Moderate", + "The classic walk of the coast. From the car park at Gull Point the path climbs steadily onto the headland and keeps close to the cliff edge for most of its length.\nThere are two steep descents into coves with stepping stones; check the tide table before you set out, because the second crossing is cut off for two hours either side of high water.\nReturn by the inland bridleway, or catch the coast bus from the village green."), + ("Dune Meadows", "5.3 km · 1 h 30 min · Easy", + "A quiet walk through protected dune grassland, rich in orchids in early summer. Keep to the marked boardwalks: the meadows are grazed by ponies and nesting birds use the open ground.\nThe bird hide at the far end of the reserve looks out over the salt marsh and is worth a long stop at dawn or dusk."), + ("Lighthouse to Lantern Bay", "7.2 km · 2 h 20 min · Moderate", + "A linear walk between two lighthouses, with a long sandy bay in the middle. The first half is on firm clifftop turf; the second half crosses the bay on the beach and climbs a flight of old coastguard steps.\nThe steps are uneven and can be slippery when wet. Walkers with dogs should use the ramp at the north end of the bay."), +] + +for (n, route) in routes.enumerated() { + pdf.beginPDFPage(nil) + pdf.setFillColor(NSColor.white.cgColor) + pdf.fill(page) + NSGraphicsContext.current = NSGraphicsContext(cgContext: pdf, flipped: false) + text("COASTAL TRAILS FIELD GUIDE · ROUTE \(n + 1)", style("HelveticaNeue-Medium", 9, accent), x: 56, top: 48, w: 483, h: 20) + text(route.0, style("HelveticaNeue-Bold", 30, ink), x: 56, top: 70, w: 483, h: 44) + text(route.1, style("HelveticaNeue", 13, accent), x: 56, top: 116, w: 483, h: 22) + map(top: 150, seed: CGFloat(n) * 1.7) + text(route.2, style("Georgia", 12, body, line: 1.25), x: 56, top: 404, w: 483, h: 360) + text("\(n + 1)", style("HelveticaNeue", 9, accent), x: 290, top: 800, w: 30, h: 16) + pdf.endPDFPage() +} +pdf.closePDF() diff --git a/site/video/demo/make.py b/site/video/demo/make.py new file mode 100644 index 0000000..98ff117 --- /dev/null +++ b/site/video/demo/make.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Writes the demo DOCX, XLSX, PPTX and Markdown into argv[1].""" +import sys +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.util import Emu, Pt + +OUT = Path(sys.argv[1]) +OUT.mkdir(parents=True, exist_ok=True) + +RELS = "http://schemas.openxmlformats.org/package/2006/relationships" +DOC_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + + +def package(path, parts): + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + for name, text in parts.items(): + archive.writestr(name, text) + + +def types(overrides): + rows = "".join(f'' for p, t in overrides) + return ('' + '' + f'' + f'{rows}') + + +def rels(rows): + body = "".join(f'' for i, t, target in rows) + return f'{body}' + + +# DOCX: a one-page project brief. +def run(text, bold=False, color=None): + props = ("" if bold else "") + (f'' if color else "") + return f'{props}{escape(text)}' + + +def para(text, style=None, runs=None): + pstyle = f'' if style else "" + return f"{pstyle}{runs or run(text)}" + + +def cell(text, width, header=False): + shade = '' if header else "" + return (f'{shade}' + f'{run(text, bold=header)}') + + +def table(rows, widths): + border = "".join(f'' + for side in ("top", "left", "bottom", "right", "insideH", "insideV")) + grid = "".join(f'' for w in widths) + body = "".join("" + "".join(cell(t, w, i == 0) for t, w in zip(row, widths)) + "" + for i, row in enumerate(rows)) + return (f'{border}' + f'' + f'{grid}{body}') + + +def style(sid, name, font, size, color="1F2A26", bold=False, before=0, after=160, based=None, level=None): + b = "" if bold else "" + outline = f'' if level is not None else "" + base = f'' if based else "" + return (f'{base}' + f'{outline}' + f'{b}' + f'') + + +def docx(path): + styles = (f'' + '' + '' + + style("Normal", "Normal", "Georgia", 22, "2B3330") + + style("Title", "Title", "Helvetica Neue", 52, "1F3D35", True, 0, 80) + + style("Subtitle", "Subtitle", "Helvetica Neue", 24, "5E6E68", False, 0, 360) + + style("Heading1", "heading 1", "Helvetica Neue", 30, "2F6B5B", True, 320, 120, level=0) + + "") + body = "".join([ + para("Coastal Trails Field Guide", "Title"), + para("Project brief · Spring edition · Draft 3", "Subtitle"), + para("Purpose", "Heading1"), + para("The field guide maps forty walks along the northern coast, from short harbor loops to " + "full-day cliff routes. Each walk gets a hand-drawn map, honest timings, tide notes and " + "a short story about the place. The spring edition adds twelve new routes and a fold-out " + "overview map."), + para("Goals for this edition", "Heading1"), + para("", runs=run("Accurate. ", bold=True) + run("Every route is walked twice before it goes to print.")), + para("", runs=run("Readable. ", bold=True) + run("One walk per spread, with the map and the notes side by side.")), + para("", runs=run("Durable. ", bold=True) + run("Water-resistant paper and a lay-flat binding for the trail.")), + para("Schedule", "Heading1"), + table([["Phase", "Owner", "Due"], + ["Route surveys", "Maya Lind", "12 March"], + ["Maps and illustrations", "Theo Brandt", "2 April"], + ["Copy edit and proofs", "Ines Okafor", "23 April"], + ["Print and distribution", "Sam Reyes", "14 May"]], + [3600, 2800, 2600]), + para(""), + para("Notes: ", runs=run("Next review: ", bold=True) + run("Thursday, with the first six route maps.")), + ]) + sect = ('' + '') + document = (f'' + f'{body}{sect}') + main = "application/vnd.openxmlformats-officedocument.wordprocessingml" + package(path, { + "[Content_Types].xml": types([("/word/document.xml", f"{main}.document.main+xml"), + ("/word/styles.xml", f"{main}.styles+xml")]), + "_rels/.rels": rels([("r1", "officeDocument", "word/document.xml")]), + "word/_rels/document.xml.rels": rels([("r1", "styles", "styles.xml")]), + "word/document.xml": document, + "word/styles.xml": styles, + }) + + +# XLSX: half-year sales by product line. +MONTHS = ["January", "February", "March", "April", "May", "June"] +LINES = ["Trail Maps", "Guidebooks", "Prints", "Workshops"] +SALES = [[4820, 6150, 2310, 1800], [5140, 5980, 2475, 2100], [6380, 7210, 2890, 2650], + [7120, 8040, 3120, 3400], [7950, 8820, 3460, 3900], [8610, 9470, 3780, 4350]] + + +def col(i): + return "ABCDEFGH"[i] + + +def xlsx(path): + def text(ref, value, s): + return f'{escape(value)}' + + def num(ref, value, s): + return f'{value}' + + rows = ['' + text("A1", "Month", 1) + + "".join(text(f"{col(i + 1)}1", n, 2) for i, n in enumerate(LINES)) + text("F1", "Total", 2) + ""] + for r, (month, values) in enumerate(zip(MONTHS, SALES), start=2): + cells = text(f"A{r}", month, 3) + "".join(num(f"{col(i + 1)}{r}", v, 4) for i, v in enumerate(values)) + total = sum(values) + cells += f'SUM(B{r}:E{r}){total}' + rows.append(f'{cells}') + rows.append('' + text("A8", "Half year", 1) + "") + sheet = (f'' + '' + '' + '' + f'{"".join(rows)}') + styles = (f'' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '') + workbook = (f'' + '') + main = "application/vnd.openxmlformats-officedocument.spreadsheetml" + package(path, { + "[Content_Types].xml": types([("/xl/workbook.xml", f"{main}.sheet.main+xml"), + ("/xl/worksheets/sheet1.xml", f"{main}.worksheet+xml"), + ("/xl/styles.xml", f"{main}.styles+xml")]), + "_rels/.rels": rels([("r1", "officeDocument", "xl/workbook.xml")]), + "xl/_rels/workbook.xml.rels": rels([("r1", "worksheet", "worksheets/sheet1.xml"), + ("r2", "styles", "styles.xml")]), + "xl/workbook.xml": workbook, + "xl/worksheets/sheet1.xml": sheet, + "xl/styles.xml": styles, + }) + + +# PPTX: four simple slides. +def pptx(path): + deck = Presentation() + deck.slide_width, deck.slide_height = Emu(12192000), Emu(6858000) + ink, accent = RGBColor(0x1F, 0x3D, 0x35), RGBColor(0x2F, 0x6B, 0x5B) + + def paint(frame, size, color, bold=False): + for p in frame.paragraphs: + for r in p.runs: + r.font.size, r.font.color.rgb, r.font.bold, r.font.name = Pt(size), color, bold, "Helvetica Neue" + + title = deck.slides.add_slide(deck.slide_layouts[0]) + title.shapes.title.text = "Coastal Trails 2027" + title.placeholders[1].text = "Spring edition · Planning review" + paint(title.shapes.title.text_frame, 54, ink, True) + paint(title.placeholders[1].text_frame, 24, accent) + slides = [ + ("What is new", ["Twelve new routes on the northern cliffs", "A fold-out overview map", + "Tide tables for every harbor walk", "Water-resistant, lay-flat binding"]), + ("Half-year sales", ["Guidebooks lead at $45,670", "Workshops more than doubled since January", + "Trail Maps up 79% since January", "Prints steady at about $3,000 a month"]), + ("Next steps", ["Finish route surveys by 12 March", "Maps and illustrations by 2 April", + "Proofs to the printer on 23 April", "Launch walk on 14 May"]), + ] + for heading, bullets in slides: + slide = deck.slides.add_slide(deck.slide_layouts[1]) + slide.shapes.title.text = heading + paint(slide.shapes.title.text_frame, 40, ink, True) + body = slide.placeholders[1].text_frame + body.text = bullets[0] + for line in bullets[1:]: + body.add_paragraph().text = line + paint(body, 26, RGBColor(0x2B, 0x33, 0x30)) + deck.core_properties.author = "Coastal Trails" + deck.save(path) + + +MARKDOWN = r"""# Trail notes: Harbor Loop + +A short walk around the old harbor, best at low tide. The path follows the sea wall, +climbs to the lighthouse and comes back through the dunes. + +## Before you go + +- [x] Check the tide table +- [x] Pack water and a windproof layer +- [ ] Download the offline map +- [ ] Tell someone your route + +## Route at a glance + +| Section | Distance | Time | +| --- | --- | --- | +| Sea wall | 1.2 km | 15 min | +| Lighthouse climb | 0.8 km | 20 min | +| Dune path | 2.1 km | 30 min | + +## Pace + +Walking time is distance over pace, plus a minute for every ten metres of climb: + +$$ +t = \frac{d}{v} + \frac{h}{10} +$$ + +## Plan + +```mermaid +flowchart LR + A[Harbor] --> B[Sea wall] + B --> C[Lighthouse] + C --> D[Dunes] + D --> A +``` +""" + +docx(OUT / "Field Guide Brief.docx") +xlsx(OUT / "Sales 2027.xlsx") +pptx(OUT / "Coastal Trails 2027.pptx") +(OUT / "harbor-loop.md").write_text(MARKDOWN) diff --git a/site/video/demo/repo.sh b/site/video/demo/repo.sh new file mode 100644 index 0000000..98a6df8 --- /dev/null +++ b/site/video/demo/repo.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Builds the demo notes repository at $1 with history. +set -euo pipefail + +repo=${1:?repo path} +rm -rf "$repo" +mkdir -p "$repo" +cd "$repo" +git init -q -b main +git config user.name "Maya Lind" +git config user.email "maya@coastaltrails.example" +git config commit.gpgsign false + +day=0 +# Commits all changes as $1 with message $2. +commit() { + day=$((day + 1)) + local when="2027-02-$(printf %02d "$day")T$((9 + day % 8)):$((10 + day * 7 % 50)):00" + git add -A + GIT_AUTHOR_NAME="$1" GIT_COMMITTER_NAME="$1" GIT_AUTHOR_EMAIL="$(tr 'A-Z ' 'a-z.' <<<"$1")@coastaltrails.example" \ + GIT_COMMITTER_EMAIL="$(tr 'A-Z ' 'a-z.' <<<"$1")@coastaltrails.example" \ + GIT_AUTHOR_DATE="$when" GIT_COMMITTER_DATE="$when" git commit -q -m "$2" +} + +mkdir -p routes maps +cat >README.md <<'MD' +# Field notes + +Working notes for the Coastal Trails field guide: one file per route, +plus the shared checklist and the map key. +MD +commit "Maya Lind" "Start the field notes" + +cat >checklist.md <<'MD' +# Survey checklist + +- Walk the route twice, once each direction +- Record timings at every junction +- Photograph each waymark +MD +commit "Maya Lind" "Add the survey checklist" + +cat >routes/harbor-loop.md <<'MD' +# Harbor Loop + +Distance: 4.1 km +Time: 1 h 05 min +Grade: Easy + +## Directions + +Start at the harbor car park. +Follow the sea wall east past the fish market. +At the slipway, keep left of the boat sheds. +Climb the steps to the lighthouse. +Take the seaward path round the lamp room. +Drop down to the beach by the coastguard hut. +Return through the dunes behind the beach. + +## Notes + +The dune path floods after heavy rain. +MD +commit "Maya Lind" "Survey the Harbor Loop" + +cat >routes/cliff-path-north.md <<'MD' +# Cliff Path North + +Distance: 9.6 km +Time: 3 h 10 min +Grade: Moderate + +From Gull Point the path climbs onto the headland. +Two steep descents into coves with stepping stones. +MD +commit "Theo Brandt" "Survey Cliff Path North" + +printf '\n- Note the tide at both coves\n' >>checklist.md +commit "Maya Lind" "Checklist: record tides at coves" + +git switch -q -c maps +cat >maps/key.md <<'MD' +# Map key + +- Dashed green: the route +- Solid dot: start and finish +- Blue wave: tidal crossing +MD +commit "Theo Brandt" "Draft the map key" +printf -- '- Triangle: viewpoint\n' >>maps/key.md +commit "Theo Brandt" "Map key: add viewpoints" +printf -- '- Cup: café or kiosk\n' >>maps/key.md +commit "Theo Brandt" "Map key: add refreshments" + +git switch -q main +cat >routes/dune-meadows.md <<'MD' +# Dune Meadows + +Distance: 5.3 km +Time: 1 h 30 min +Grade: Easy + +Boardwalks through the reserve. Ponies graze the meadows. +MD +commit "Ines Okafor" "Survey Dune Meadows" +sed -i '' 's/Two steep descents/Two steep, short descents/' routes/cliff-path-north.md +commit "Ines Okafor" "Copy edit Cliff Path North" +day=$((day + 1)) +GIT_AUTHOR_DATE="2027-02-$(printf %02d "$day")T16:20:00" GIT_COMMITTER_DATE="2027-02-$(printf %02d "$day")T16:20:00" \ + git merge -q --no-ff maps -m "Merge the map key" + +git switch -q -c tide-tables +cat >tides.md <<'MD' +# Tide tables + +| Harbor | Spring low | Neap low | +| --- | --- | --- | +| Gull Point | 06:40 | 09:15 | +| Lantern Bay | 07:05 | 09:40 | +MD +commit "Sam Reyes" "Start the tide tables" +printf '| Old Harbor | 06:55 | 09:30 |\n' >>tides.md +commit "Sam Reyes" "Tides: add Old Harbor" + +git switch -q main +cat >routes/lantern-bay.md <<'MD' +# Lighthouse to Lantern Bay + +Distance: 7.2 km +Time: 2 h 20 min +Grade: Moderate + +Linear walk between two lighthouses. Coastguard steps at the north end. +MD +commit "Maya Lind" "Survey Lighthouse to Lantern Bay" +sed -i '' 's/Ponies graze the meadows./Ponies graze the meadows. Orchids in early summer./' routes/dune-meadows.md +commit "Ines Okafor" "Dune Meadows: note the orchids" + +# Unstaged work for the recording: two hunks in one file. +cat >routes/harbor-loop.md <<'MD' +# Harbor Loop + +Distance: 4.1 km +Time: 1 h 05 min +Grade: Easy +Best at: low tide + +## Directions + +Start at the harbor car park. +Follow the sea wall east past the fish market. +At the slipway, keep left of the boat sheds. +Climb the steps to the lighthouse. +Take the seaward path round the lamp room. +Drop down to the beach by the coastguard hut. +Return through the dunes behind the beach. + +## Notes + +The dune path floods after heavy rain; take the road above it instead. +Café at the harbor, open daily from eight. +MD diff --git a/site/video/music.py b/site/video/music.py new file mode 100644 index 0000000..10c3caa --- /dev/null +++ b/site/video/music.py @@ -0,0 +1,319 @@ +# Usage: python music.py, in a venv with numpy. +import wave +from pathlib import Path + +import numpy as np + +SR = 48000 +BEAT = 60 / 112 +BARS = 42 +N = round(BARS * 4 * BEAT * SR) +OUT = Path(__file__).parent / "out" / "music.wav" +TARGET_LUFS = -16.0 +CEILING = 10 ** (-1.0 / 20) + +rng = np.random.default_rng(1120) +dry = np.zeros((N, 2)) +send = np.zeros((N, 2)) + +# (bass root, pad voicing, arpeggio tones): D A Bm G +CHORDS = [ + (38, [50, 57, 62, 66, 69], [62, 66, 69, 74]), + (45, [49, 57, 61, 64, 69], [61, 64, 69, 73]), + (47, [47, 59, 62, 66, 71], [62, 66, 71, 74]), + (43, [43, 59, 62, 67, 71], [62, 67, 71, 74]), +] +ARP = [0, 1, 2, 1, 3, 2, 1, 2] +INTRO = [(0, 74), (4, 73), (6, 76), (8, 78), (10, 74), (12, 79), (14, 78)] +COUNTER = [(0, 78, 2), (2, 76, 1), (3, 74, 1), (4, 76, 3), (7, 73, 1), + (8, 74, 2), (10, 78, 2), (12, 79, 2), (14, 78, 1), (15, 76, 1)] + + +def at(beat): + return round(beat * BEAT * SR) + + +def hz(m): + return 440.0 * 2 ** ((m - 69) / 12) + + +def times(sec): + return np.arange(int(sec * SR)) / SR + + +def attack(sig, sec): + a = max(1, int(sec * SR)) + sig[:a] *= np.linspace(0.0, 1.0, a) + return sig + + +def tail(sig, sec=0.02): + r = int(sec * SR) + ramp = np.linspace(1.0, 0.0, r) + sig[-r:] *= ramp if sig.ndim == 1 else ramp[:, None] + return sig + + +def put(sig, beat, gain=1.0, pan=0.0, rev=0.2): + i = at(beat) + if sig.ndim == 1: + th = (pan + 1) * np.pi / 4 + sig = np.stack([sig * np.cos(th), sig * np.sin(th)], 1) * np.sqrt(2) + n = min(len(sig), N - i) + dry[i:i + n] += sig[:n] * gain + send[i:i + n] += sig[:n] * gain * rev + + +def band_noise(n, lo, hi): + spec = np.fft.rfft(rng.standard_normal(n)) + f = np.fft.rfftfreq(n, 1 / SR) + spec *= (f > lo) * (f < hi) + out = np.fft.irfft(spec, n) + return out / np.abs(out).max() + + +def pluck(m, dur=1.0, tau=0.45, partials=8): + t = times(dur) + s = np.zeros_like(t) + for h in range(1, partials + 1): + if hz(m) * h > 15000: + break + s += np.sin(2 * np.pi * hz(m) * h * t) * np.exp(-t * (1 + 0.8 * (h - 1)) / tau) / h ** 1.4 + return tail(attack(s * 0.5, 0.002)) + + +def mallet(m, dur=1.6, tau=0.7): + t = times(dur) + f = hz(m) + s = (np.sin(2 * np.pi * f * t) * np.exp(-t / tau) + + 0.25 * np.sin(2 * np.pi * 2 * f * t) * np.exp(-t / (tau / 3)) + + 0.12 * np.sin(2 * np.pi * 4.0 * f * t) * np.exp(-t / (tau / 8))) + return tail(attack(s * 0.5, 0.003)) + + +def tick(level): + t = times(0.08) + s = (np.sin(2 * np.pi * 1750 * t) * np.exp(-t / 0.009) + + 0.5 * np.sin(2 * np.pi * 2950 * t) * np.exp(-t / 0.005) + + 0.3 * band_noise(len(t), 2000, 8000) * np.exp(-t / 0.0015)) + return tail(attack(s * level, 0.0003), 0.01) + + +def kick(level=1.0, decay=0.22): + t = times(0.7) + f = 46 + 70 * np.exp(-t / 0.032) + s = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t / decay) + return tail(attack(s * level, 0.0015)) + + +def snap(): + t = times(0.3) + e = sum(np.exp(-np.clip(t - d, 0, None) / 0.004) * (t >= d) for d in (0.0, 0.008, 0.016)) + e = e + 0.45 * np.exp(-t / 0.055) + s = band_noise(len(t), 900, 5000) * e + return tail(attack(s * 0.5, 0.0004)) + + +def shaker(): + t = times(0.09) + s = band_noise(len(t), 5000, 12000) * np.exp(-t / 0.022) + return tail(attack(s * 0.3, 0.003)) + + +def bass(m, dur): + t = times(dur) + w = 2 * np.pi * hz(m) * t + s = (np.sin(w) + 0.28 * np.sin(2 * w) + 0.08 * np.sin(3 * w)) * np.exp(-t / 0.9) + return tail(attack(np.tanh(1.4 * s) / np.tanh(1.4), 0.004), 0.04) + + +def pad(voices, sec, rise, release=0.6, decay=None): + t = times(sec + release) + out = np.zeros((len(t), 2)) + for m in voices: + for side, cents in ((0, -6), (1, 6)): + f = hz(m) * 2 ** (cents / 1200) + ph = rng.uniform(0, 2 * np.pi, 4) + for h, a in enumerate((1.0, 0.4, 0.18, 0.07), 1): + out[:, side] += a * np.sin(2 * np.pi * f * h * t + ph[h - 1]) + env = np.ones(len(t)) + r = int(rise * SR) + env[:r] = np.sin(np.linspace(0, np.pi / 2, r)) ** 2 + if decay: + env *= np.exp(-t / decay) + else: + k = int(sec * SR) + env[k:] *= np.cos(np.linspace(0, np.pi / 2, len(t) - k)) ** 2 + return tail(out * env[:, None] / len(voices) * 0.35, 0.05) + + +def bar_start(b): + return b * 4 + + +def intro(b): + for beat in range(4): + put(tick(1.0 if beat == 0 else 0.6), bar_start(b) + beat, 0.16, 0.0, 0.1) + for beat, m in INTRO: + if beat // 4 == b: + put(pluck(m, 2.2, 0.9), beat, 0.55, 0.15, 0.35) + + +def build(b): + root, voices, tones = CHORDS[b % 4] + s = bar_start(b) + for beat in range(4): + put(tick(1.0 if beat == 0 else 0.6), s + beat, 0.14, 0.0, 0.1) + put(pluck(tones[ARP[beat * 2]], 1.4, 0.6), s + beat, 0.38, 0.25 if beat % 2 else -0.25, 0.3) + for beat in (0, 2): + put(kick(0.55), s + beat, 0.8, 0.0, 0.03) + put(pad(voices, 4 * BEAT, 1.6 if b == 4 else 0.25), s, 0.55 + 0.05 * (b - 4), rev=0.35) + + +def groove(b, lift, arp_up=0, sixteenths=False, four=False, counter=False): + root, voices, tones = CHORDS[b % 4] + s = bar_start(b) + for beat in (range(4) if four else (0, 2)): + put(kick(0.9), s + beat, 0.95, 0.0, 0.03) + for beat in (1, 3): + put(snap(), s + beat, 0.42 * lift, 0.05, 0.25) + for beat in range(4): + put(tick(0.7 if beat else 1.0), s + beat, 0.09, -0.1, 0.1) + if lift > 1.0: + for beat in range(4): + put(shaker(), s + beat + 0.5, 0.2 * lift, 0.35, 0.1) + step = 0.25 if sixteenths else 0.5 + for k in range(int(4 / step)): + m = tones[ARP[k % 8]] + arp_up + put(pluck(m, 0.9, 0.32), s + k * step, 0.33 * lift * (1.0 if k % 2 == 0 else 0.8), + -0.35 if k % 2 else 0.35, 0.28) + for off, dur, interval in ((0, 1.4, 0), (1.5, 0.45, 0), (2, 1.4, 0), (3.5, 0.45, 7)): + put(bass(root + interval, dur * BEAT), s + off, 0.5, 0.0, 0.02) + put(pad(voices, 4 * BEAT, 0.25), s, 0.42, rev=0.35) + if counter: + for beat, m, length in COUNTER: + if beat // 4 == b % 4: + put(mallet(m, length * BEAT + 0.8, 0.6), s + beat % 4, 0.42, 0.3, 0.4) + + +def interlude(b): + root, voices, tones = CHORDS[b % 4] + s = bar_start(b) + put(pad(voices, 4 * BEAT, 0.6 if b == 32 else 0.25), s, 0.7, rev=0.45) + put(pluck(tones[3], 2.4, 1.0), s, 0.45, 0.2, 0.45) + put(pluck(tones[1], 2.0, 0.9), s + 2, 0.35, -0.2, 0.45) + if b % 2: + put(pluck(tones[2], 1.6, 0.8), s + 3, 0.28, 0.1, 0.45) + + +def ending(): + s = bar_start(40) + put(kick(1.0, 0.45), s, 1.0, 0.0, 0.05) + put(snap(), s, 0.35, 0.0, 0.4) + put(bass(38, 3.6), s, 0.6, 0.0, 0.05) + put(pad([50, 57, 62, 66, 69, 74], 4.2, 0.01, 0.0, decay=1.3), s, 0.9, rev=0.45) + for m, pan in ((62, -0.3), (66, 0.3), (69, -0.15), (74, 0.15), (78, 0.0)): + put(mallet(m, 4.2, 1.1), s, 0.32, pan, 0.45) + put(pluck(86, 3.0, 1.2), s, 0.2, 0.0, 0.5) + + +def arrange(): + for b in range(40): + if b < 4: + intro(b) + elif b < 8: + build(b) + elif b < 20: + groove(b, 1.0) + elif b < 32: + groove(b, 1.12, arp_up=12, counter=True) + elif b < 36: + interlude(b) + else: + groove(b, 0.85 + 0.1 * (b - 36), sixteenths=b >= 38, four=b >= 38) + ending() + + +def reverb(x): + n_ir = int(2.4 * SR) + t = np.arange(n_ir) / SR + f = np.fft.rfftfreq(n_ir, 1 / SR) + nfft = 1 << int(np.ceil(np.log2(N + n_ir))) + out = np.zeros_like(x) + for c in range(2): + ir = np.fft.irfft(np.fft.rfft(rng.standard_normal(n_ir)) / (1 + (f / 5000) ** 2), n_ir) + ir *= np.exp(-t / 0.32) + ir = np.concatenate([np.zeros(int(0.015 * SR)), ir])[:n_ir] + ir /= np.sqrt(np.sum(ir ** 2)) + out[:, c] = np.fft.irfft(np.fft.rfft(x[:, c], nfft) * np.fft.rfft(ir, nfft), nfft)[:N] + return out + + +def biquad(f, b, a): + z = np.exp(-2j * np.pi * f / SR) + return (b[0] + b[1] * z + b[2] * z ** 2) / (a[0] + a[1] * z + a[2] * z ** 2) + + +def lufs(x): + f = np.fft.rfftfreq(len(x), 1 / SR) + k = (biquad(f, (1.53512485958697, -2.69169618940638, 1.19839281085285), + (1.0, -1.69065929318241, 0.73248077421585)) + * biquad(f, (1.0, -2.0, 1.0), (1.0, -1.99004745483398, 0.99007225036621))) + y = np.fft.irfft(np.fft.rfft(x, axis=0) * k[:, None], len(x), axis=0) + c = np.concatenate([[0.0], np.cumsum(np.sum(y ** 2, 1))]) + starts = np.arange(0, len(x) - 19200 + 1, 4800) + p = (c[starts + 19200] - c[starts]) / 19200 + p = p[-0.691 + 10 * np.log10(p + 1e-20) > -70] + p = p[-0.691 + 10 * np.log10(p) > -0.691 + 10 * np.log10(p.mean()) - 10] + return -0.691 + 10 * np.log10(p.mean()) + + +def run_min(x, w): + n = len(x) + xp = np.concatenate([x, np.ones(-(-(n + w) // w) * w - n)]).reshape(-1, w) + pre = np.minimum.accumulate(xp, 1).ravel() + suf = np.minimum.accumulate(xp[:, ::-1], 1)[:, ::-1].ravel() + return np.minimum(suf[:n], pre[w - 1:w - 1 + n]) + + +def limit(x, half=480): + need = np.minimum(1.0, CEILING / np.maximum(np.abs(x).max(1), 1e-12)) + hold = run_min(np.concatenate([np.ones(half), need, np.ones(half)]), 2 * half + 1) + h = half // 2 + c = np.concatenate([[0.0], np.cumsum(np.concatenate([np.ones(h), hold[:N], np.ones(h)]))]) + gain = (c[2 * h + 1:] - c[:-2 * h - 1]) / (2 * h + 1) + return np.clip(x * gain[:, None], -CEILING, CEILING) + + +def master(): + mix = dry + reverb(send) * 0.32 + fade = np.ones(N) + k = int(1.4 * SR) + fade[-k:] = np.cos(np.linspace(0, np.pi / 2, k)) ** 2 + mix *= fade[:, None] + g = 1.0 + for _ in range(4): + g *= 10 ** ((TARGET_LUFS - lufs(limit(mix * g))) / 20) + return limit(mix * g) + + +def write(x): + OUT.parent.mkdir(parents=True, exist_ok=True) + pcm = np.round(x * 8388607).astype(""$data/settings.json" + HOME=$home ELY_APPEARANCE=light ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_LOG=info ELY_REDUCE_MOTION=0 \ + ${EXTRA_ENV:-} "$root/target/release/$app" "$@" >"$out/logs/$app.out" 2>"$out/logs/$app.err" & + app_pid=$! +} + +# Waits up to $2 seconds for log text $1. +wait_log() { + local i + for i in $(seq $((${2:-30} * 4))); do + grep -q -- "$1" "$out/logs/$app_name.err" 2>/dev/null && return 0 + kill -0 "$app_pid" 2>/dev/null || { echo "app quit: $(tail -n 5 "$out/logs/$app_name.err")" >&2; return 1; } + sleep 0.25 + done + echo "timed out waiting for: $1" >&2 + return 1 +} + +# Waits until the AX tree shows text $1. +wait_ax() { + local i + for i in $(seq $((${2:-30} * 2))); do + "$bin/ax-values" "$app_pid" | grep -q -- "$1" && return 0 + sleep 0.5 + done + echo "AX never showed: $1" >&2 + return 1 +} + +# Center of the first AX element whose line has $1, as "x y". +where() { + "$bin/ax-values" "$app_pid" | grep -F -- "$1" | grep -oE 'at=[0-9]+,[0-9]+' | head -n 1 | sed 's/at=//; s/,/ /' +} + +front() { + osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app_pid) to true" >/dev/null +} + +keys() { osascript -e "tell application \"System Events\" to $1" >/dev/null; } + +# Types $1 at about twelve characters a second. +typed() { + local text=$1 i c + local script='tell application "System Events"' + for ((i = 0; i < ${#text}; i++)); do + c=${text:i:1} + case $c in + '"') c='\"' ;; + '\') c='\\' ;; + esac + script+=$'\n'"keystroke \"$c\""$'\n'"delay $(awk -v r=$RANDOM 'BEGIN { printf "%.3f", 0.065 + (r % 40) / 1000 }')" + done + osascript -e "$script"$'\nend tell' >/dev/null +} + +pause() { sleep "${1:-0.6}"; } +mouse() { "$bin/mouse" "$@"; } + +# Window frame in points, as "x y w h". +frame() { + osascript -l JavaScript - "$app_pid" <<'JS' +ObjC.import('CoreGraphics'); +function run(argv) { + const all = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, 0))); + const w = all.filter(w => w.kCGWindowOwnerPID === parseInt(argv[0]) && w.kCGWindowLayer === 0)[0]; + const b = w.kCGWindowBounds; + return [b.X, b.Y, b.Width, b.Height].join(' '); +} +JS +} + +# Starts capturing the window region of the main screen. +record_start() { + local x y w h + read -r x y w h < <(frame) + [ "$w" = "$win_w" ] && [ "$h" = "$win_h" ] || echo "note: window is ${w}x${h}, not ${win_w}x${win_h}" >&2 + rm -f "$out/rec/$1.mov" + ffmpeg -hide_banner -loglevel error -f avfoundation -capture_cursor 1 -framerate 60 -i "0:none" \ + -vf "crop=$((w * 2)):$((h * 2)):$((x * 2)):$((y * 2))" -c:v h264_videotoolbox -b:v 80M -r 60 \ + "$out/rec/$1.mov" /dev/null || true + wait "$app_pid" 2>/dev/null || true + app_pid= +} + +us_input() { "$inputsource" com.apple.keylayout.US; } +restore_input() { "$inputsource" com.tencent.inputmethod.wetype.pinyin; } + +cleanup() { + [ -n "$cap_pid" ] && kill -INT "$cap_pid" 2>/dev/null || true + quit_app + restore_input +} +trap cleanup EXIT + +tools diff --git a/site/video/record/mouse.swift b/site/video/record/mouse.swift new file mode 100644 index 0000000..1a27bbe --- /dev/null +++ b/site/video/record/mouse.swift @@ -0,0 +1,70 @@ +// Human-paced mouse: move, click, double, drag, scroll. +import CoreGraphics +import Foundation + +let args = Array(CommandLine.arguments.dropFirst()) +let verb = args[0] +let n = args.dropFirst().map { Double($0)! } + +func now() -> CGPoint { CGEvent(source: nil)!.location } + +func post(_ kind: CGEventType, _ at: CGPoint, clicks: Int64 = 1) { + let e = CGEvent(mouseEventSource: nil, mouseType: kind, mouseCursorPosition: at, mouseButton: .left)! + e.setIntegerValueField(.mouseEventClickState, value: clicks) + e.post(tap: .cghidEventTap) +} + +// Eased glide; dragging posts drag events instead. +func glide(to: CGPoint, seconds: Double, dragging: Bool = false) { + let from = now() + let steps = max(2, Int(seconds * 120)) + for i in 1...steps { + let t = Double(i) / Double(steps) + let e = t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2 + let p = CGPoint(x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e) + post(dragging ? .leftMouseDragged : .mouseMoved, p) + usleep(useconds_t(seconds / Double(steps) * 1_000_000)) + } +} + +func pace(_ a: CGPoint) -> Double { + let d = hypot(a.x - now().x, a.y - now().y) + return min(0.9, max(0.25, d / 1400)) +} + +switch verb { +case "move": + let p = CGPoint(x: n[0], y: n[1]) + glide(to: p, seconds: n.count > 2 ? n[2] : pace(p)) +case "click", "double": + let p = CGPoint(x: n[0], y: n[1]) + glide(to: p, seconds: pace(p)) + usleep(120_000) + for c in 1...(verb == "double" ? 2 : 1) { + post(.leftMouseDown, p, clicks: Int64(c)) + usleep(70_000) + post(.leftMouseUp, p, clicks: Int64(c)) + usleep(90_000) + } +case "drag": + let a = CGPoint(x: n[0], y: n[1]), b = CGPoint(x: n[2], y: n[3]) + glide(to: a, seconds: pace(a)) + usleep(150_000) + post(.leftMouseDown, a) + usleep(120_000) + glide(to: b, seconds: n.count > 4 ? n[4] : 0.7, dragging: true) + usleep(120_000) + post(.leftMouseUp, b) +case "scroll": + // Pixel deltas, spread over time; negative scrolls down. + let total = n[0], seconds = n.count > 1 ? n[1] : 1.0 + let steps = max(1, Int(seconds * 60)) + for _ in 0..