// Beats, punch-ins and callouts for the six app sections. import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); export const EDIT_PLAN = resolve(here, "../edit/plan.json"); export const FPS = 30; export const BEAT = 60 / 112; export const LAND = 0.2; /** The letterbox window, read from the integrator's plan. */ export const WINDOW = JSON.parse(readFileSync(EDIT_PLAN, "utf8")).window; export const REC = { w: 1920, h: 1080 }; /** CSS cubic-bezier timing, solved by bisection. */ function bezier(x1, y1, x2, y2) { const at = (a, b, u) => 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); }; } export const settle = bezier(0.2, 0.8, 0.3, 1); export const glide = bezier(0.65, 0, 0.35, 1); /** * Per app: first beat of its section, punch-ins and callouts. * Beats count from the section's bar 1; rects and boxes are recording pixels. * A punch-in eases in over `in`, holds, eases out over `out`; * when one's `out` equals the next one's `in`, the view pans directly. */ export const APPS = [ { app: "doc", start: 32, zooms: [ { rect: [280, 190, 960, 540], in: [4.5, 5], out: [8, 8.5] }, { rect: [250, 187, 900, 506.25], in: [10, 10.5], out: [13.5, 14] }, ], callouts: [ { en: "Type", zh: "离线输入", at: [5, 8], box: [385, 405, 750, 100] }, { en: "Bold ⌘B", zh: "加粗", at: [11, 14], box: [553, 407, 52, 28] }, { en: "125%", zh: "缩放", at: [15, 16], box: [1828, 1054, 54, 24] }, ], }, { app: "table", start: 48, zooms: [ { rect: [0, 70, 1140, 641.25], in: [4.5, 5], out: [7.5, 8] }, { rect: [145, 110, 900, 506.25], in: [12, 12.5], out: [14.5, 15] }, ], callouts: [ { en: "=SUM", zh: "求和", at: [5, 7.5], box: [178, 92, 110, 24] }, { en: "Fill right", zh: "向右填充", at: [6, 8], box: [229, 414, 892, 36] }, { en: "Chart", zh: "插入图表", at: [13, 15], box: [232, 158, 726, 408] }, ], }, { app: "show", start: 64, zooms: [ { rect: [920, 252, 1000, 562.5], in: [5, 5.5], out: [7.5, 8] }, { rect: [360, 202, 1200, 675], in: [12.5, 13], out: [15.5, 16] }, ], callouts: [ { en: "Fly In", zh: "飞入", at: [5.5, 7], box: [1580, 340, 200, 30] }, { en: "Preview", zh: "预览动画", at: [8, 10], box: [1722, 768, 158, 30] }, { en: "Fly In", zh: "飞入", at: [13, 15], box: [425, 423, 1068, 92], side: "left" }, { en: "Fade In", zh: "淡入", at: [15, 16], box: [585, 612, 750, 54], side: "right" }, ], }, { app: "pdf", start: 80, zooms: [ { rect: [605, 360, 960, 540], in: [6.5, 7], out: [8.5, 9] }, { rect: [480, 270, 960, 540], in: [10, 10.5], out: [12.5, 13] }, { rect: [760, 540, 960, 540], in: [14.5, 15], out: [15.5, 16] }, ], callouts: [ { en: "Highlight", zh: "高亮", at: [7, 9], box: [822, 617, 525, 38] }, { en: "Draw", zh: "手写签名", at: [10, 13], box: [727, 478, 466, 190] }, { en: "Sign", zh: "签名", at: [15, 16], box: [1148, 848, 184, 78] }, ], }, { app: "tree", start: 96, zooms: [ { rect: [960, 120, 960, 540], in: [4.5, 5], out: [6, 6.5] }, { rect: [0, 517.5, 1000, 562.5], in: [6.5, 7], out: [8.5, 9] }, { rect: [0, 140, 900, 506.25], in: [9.5, 10], out: [12.5, 13] }, ], callouts: [ { en: "Stage hunk", zh: "暂存此块", at: [5, 6.5], box: [1805, 168, 98, 26] }, { en: "Commit", zh: "提交", at: [7, 9], box: [508, 1013, 129, 29] }, { en: "History", zh: "提交历史", at: [10, 13], box: [2, 170, 640, 605] }, ], }, { app: "mark", start: 112, zooms: [ { rect: [603, 150, 960, 540], in: [8.5, 9], out: [10.5, 11] }, { rect: [603, 530, 960, 540], in: [10.5, 11], out: [11.5, 12] }, ], callouts: [ { en: "Math", zh: "公式", at: [9, 10.5], box: [652, 291, 862, 245] }, { en: "Mermaid", zh: "流程图", at: [11, 12], box: [672, 880, 820, 100] }, { en: "Source ⌘2", zh: "源码", at: [13, 15], box: [1845, 1056, 26, 22] }, ], }, ]; /** Global seconds of a section beat. */ export const seconds = (a, beat) => (a.start + beat) * BEAT; /** Global frames of bars 2–4 of a section. */ export function frames(a) { const first = Math.ceil(seconds(a, 4) * FPS - 1e-6); const end = Math.ceil(seconds(a, 16) * FPS - 1e-6); return { first, count: end - first }; } const FULL = [0, 0, REC.w, REC.h]; const mix = (a, b, p) => a.map((v, i) => v + (b[i] - v) * p); /** Eased moves of the view rect, as [t0, t1, from, to, ease]. */ function moves(a) { const out = []; a.zooms.forEach((z, i) => { const prev = a.zooms[i - 1]; const panned = prev && prev.out[0] === z.in[0] && prev.out[1] === z.in[1]; if (!panned) out.push([seconds(a, z.in[0]), seconds(a, z.in[1]), FULL, z.rect, settle]); const next = a.zooms[i + 1]; const pans = next && next.in[0] === z.out[0] && next.in[1] === z.out[1]; out.push([seconds(a, z.out[0]), seconds(a, z.out[1]), z.rect, pans ? next.rect : FULL, glide]); }); return out; } /** The recording rect shown in the window at time t. */ export function view(a, t) { let rect = FULL; for (const [t0, t1, from, to, ease] of moves(a)) { if (t >= t1) rect = to; else if (t > t0) return mix(from, to, ease((t - t0) / (t1 - t0))); else break; } return rect; } /** Recording pixel to frame pixel through a view rect. */ export function place([rx, ry], [vx, vy, vw, vh]) { return [WINDOW.x + ((rx - vx) / vw) * WINDOW.w, WINDOW.y + ((ry - vy) / vh) * WINDOW.h]; } /** The punch-ins in the integrator's terms, global seconds. */ export function zoomList(a) { const r = (v) => Math.round(v * 10000) / 10000; return a.zooms.map((z) => ({ t0: r(seconds(a, z.in[0])), t1: r(seconds(a, z.out[1])), rect: z.rect, in: z.in.map((b) => r(seconds(a, b))), out: z.out.map((b) => r(seconds(a, b))), })); }