diff --git a/README.en.md b/README.en.md index 789f75e..ebf8c9e 100644 --- a/README.en.md +++ b/README.en.md @@ -8,7 +8,7 @@ *A complete, thoughtfully built local dashboard. From each computer to every task.* -[Live Demo](https://zjay26.github.io/codex-usage/?lang=en) · [Windows x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) · [Linux x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) · [macOS Apple Silicon](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) · [All downloads](#install-directly) · English / [简体中文](README.md) +[Live Demo](https://zjay26.github.io/codex-usage/?lang=en) · [Windows x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) · [Linux x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) · [macOS Apple Silicon](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) · [All downloads](#quick-start) · English / [简体中文](README.md) [![CI](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml/badge.svg)](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/zJay26/codex-usage?display_name=tag)](https://github.com/zJay26/codex-usage/releases/latest) @@ -21,84 +21,75 @@ Overview → minute-precision time range → hourly drill-down → calendar → projects and task trees → search and Fast filters → JSON / CSV export → model pricing → themes and languages. -[Try the interactive demo](https://zjay26.github.io/codex-usage/?lang=en) · [High-resolution video](docs/media/codex-usage-demo-en.mp4) +[Try the interactive demo](https://zjay26.github.io/codex-usage/?lang=en) · [High-resolution video](docs/media/codex-usage-demo-en.mp4) · [30-second promo](docs/media/codex-usage-promo-en.mp4) ([vertical](docs/media/codex-usage-promo-en-vertical.mp4)) > Recorded directly from the current Dashboard with healthy, fully priced synthetic data. The online demo does not read your files, set cookies, run analytics, or make external requests. -## From the big picture to every task +## Quick start -**codex-usage is a complete local usage dashboard built for people who use Codex every day.** Totals, trends, costs, models, projects, and tasks come together in one clear interface. Start with the big picture, then follow a date, hour, model, or task to understand what drove consumption. It is useful on a single computer, and per-machine accounting keeps each host's usage clear when you share an account across devices. +**Just two steps: download the program for your computer, then run `install` once.** -Install once to index existing history and keep new usage up to date. **Go from “How much did I use today?” to “What did these 90 minutes, this project, or this task tree consume?” in the same interface.** Regular / Fast breakdowns, minute-precision ranges, Session search, combined filters, API-equivalent costs, and exports are all included. English and Chinese, light and dark themes, display settings, and a mobile layout make it comfortable to check every day. +It then organizes the Codex history already on your computer and keeps counting in the background. To check your usage later, open [http://127.0.0.1:43189](http://127.0.0.1:43189) in a browser. No database to set up, no account to create, and your data never leaves your computer. -One binary runs on Windows, Linux / WSL, or macOS, with no database service or central server to deploy. Statistics stay on the current computer; prompts, replies, and tool output are never stored, and `auth.json` is never read. Costs are estimates using bundled public API prices and Fast credit multipliers, not actual bills or account quotas. +### Step 1: Pick your version and download + +Not sure which one you need? Match your computer to the table: -## Install directly +| Your computer | Download | +|---|---| +| Windows (most PCs) | [Windows x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) | +| Windows ARM device (uncommon; Settings → System → About shows an ARM processor) | [Windows ARM64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-arm64.exe) | +| Mac with Apple silicon (M1 / M2 / M3 / M4, etc.) | [macOS Apple Silicon](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) | +| Mac with an Intel chip (Apple menu → About This Mac says “Intel”) | [macOS Intel](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-amd64) | +| Linux / WSL (most computers) | [Linux x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) | +| Linux / WSL ARM device | [Linux ARM64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-arm64) | -This README covers stable **[v2.7.1](https://github.com/zJay26/codex-usage/releases/tag/v2.7.1)**; see the [release notes](docs/releases/v2.7.1.md) for changes and upgrade boundaries. Download links below always resolve to the latest stable release. +### Step 2: Run install -| System | amd64 / x64 | arm64 | -|---|---|---| -| Windows | [x64 binary](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) | [ARM64 binary](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-arm64.exe) | -| Linux / WSL | [x64 binary](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) | [ARM64 binary](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-arm64) | -| macOS | [Intel binary](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-amd64) | [Apple Silicon binary](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) | +**Already downloaded it in your browser?** Open a terminal (PowerShell on Windows), go to the folder containing the file, and run ` --lang en install`. On Windows, for example: `.\codex-usage-windows-amd64.exe --lang en install`. On Mac / Linux, first run `chmod +x ` to make it executable, then `./ --lang en install`. -### Windows +**Want the shortcut?** Copy the commands for your system below and paste them into a terminal; they download and install in one go. -amd64 / x64, without administrator privileges; replace `amd64` with `arm64` in the download URL for ARM64 devices: +**Windows**: search for “PowerShell” in the Start menu, open it, and paste. No administrator privileges needed. For ARM64 devices, replace `amd64` with `arm64` in the URL: ```powershell Invoke-WebRequest https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe -OutFile codex-usage.exe .\codex-usage.exe --lang en install ``` -### Linux / WSL - -amd64 / x64; replace `amd64` with `arm64` in the download URL for ARM64 devices: +**macOS**: open Terminal (search for it in Launchpad) and paste. No `sudo` needed. This is the Apple Silicon version; for Intel, replace `arm64` with `amd64` in the URL: ```bash -curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64 -o codex-usage +curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64 -o codex-usage chmod +x codex-usage ./codex-usage --lang en install ``` -Login startup uses `systemd --user` by default. If the user bus is unavailable, the installer attempts a detached start and prints a warning; automatic startup then needs manual configuration. - -### macOS +Run this from a normal macOS graphical login session; the service then starts automatically at login. The binary is not Apple Developer-ID signed or notarized, so macOS may block it on first run; see [macOS installation](docs/macos.md) for opening and verification steps. In an SSH-only session, use `./codex-usage serve` to run in the foreground. -Apple Silicon; replace `arm64` with `amd64` in the download URL for Intel: +**Linux / WSL**: open a terminal and paste. This is the x64 version; for ARM64 devices, replace `amd64` with `arm64` in the URL: ```bash -curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64 -o codex-usage +curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64 -o codex-usage chmod +x codex-usage ./codex-usage --lang en install ``` -Install from a normal macOS graphical login session. A per-user LaunchAgent starts the service at login without `sudo`. Binaries are not Apple Developer-ID signed or notarized; see [macOS installation](docs/macos.md) for verification and opening instructions. In an SSH-only session, use `./codex-usage serve` to run in the foreground. - -### Verify and open the Dashboard - -Each Release includes [SHA256SUMS](https://github.com/zJay26/codex-usage/releases/latest/download/SHA256SUMS). Before executing the downloaded binary, you can calculate its hash with `Get-FileHash .\codex-usage.exe -Algorithm SHA256` on Windows, `sha256sum codex-usage` on Linux, or `shasum -a 256 codex-usage` on macOS. Compare it with the manifest entry for your system and architecture's full asset filename. - -The installer finds existing local usage and starts the background service. Open the Dashboard URL printed by installation, [http://127.0.0.1:43189](http://127.0.0.1:43189) by default. You can also run the installed binary to open the browser; commands for the default locations are: +Login startup uses `systemd --user` by default. If that is unavailable, the installer starts the program in the background and prints a warning; automatic startup then needs manual configuration. -| System | Open the Dashboard | -|---|---| -| Windows PowerShell | `& "$env:LOCALAPPDATA\Programs\codex-usage\codex-usage.exe"` | -| Linux / WSL | `"$HOME/.local/bin/codex-usage"` | -| macOS | `"$HOME/Library/Application Support/codex-usage/bin/codex-usage"` | +### Step 3: Open the Dashboard -Installation does not modify `PATH`. If you set `CODEX_USAGE_HOME`, use the executable path printed by installation. On a headless Linux server, the program prints an SSH tunnel command; run it on your own computer before opening the Dashboard. +When installation finishes, open [http://127.0.0.1:43189](http://127.0.0.1:43189) in your browser (only your own computer can open this address). If the installer prints a different address, use that one. -### Upgrade an existing installation +That's it: the program runs in the background, your history is organized, and new usage keeps updating automatically. From now on, just open that address to check. -Starting with **v2.5.0**, the application checks GitHub for the latest stable release every six hours by default. Updates are optional: open **Software updates** in the footer to review release notes, disable automatic checks, or check manually. Only **Download and update** downloads the release, verifies SHA256, backs up the program and local statistics, and replaces and restarts the application. A startup failure triggers an attempt to restore the previous program and data. Backups remain under `.codex-usage-updates/run-*` in the state directory. +## From the big picture to every task -**Installed v2.5.0 or later can update in the app; versions before v2.5.0 require a manual download and `install` first.** Portable and preview copies only offer version checks and the release page. Checks fetch version information from GitHub, downloads come from this project's Release assets, and neither uploads usage, paths, or conversations. Turning off automatic checks stops background update requests. Re-running `install` remains available for manual upgrades. +**codex-usage is a complete local usage dashboard built for people who use Codex every day.** Totals, trends, costs, models, projects, and tasks come together in one clear interface. Start with the big picture, then follow a date, hour, model, or task to understand what drove consumption. It is useful on a single computer, and per-machine accounting keeps each host's usage clear when you share an account across devices. -**Software updates** lets you set the update download directory, open the folder, or copy its path. The default is `codex-usage` inside your user Downloads directory. Version folders retain the release binaries, and the UI shows the full path of the last download. Directory changes only affect future downloads; existing files stay in place and database backups remain in the local state directory. +Install once to index existing history and keep new usage up to date. **Go from “How much did I use today?” to “What did these 90 minutes, this project, or this task tree consume?” in the same interface.** Regular / Fast breakdowns, minute-precision ranges, Session search, combined filters, API-equivalent costs, and exports are all included. English and Chinese, light and dark themes, display settings, and a mobile layout make it comfortable to check every day. -**v2.6.4 fixes missing compaction usage and retains the v2.6.3 mixed-counter fix.** Upgrading an older ledger to schema v11 preserves its statistics and flags them for review; incremental scans pause. Back up the state and verify source JSONL coverage, then select **Rescan → Approve and rebuild** or explicitly run `codex-usage scan --rebuild` to correct history. Rebuilding cannot recover deleted source files, and upgrading the binary alone does not correct old totals. +One binary runs on Windows, Linux / WSL, or macOS, with no database service or central server to deploy. Statistics stay on the current computer; prompts, replies, and tool output are never stored, and `auth.json` is never read. Costs are estimates using bundled public API prices and Fast credit multipliers, not actual bills or account quotas. ## A complete toolkit for everyday use @@ -318,7 +309,7 @@ npm test By default, `npm test` builds and launches a real Go binary in a temporary directory. Set `CODEX_USAGE_BIN` to reuse an existing build. -Regenerate README animations, videos, and screenshots with `npm run capture:media`; see [media notes](docs/media/README.md) for dependencies, scenarios, and recording checks. +Regenerate README animations, videos, and screenshots with `npm run capture:media`, and the 30-second promo with `npm run capture:promo`; see [media notes](docs/media/README.md) for dependencies, scenarios, and recording checks. Current [CI](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml) covers Go tests and vet on Windows, Linux, macOS Apple Silicon / Intel, Linux concurrency checks, six-target cross-builds, and Dashboard tests. Each macOS architecture also runs three install/uninstall/reinstall cycles. Release publication requires the native macOS checks to pass. diff --git a/README.md b/README.md index af0f757..57fc684 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ *完整、顺手的本地用量仪表盘,从每台电脑到每一次任务。* -[在线体验](https://zjay26.github.io/codex-usage/?lang=zh-CN) · [Windows x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) · [Linux x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) · [macOS Apple Silicon](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) · [全部下载](#直接安装) · [English](README.en.md) / 简体中文 +[在线体验](https://zjay26.github.io/codex-usage/?lang=zh-CN) · [Windows x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) · [Linux x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) · [macOS Apple Silicon](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) · [全部下载](#快速开始) · [English](README.en.md) / 简体中文 [![CI](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml/badge.svg)](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/zJay26/codex-usage?display_name=tag)](https://github.com/zJay26/codex-usage/releases/latest) @@ -21,84 +21,75 @@ 概览 → 分钟级时间查询 → 小时下钻 → 每日月历 → 项目与任务树 → 搜索与 Fast 筛选 → JSON / CSV 导出 → 模型定价 → 明暗主题与中英切换。 -[亲手试用在线 Demo](https://zjay26.github.io/codex-usage/?lang=zh-CN) · [高清演示视频](docs/media/codex-usage-demo-zh.mp4) +[亲手试用在线 Demo](https://zjay26.github.io/codex-usage/?lang=zh-CN) · [高清演示视频](docs/media/codex-usage-demo-zh.mp4) · [30 秒宣传片](docs/media/codex-usage-promo-zh.mp4)([竖屏版](docs/media/codex-usage-promo-zh-vertical.mp4)) > 演示使用完整定价、无异常记录的合成数据,直接录制当前 Dashboard。在线 Demo 不读取你的文件、不设 Cookie、无埋点或外部请求。 -## 从总览到每一次任务 +## 快速开始 -**codex-usage 是为日常高频使用 Codex 打磨的完整本地用量统计工具。** 总量、趋势、费用、模型、项目和任务明细集中在一个清晰的 Dashboard:先看全貌,再顺着日期、小时、模型或任务找到消耗来源。单台电脑用起来完整顺手,多台电脑使用同一账号时,逐电脑统计又能让每台主机的用量各自清楚。 +**只有两步:下载适合你电脑的程序,然后运行一次 `install`。** -安装一次,历史记录自动整理,后续用量持续更新。**从“今天用了多少”到“这 90 分钟、这个项目、这棵子任务树花了多少”,都能在同一个界面里查清。** 常规 / Fast 拆分、精确到分钟的时间范围、Session 搜索、组合筛选、API 等价费用和数据导出一应俱全;中英双语、明暗主题、显示设置与手机布局让日常查看同样舒服。 +装好后它会自动整理你电脑上已有的 Codex 记录,并在后台持续统计。以后想看用量,用浏览器打开 [http://127.0.0.1:43189](http://127.0.0.1:43189) 即可。不用装数据库,不用注册账号,数据也不会离开你的电脑。 -Windows、Linux / WSL、macOS 均可使用,一个程序即可安装,无需部署数据库或中心服务器。所有统计留在当前电脑,不保存 prompt、回复或工具输出,也不读取 `auth.json`。费用是按内置公开 API 单价及 Fast 额度倍率计算的等价估算,并非真实账单或账号配额。 +### 第 1 步:选对版本,下载 + +不确定自己是哪种电脑,可以对照下表: -## 直接安装 +| 你的电脑 | 点击下载 | +|---|---| +| Windows(绝大多数电脑) | [Windows x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) | +| Windows ARM 设备(少数机型;可在“设置 → 系统 → 关于”看到“ARM 处理器”) | [Windows ARM64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-arm64.exe) | +| Mac,M 系列芯片(M1 / M2 / M3 / M4 等) | [macOS Apple Silicon](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) | +| Mac,Intel 芯片(左上角苹果菜单 → 关于本机,写着“Intel”) | [macOS Intel](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-amd64) | +| Linux / WSL(绝大多数电脑) | [Linux x64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) | +| Linux / WSL ARM 设备 | [Linux ARM64](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-arm64) | -本文对应稳定版 **[v2.7.1](https://github.com/zJay26/codex-usage/releases/tag/v2.7.1)**;变更和升级边界见 [发布说明](docs/releases/v2.7.1.md)。以下下载链接始终指向最新稳定版。 +### 第 2 步:运行 install -| 系统 | amd64 / x64 | arm64 | -|---|---|---| -| Windows | [x64 程序](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe) | [ARM64 程序](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-arm64.exe) | -| Linux / WSL | [x64 程序](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64) | [ARM64 程序](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-arm64) | -| macOS | [Intel 程序](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-amd64) | [Apple Silicon 程序](https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64) | +**已经用浏览器下载好了?** 打开终端(Windows 用 PowerShell),进入文件所在的文件夹,运行 `<程序文件名> install` 就行。例如 Windows:`.\codex-usage-windows-amd64.exe install`;Mac / Linux 需先执行 `chmod +x <程序文件名>` 赋予运行权限,再执行 `./<程序文件名> install`。 -### Windows +**想省事?** 直接复制下面对应系统的命令,粘贴到终端回车,下载和安装一次完成: -amd64 / x64,无需管理员权限;ARM64 设备将下载地址中的 `amd64` 改为 `arm64`: +**Windows**:开始菜单搜索“PowerShell”并打开,粘贴运行,无需管理员权限。ARM64 设备把地址里的 `amd64` 改成 `arm64`: ```powershell Invoke-WebRequest https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-windows-amd64.exe -OutFile codex-usage.exe .\codex-usage.exe install ``` -### Linux / WSL - -amd64 / x64;ARM64 设备将下载地址中的 `amd64` 改为 `arm64`: +**macOS**:打开“终端”(启动台里搜索),粘贴运行,无需 `sudo`。以下是 Apple Silicon 版;Intel 设备把地址里的 `arm64` 改成 `amd64`: ```bash -curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64 -o codex-usage +curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64 -o codex-usage chmod +x codex-usage ./codex-usage install ``` -默认使用 `systemd --user` 登录自启;user bus 不可用时会尝试后台启动并给出提示,需要自行完成自启配置。 - -### macOS +请在 Mac 的图形登录界面下操作,安装后会随登录自动启动。程序未经 Apple Developer ID 签名或公证,首次运行可能被系统拦截;打开方式和校验见 [macOS 安装说明](docs/macos.md)。只能通过 SSH 连接时,可用 `./codex-usage serve` 前台运行。 -Apple Silicon;Intel 设备将下载地址中的 `arm64` 改为 `amd64`: +**Linux / WSL**:打开终端,粘贴运行。以下是 x64 版;ARM64 设备把地址里的 `amd64` 改成 `arm64`: ```bash -curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-darwin-arm64 -o codex-usage +curl -fL https://github.com/zJay26/codex-usage/releases/latest/download/codex-usage-linux-amd64 -o codex-usage chmod +x codex-usage ./codex-usage install ``` -请在 macOS 图形登录会话中安装,通过用户 LaunchAgent 登录自启,无需 `sudo`。程序未经 Apple Developer ID 签名或公证;校验和打开方式见 [macOS 安装说明](docs/macos.md)。仅有 SSH 会话时可用 `./codex-usage serve` 前台运行。 - -### 校验与打开 Dashboard - -每个 Release 附带 [SHA256SUMS](https://github.com/zJay26/codex-usage/releases/latest/download/SHA256SUMS)。可在执行下载的程序前,用 Windows 的 `Get-FileHash .\codex-usage.exe -Algorithm SHA256`、Linux 的 `sha256sum codex-usage` 或 macOS 的 `shasum -a 256 codex-usage` 计算哈希,与清单中对应系统和架构的完整文件名核对。 - -安装器会整理本机已有记录并启动后台服务。之后访问安装输出中的 Dashboard 地址,默认是 [http://127.0.0.1:43189](http://127.0.0.1:43189)。也可运行已安装的程序打开浏览器;默认路径下的命令如下: +默认通过 `systemd --user` 实现登录自启;如果系统不支持,程序会先在后台启动并给出提示,之后需要自行配置自启。 -| 系统 | 打开 Dashboard | -|---|---| -| Windows PowerShell | `& "$env:LOCALAPPDATA\Programs\codex-usage\codex-usage.exe"` | -| Linux / WSL | `"$HOME/.local/bin/codex-usage"` | -| macOS | `"$HOME/Library/Application Support/codex-usage/bin/codex-usage"` | +### 第 3 步:打开 Dashboard -安装器不会修改 `PATH`。设置过 `CODEX_USAGE_HOME` 时,以安装输出的程序路径为准。Linux 服务器没有桌面环境时,程序会打印 SSH 隧道命令;在自己的电脑执行后再打开 Dashboard。 +安装完成后,用浏览器访问 [http://127.0.0.1:43189](http://127.0.0.1:43189)(这个地址只有你自己的电脑能打开)。如果安装输出里给出了不同的地址,以输出为准。 -### 升级现有安装 +这样就完成了:程序在后台运行,历史记录已经整理好,之后的新用量会自动更新,日常直接打开上面的地址查看就行。 -从 **v2.5.0** 起,程序默认每 6 小时检查一次 GitHub 最新稳定版,只提示、不强制更新。页面底部的“软件更新”可查看发布说明、关闭自动检查或手动检查;点击“下载并更新”后才下载并校验 SHA256、备份程序和本机统计、替换并重启。新版本启动失败会尝试恢复旧程序和数据,备份保留在状态目录的 `.codex-usage-updates/run-*` 中。 +## 从总览到每一次任务 -**已安装 v2.5.0 或更高版本,可直接在软件内选择升级;早于 v2.5.0 的版本需先手动下载并执行 `install`。** 便携版和预览版只提供检查和发布页链接。更新检查只向 GitHub 请求版本信息,下载也来自本项目 Release,不上传用量、路径或对话;关闭自动检查后不会在后台请求更新信息。仍可随时通过重新执行 `install` 手动升级。 +**codex-usage 是为日常高频使用 Codex 打磨的完整本地用量统计工具。** 总量、趋势、费用、模型、项目和任务明细集中在一个清晰的 Dashboard:先看全貌,再顺着日期、小时、模型或任务找到消耗来源。单台电脑用起来完整顺手,多台电脑使用同一账号时,逐电脑统计又能让每台主机的用量各自清楚。 -“软件更新”中可以设置**更新包下载目录**,并打开文件夹或复制路径。默认保存到用户下载目录下的 `codex-usage` 文件夹;版本文件夹内保留正式安装包,界面显示最近下载的完整路径。保存新目录只影响后续下载,已有文件留在原处;数据库备份仍保留在本机状态目录。 +安装一次,历史记录自动整理,后续用量持续更新。**从“今天用了多少”到“这 90 分钟、这个项目、这棵子任务树花了多少”,都能在同一个界面里查清。** 常规 / Fast 拆分、精确到分钟的时间范围、Session 搜索、组合筛选、API 等价费用和数据导出一应俱全;中英双语、明暗主题、显示设置与手机布局让日常查看同样舒服。 -**v2.6.4 修复压缩请求漏计,保留 v2.6.3 的混合累计修复。** 旧版统计库升级到 schema v11 后,已有统计会保留并标记为需要核对,增量扫描暂停。先备份并确认源 JSONL 仍齐全,再点击“重新扫描”并选择“同意并重建”,或显式执行 `codex-usage scan --rebuild`,修正历史用量。源文件已删除的历史无法由重建恢复;只升级程序不会自动修正旧账。 +Windows、Linux / WSL、macOS 均可使用,一个程序即可安装,无需部署数据库或中心服务器。所有统计留在当前电脑,不保存 prompt、回复或工具输出,也不读取 `auth.json`。费用是按内置公开 API 单价及 Fast 额度倍率计算的等价估算,并非真实账单或账号配额。 ## 为日常使用打磨的完整功能 @@ -320,7 +311,7 @@ npm test `npm test` 默认在临时目录构建并启动真实 Go 二进制;设置 `CODEX_USAGE_BIN` 可以复用已有构建产物。 -README 动图、视频和截图可通过 `npm run capture:media` 重新生成;依赖、演示场景与录制检查见 [媒体说明](docs/media/README.md)。 +README 动图、视频和截图可通过 `npm run capture:media` 重新生成,30 秒宣传片可通过 `npm run capture:promo` 重新生成;依赖、演示场景与录制检查见 [媒体说明](docs/media/README.md)。 当前 [CI](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml) 覆盖 Windows、Linux、macOS Apple Silicon / Intel 的 Go 测试与 vet、Linux 并发检查、六目标交叉构建及 Dashboard 测试;两个 macOS 架构还各执行三轮安装、卸载和重装。Release 发布也要求原生 macOS 检查通过。 diff --git a/docs/media/README.md b/docs/media/README.md index f85586b..af99be2 100644 --- a/docs/media/README.md +++ b/docs/media/README.md @@ -24,3 +24,23 @@ Outputs: The tour covers overview ranges, minute-precision queries, hourly drill-down, daily calendar, projects, expandable task trees, Session search, Fast filtering, JSON / CSV export, model prices, themes, and languages. Pointer movement takes 160–340 ms with easing; most results remain visible for 350–850 ms. There is no global time compression and no truncated tail. Capture fixes the clock and timezone for reproducible dates, waits for pricing before recording, and checks the entire tour for visible warning/error banners, JavaScript errors, and external requests. The export dialog is demonstrated without downloading files. Software installation and updates are documented in the project README; the synthetic demo does not install software. + +## Promo film + +`npm run capture:promo` renders a 30-second promotional film from the same synthetic demo, in Chinese and English, landscape and vertical: + +- `codex-usage-promo-zh.mp4` and `codex-usage-promo-en.mp4`: 1920 × 1080, 30 fps, H.264 + AAC. +- `codex-usage-promo-zh-vertical.mp4` and `codex-usage-promo-en-vertical.mp4`: 1080 × 1920 for short-video platforms. +- `codex-usage-promo-poster.jpg` and `codex-usage-promo-poster-en.jpg`: landscape posters with a play badge. + +[![Codex Usage promo poster](codex-usage-promo-poster-en.jpg)](codex-usage-promo-en.mp4) + +The pipeline has three steps, all offline: + +1. `scripts/promo/assets.mjs` captures 2× Dashboard stills of each feature and the rectangles the camera focuses on. The portrait cut uses a 1000 × 1150 Dashboard viewport so the panel fills the frame. The demo notice banner is left out of the stills; the film carries its own "synthetic demo data" label instead. +2. `scripts/promo/promo.html`, `promo.css`, and `promo.js` compose the scenes. Every frame is a pure function of time, so `scripts/capture-promo.mjs` seeks frame by frame and pipes lossless screenshots into FFmpeg. There is no real-time recording, dropped frames, or timing drift. +3. `scripts/promo/music.mjs` synthesizes the soundtrack (pads, bass, drums, plucks, bells, and whooshes) in Node, then normalizes it to −16 LUFS with FFmpeg's two-pass `loudnorm`. The soundtrack is generated, so it carries no licensing obligations. + +`scripts/promo/timeline.json` is the single source of scene timing for both picture and sound. `MEDIA_LOCALE=zh-CN|en` and `PROMO_FORMAT=landscape|vertical` render a single version. `PROMO_PREVIEW=1` writes only the review keyframes and posters. Rendering checks each MP4's resolution, frame count, duration, audio stream, and size. Review stills and `render.json` are written to `../../dist/promo-review/`. + +The copy uses system fonts, the same as the Dashboard. Segoe UI Variable and Microsoft YaHei were used for the committed renders, so other systems may show slightly different typography. diff --git a/docs/media/codex-usage-promo-en-vertical.mp4 b/docs/media/codex-usage-promo-en-vertical.mp4 new file mode 100644 index 0000000..85689a4 Binary files /dev/null and b/docs/media/codex-usage-promo-en-vertical.mp4 differ diff --git a/docs/media/codex-usage-promo-en.mp4 b/docs/media/codex-usage-promo-en.mp4 new file mode 100644 index 0000000..c38ed7e Binary files /dev/null and b/docs/media/codex-usage-promo-en.mp4 differ diff --git a/docs/media/codex-usage-promo-poster-en.jpg b/docs/media/codex-usage-promo-poster-en.jpg new file mode 100644 index 0000000..3734c83 Binary files /dev/null and b/docs/media/codex-usage-promo-poster-en.jpg differ diff --git a/docs/media/codex-usage-promo-poster.jpg b/docs/media/codex-usage-promo-poster.jpg new file mode 100644 index 0000000..f349b2b Binary files /dev/null and b/docs/media/codex-usage-promo-poster.jpg differ diff --git a/docs/media/codex-usage-promo-zh-vertical.mp4 b/docs/media/codex-usage-promo-zh-vertical.mp4 new file mode 100644 index 0000000..4251c04 Binary files /dev/null and b/docs/media/codex-usage-promo-zh-vertical.mp4 differ diff --git a/docs/media/codex-usage-promo-zh.mp4 b/docs/media/codex-usage-promo-zh.mp4 new file mode 100644 index 0000000..75fdf92 Binary files /dev/null and b/docs/media/codex-usage-promo-zh.mp4 differ diff --git a/package.json b/package.json index 9e0fb03..3f6dc74 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build:icons": "node scripts/export-icons.mjs", "build:demo": "node scripts/build-demo.mjs dist/pages", "capture:media": "node scripts/capture-media.mjs", + "capture:promo": "node scripts/capture-promo.mjs", "pretest": "npm run build:demo", "test": "playwright test tests/dashboard.spec.mjs tests/demo.spec.mjs", "pretest:demo": "npm run build:demo", diff --git a/scripts/capture-media.mjs b/scripts/capture-media.mjs index 9c889ba..b164bfb 100644 --- a/scripts/capture-media.mjs +++ b/scripts/capture-media.mjs @@ -1,82 +1,23 @@ import { chromium, expect } from "@playwright/test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { once } from "node:events"; -import { createServer } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { spawn, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; +import { assertSynthetic, openDemo, repoRoot, run, startDemoServer } from "./media-lib.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const mediaDir = path.join(repoRoot, "docs", "media"); const imagesDir = path.join(repoRoot, "docs", "images"); const reviewDir = path.join(repoRoot, "dist", "media-review"); const tempDir = await mkdtemp(path.join(tmpdir(), "codex-usage-media-")); const viewport = { width: 1440, height: 1000 }; -const fixedTime = new Date("2026-09-23T16:30:00+08:00"); -const port = await new Promise((resolve, reject) => { - const probe = createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const { port } = probe.address(); - probe.close(error => error ? reject(error) : resolve(port)); - }); -}); -const baseURL = `http://127.0.0.1:${port}/codex-usage/`; - -function run(command, args, capture = false) { - const result = spawnSync(command, args, { cwd: repoRoot, stdio: capture ? "pipe" : "inherit", encoding: "utf8", windowsHide: true }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr || result.status}`); - return result.stdout; -} - -function assertSynthetic(text) { - for (const pattern of [/[A-Z]:\\Users\\/i, /\/home\/[a-z0-9._-]+\//i, /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i]) { - if (pattern.test(text)) throw new Error(`Sensitive-looking value matched ${pattern}`); - } -} - -async function waitForServer() { - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - try { if ((await fetch(baseURL)).ok) return; } catch {} - await delay(100); - } - throw new Error("media demo server did not become ready"); -} - -async function openDemo(context, locale) { - const page = await context.newPage(); - await page.clock.setFixedTime(fixedTime); - const errors = []; - page.on("pageerror", error => errors.push(error.message)); - page.on("request", request => { - if (new URL(request.url()).origin !== new URL(baseURL).origin) errors.push(`External request: ${request.url()}`); - }); - await page.goto(`${baseURL}?lang=${locale}`, { waitUntil: "networkidle" }); - await expect(page.locator("#overviewTotal")).not.toHaveText("—"); - await expect(page.locator("#overviewCoverage")).toContainText("100"); - await expect(page.locator("#coverageBanner")).toBeHidden(); - await page.evaluate(() => { - window.mediaWarnings = []; - // Observe transient errors between chapters as well as settled screens. - setInterval(() => { - for (const selector of ["#coverageBanner", "#toast.error", "#customRangeError", "#updateError"]) { - const node = document.querySelector(selector); - if (node?.checkVisibility() && node.textContent.trim()) window.mediaWarnings.push(node.textContent.trim()); - } - }, 40); - }); - return { page, errors }; -} +let baseURL; async function recordDemo(browser, locale) { const chapterDir = path.join(reviewDir, locale); await mkdir(chapterDir, { recursive: true }); const context = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme: "light", timezoneId: "Asia/Shanghai", recordVideo: { dir: tempDir, size: viewport } }); - const { page, errors } = await openDemo(context, locale); + const { page, errors } = await openDemo(context, baseURL, locale); const video = page.video(); // A recording-only pointer; product layout and content are untouched. await page.evaluate(() => { @@ -232,11 +173,10 @@ async function recordDemo(browser, locale) { } } -run(process.execPath, ["scripts/build-demo.mjs", "dist/pages"]); for (const directory of [mediaDir, imagesDir, reviewDir]) await mkdir(directory, { recursive: true }); -const server = spawn(process.execPath, ["scripts/serve-static.mjs", "dist/pages", String(port), "codex-usage"], { cwd: repoRoot, stdio: "ignore", windowsHide: true }); +const server = await startDemoServer(); +baseURL = server.baseURL; try { - await waitForServer(); const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); try { const socialContext = await browser.newContext({ viewport: { width: 1280, height: 640 }, deviceScaleFactor: 1 }); @@ -246,7 +186,7 @@ try { await social.screenshot({ path: path.join(mediaDir, "social-preview.png") }); await socialContext.close(); const desktopContext = await browser.newContext({ viewport, colorScheme: "light", timezoneId: "Asia/Shanghai", deviceScaleFactor: 1 }); - const { page: desktop } = await openDemo(desktopContext, "zh-CN"); + const { page: desktop } = await openDemo(desktopContext, baseURL, "zh-CN"); assertSynthetic(await desktop.locator("body").innerText()); await desktop.screenshot({ path: path.join(imagesDir, "dashboard.png"), fullPage: true }); await desktop.setViewportSize({ width: 390, height: 844 }); @@ -258,11 +198,7 @@ try { } console.log(`Generated media in ${mediaDir}; chapter screenshots and audit in ${reviewDir}`); } finally { - if (server.exitCode === null && server.signalCode === null) { - const exited = once(server, "exit"); - server.kill(); - await Promise.race([exited, delay(5_000)]); - } + await server.stop(); const tempRoot = path.resolve(tmpdir()) + path.sep; if (!path.resolve(tempDir).startsWith(tempRoot) || !path.basename(tempDir).startsWith("codex-usage-media-")) throw new Error("Unexpected capture cleanup path"); await rm(tempDir, { recursive: true, force: true }); diff --git a/scripts/capture-promo.mjs b/scripts/capture-promo.mjs new file mode 100644 index 0000000..b709204 --- /dev/null +++ b/scripts/capture-promo.mjs @@ -0,0 +1,156 @@ +import { chromium } from "@playwright/test"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { once } from "node:events"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawn } from "node:child_process"; +import { assertSynthetic, repoRoot, run, startDemoServer } from "./media-lib.mjs"; +import { captureAssets } from "./promo/assets.mjs"; +import { renderMusic } from "./promo/music.mjs"; + +const promoDir = path.join(repoRoot, "scripts", "promo"); +const mediaDir = path.join(repoRoot, "docs", "media"); +const reviewDir = path.join(repoRoot, "dist", "promo-review"); +const timeline = JSON.parse(await readFile(path.join(promoDir, "timeline.json"), "utf8")); +const locales = process.env.MEDIA_LOCALE ? [process.env.MEDIA_LOCALE] : ["zh-CN", "en"]; +const formats = process.env.PROMO_FORMAT ? [process.env.PROMO_FORMAT] : ["landscape", "vertical"]; +// PROMO_PREVIEW=1 renders only the review keyframes, for fast iteration on the composition. +const previewOnly = process.env.PROMO_PREVIEW === "1"; +const sizes = { landscape: { width: 1920, height: 1080 }, vertical: { width: 1080, height: 1920 } }; +// Dashboard viewport captured for each format; the portrait cut uses a narrower, taller page. +const stillViewports = { landscape: { width: 1440, height: 900 }, vertical: { width: 1000, height: 1150 } }; +const frameCount = Math.round(timeline.duration * timeline.fps); +const tempDir = await mkdtemp(path.join(tmpdir(), "codex-usage-promo-")); +const assetsDir = path.join(tempDir, "assets"); + +function outputName(locale, format) { + return `codex-usage-promo-${locale === "en" ? "en" : "zh"}${format === "vertical" ? "-vertical" : ""}.mp4`; +} + +// One review still per scene (its settled middle) plus the poster time. +function reviewTimes() { + const times = timeline.scenes.map(s => ({ name: s.id, t: s.id === "cta" ? s.end - .2 : s.start + (s.end - s.start) * .62 })); + return [...times, { name: "poster", t: timeline.poster }]; +} + +async function openStage(browser, locale, format, manifests) { + const size = sizes[format]; + const context = await browser.newContext({ viewport: size, deviceScaleFactor: 1, colorScheme: "dark" }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("console", message => { if (message.type() === "error") errors.push(message.text()); }); + page.on("request", request => { if (!request.url().startsWith("file:")) errors.push(`External request: ${request.url()}`); }); + const otherLocale = locale === "en" ? "zh-CN" : "en"; + const other = manifests[`${otherLocale}-${format}`] || manifests[`${locale}-${format}`]; + const icon = (await readFile(path.join(repoRoot, "internal", "web", "static", "icon.svg"), "utf8")).replace(/<\/?title>[^<]*|[^<]*<\/desc>/g, ""); + await page.addInitScript(data => { window.PROMO = data; }, { timeline, locale, format, icon, assets: manifests[`${locale}-${format}`], other }); + await page.goto(pathToFileURL(path.join(promoDir, "promo.html")).href); + await page.evaluate(() => window.promo.ready); + return { context, page, errors }; +} + +async function renderVideo(browser, locale, format, manifests, audio) { + const { context, page, errors } = await openStage(browser, locale, format, manifests); + const review = path.join(reviewDir, `${locale}-${format}`); + await mkdir(review, { recursive: true }); + try { + for (const { name, t } of reviewTimes()) { + await page.evaluate(t => window.promo.seek(t), t); + await page.screenshot({ path: path.join(review, `${t.toFixed(2).padStart(5, "0")}-${name}.png`) }); + } + assertSynthetic(await page.locator("body").innerText()); + if (format === "landscape") { + await page.evaluate(t => { window.promo.seek(t); window.promo.poster(true); }, timeline.poster); + const poster = path.join(tempDir, `poster-${locale}.png`); + await page.screenshot({ path: poster }); + await page.evaluate(() => window.promo.poster(false)); + run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", poster, "-q:v", "3", path.join(mediaDir, `codex-usage-promo-poster${locale === "en" ? "-en" : ""}.jpg`)]); + } + if (previewOnly) return null; + + const output = path.join(mediaDir, outputName(locale, format)); + const ffmpeg = spawn("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", + "-f", "image2pipe", "-framerate", String(timeline.fps), "-c:v", "png", "-i", "pipe:0", + "-i", audio, + "-map", "0:v", "-map", "1:a", + "-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p", + "-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-g", String(timeline.fps * 2), + "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv", + "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", output + ], { cwd: repoRoot, stdio: ["pipe", "inherit", "inherit"], windowsHide: true }); + const exited = once(ffmpeg, "exit"); + const cdp = await context.newCDPSession(page); + const started = Date.now(); + for (let frame = 0; frame < frameCount; frame++) { + await page.evaluate(t => window.promo.seek(t), frame / timeline.fps); + const { data } = await cdp.send("Page.captureScreenshot", { format: "png", optimizeForSpeed: true }); + if (!ffmpeg.stdin.write(Buffer.from(data, "base64"))) await once(ffmpeg.stdin, "drain"); + if (frame % 150 === 149) console.log(`${locale} ${format}: ${frame + 1}/${frameCount} frames (${((Date.now() - started) / 1000).toFixed(0)}s)`); + } + ffmpeg.stdin.end(); + const [code] = await exited; + if (code !== 0) throw new Error(`ffmpeg exited with ${code} for ${output}`); + if (errors.length) throw new Error(JSON.stringify({ locale, format, errors })); + return verify(output, format); + } finally { + await context.close(); + } +} + +async function verify(file, format) { + const probe = JSON.parse(run("ffprobe", ["-v", "error", "-count_frames", "-show_entries", "stream=codec_type,codec_name,width,height,avg_frame_rate,nb_read_frames:format=duration", "-of", "json", file], true)); + const video = probe.streams.find(s => s.codec_type === "video"); + const audio = probe.streams.find(s => s.codec_type === "audio"); + const { width, height } = sizes[format]; + const { size } = await stat(file); + const checks = { + resolution: video.width === width && video.height === height, + fps: video.avg_frame_rate === `${timeline.fps}/1`, + frames: Number(video.nb_read_frames) === frameCount, + duration: Math.abs(Number(probe.format.duration) - timeline.duration) < .1, + audio: audio?.codec_name === "aac", + size: size > 200_000 && size < 16_000_000 + }; + const failed = Object.entries(checks).filter(([, ok]) => !ok).map(([name]) => name); + if (failed.length) throw new Error(`${file} failed checks: ${failed.join(", ")} ${JSON.stringify(probe)}`); + return { file: path.relative(repoRoot, file), bytes: size, duration: Number(probe.format.duration), frames: Number(video.nb_read_frames) }; +} + +await mkdir(mediaDir, { recursive: true }); +await mkdir(reviewDir, { recursive: true }); +try { + const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + try { + const manifests = {}; + const server = await startDemoServer(); + try { + // Both languages are captured so the interface scene can show the language switch. + for (const locale of ["zh-CN", "en"]) for (const format of formats) { + const dir = path.join(assetsDir, `${locale}-${format}`); + const manifest = await captureAssets(browser, server.baseURL, locale, dir, stillViewports[format]); + manifests[`${locale}-${format}`] = { ...manifest, base: pathToFileURL(dir).href + "/" }; + console.log(`${locale} ${format}: captured ${Object.keys(manifest.shots).length} Dashboard stills`); + } + } finally { + await server.stop(); + } + const audio = path.join(tempDir, "music.wav"); + if (!previewOnly) await renderMusic(timeline, audio, tempDir); + const results = []; + for (const locale of locales) for (const format of formats) { + const result = await renderVideo(browser, locale, format, manifests, audio); + if (result) { results.push(result); console.log(`Rendered ${result.file} (${(result.bytes / 1e6).toFixed(1)} MB)`); } + } + await writeFile(path.join(reviewDir, "render.json"), JSON.stringify({ timeline, results }, null, 2) + "\n"); + } finally { + await browser.close(); + } + console.log(`Review keyframes in ${reviewDir}`); +} finally { + const tempRoot = path.resolve(tmpdir()) + path.sep; + if (!path.resolve(tempDir).startsWith(tempRoot) || !path.basename(tempDir).startsWith("codex-usage-promo-")) throw new Error("Unexpected promo cleanup path"); + await rm(tempDir, { recursive: true, force: true }); +} diff --git a/scripts/media-lib.mjs b/scripts/media-lib.mjs new file mode 100644 index 0000000..6238f3f --- /dev/null +++ b/scripts/media-lib.mjs @@ -0,0 +1,80 @@ +import { expect } from "@playwright/test"; +import { once } from "node:events"; +import { createServer } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; + +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const fixedTime = new Date("2026-09-23T16:30:00+08:00"); + +export function freePort() { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const { port } = probe.address(); + probe.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +export function run(command, args, capture = false) { + const result = spawnSync(command, args, { cwd: repoRoot, stdio: capture ? "pipe" : "inherit", encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr || result.status}`); + return result.stdout; +} + +export function assertSynthetic(text) { + for (const pattern of [/[A-Z]:\\Users\\/i, /\/home\/[a-z0-9._-]+\//i, /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i]) { + if (pattern.test(text)) throw new Error(`Sensitive-looking value matched ${pattern}`); + } +} + +// Builds the static demo and serves it on a free loopback port until stop() is called. +export async function startDemoServer() { + const port = await freePort(); + const baseURL = `http://127.0.0.1:${port}/codex-usage/`; + run(process.execPath, ["scripts/build-demo.mjs", "dist/pages"]); + const server = spawn(process.execPath, ["scripts/serve-static.mjs", "dist/pages", String(port), "codex-usage"], { cwd: repoRoot, stdio: "ignore", windowsHide: true }); + const deadline = Date.now() + 10_000; + for (;;) { + try { if ((await fetch(baseURL)).ok) break; } catch {} + if (Date.now() > deadline) throw new Error("media demo server did not become ready"); + await delay(100); + } + async function stop() { + if (server.exitCode !== null || server.signalCode !== null) return; + const exited = once(server, "exit"); + server.kill(); + await Promise.race([exited, delay(5_000)]); + } + return { baseURL, stop }; +} + +export async function openDemo(context, baseURL, locale) { + const page = await context.newPage(); + await page.clock.setFixedTime(fixedTime); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("request", request => { + if (new URL(request.url()).origin !== new URL(baseURL).origin) errors.push(`External request: ${request.url()}`); + }); + await page.goto(`${baseURL}?lang=${locale}`, { waitUntil: "networkidle" }); + await expect(page.locator("#overviewTotal")).not.toHaveText("—"); + await expect(page.locator("#overviewCoverage")).toContainText("100"); + await expect(page.locator("#coverageBanner")).toBeHidden(); + await page.evaluate(() => { + window.mediaWarnings = []; + // Observe transient errors between chapters as well as settled screens. + setInterval(() => { + for (const selector of ["#coverageBanner", "#toast.error", "#customRangeError", "#updateError"]) { + const node = document.querySelector(selector); + if (node?.checkVisibility() && node.textContent.trim()) window.mediaWarnings.push(node.textContent.trim()); + } + }, 40); + }); + return { page, errors }; +} diff --git a/scripts/promo/assets.mjs b/scripts/promo/assets.mjs new file mode 100644 index 0000000..90671c1 --- /dev/null +++ b/scripts/promo/assets.mjs @@ -0,0 +1,135 @@ +import { expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { assertSynthetic, openDemo } from "../media-lib.mjs"; + +const scale = 2; +// The promo labels its synthetic data itself, so the demo notice is left out of the stills. +const stillStyle = "#demoBanner{display:none!important}*,*::before,*::after{caret-color:transparent!important;scroll-behavior:auto!important}"; + +// Captures crisp Dashboard stills plus the on-screen rectangles the promo camera zooms into. +export async function captureAssets(browser, baseURL, locale, outDir, viewport) { + await mkdir(outDir, { recursive: true }); + const context = await browser.newContext({ viewport, deviceScaleFactor: scale, colorScheme: "light", timezoneId: "Asia/Shanghai", reducedMotion: "reduce" }); + const { page, errors } = await openDemo(context, baseURL, locale); + await page.addStyleTag({ content: stillStyle }); + const shots = {}; + + async function rect(selector) { + const box = await page.locator(selector).first().evaluate(node => { + const r = node.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + return Object.fromEntries(Object.entries(box).map(([key, value]) => [key, Math.round(value)])); + } + async function scrollTo(selector, top = 80) { + await page.locator(selector).first().evaluate((node, top) => window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top - top), top); + await delay(250); + } + async function shot(name, focus) { + await page.mouse.move(viewport.width - 4, viewport.height - 4); + await delay(350); + assertSynthetic(await page.locator("body").innerText()); + await expect(page.locator("#coverageBanner")).toBeHidden(); + const file = `${name}.png`; + await page.screenshot({ path: path.join(outDir, file) }); + const focusRects = {}; + for (const [key, selector] of Object.entries(focus)) focusRects[key] = await rect(selector); + shots[name] = { file, width: viewport.width, height: viewport.height, focus: focusRects }; + } + + await shot("overview", { hero: ".hero-metrics", total: "#overviewTotal", cost: "#overviewCost", totalCard: ".token-metric", costCard: ".cost-metric" }); + const values = { + total: (await page.locator("#overviewTotal").innerText()).trim(), + cost: (await page.locator("#overviewCost").innerText()).trim(), + coverage: (await page.locator("#overviewCoverage").innerText()).trim() + }; + + await page.locator('[data-overview-range="custom"]').click(); + await page.locator("#rangeStart").fill("2026-09-22T09:00"); + await page.locator("#rangeEnd").fill("2026-09-23T16:30"); + await page.locator("#queryRange").click(); + await expect(page.locator("#overviewExactTotal")).toBeVisible(); + await shot("range", { form: "#customRangeForm", hero: ".hero-metrics", exact: "#overviewExactTotal" }); + + await page.locator('[data-overview-range="7d"]').click(); + await scrollTo("#usageTrendPanel", 90); + await page.locator("#trendHourlyTab").click(); + await page.locator("#hourlyPoints .hour-point:not(.zero)").nth(8).click(); + await delay(300); + await shot("hourly", { panel: "#usageTrendPanel", chart: "#hourlyChart" }); + + await page.locator("#trendDailyTab").click(); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.locator("#tab-daily").click(); + await page.locator('[data-calendar-date="2026-09-22"]').click(); + await expect(page.locator("#dayTotal")).not.toHaveText("0"); + await shot("calendar", { calendar: "#calendarGrid", day: "#dayTotal" }); + + await page.locator("#tab-details").click(); + await page.locator('[data-dimension="project"]').click(); + await delay(300); + await shot("projects", { panel: "#breakdownPanel" }); + + await scrollTo("#sessionsPanel", 70); + await page.locator('[data-session-view="tree"]').click(); + await expect(page.locator("[data-tree-toggle]").first()).toBeVisible(); + await delay(300); + await shot("tree", { panel: "#sessionsPanel", rows: "#sessionRows" }); + + await page.locator('[data-session-view="list"]').click(); + await page.locator("#sessionSearch").fill("local"); + await delay(700); + await shot("search", { panel: "#sessionsPanel", search: "#sessionSearch" }); + await page.locator("#sessionSearchClear").click(); + + await page.locator("#tab-overview").click(); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.locator("#filterButton").click(); + await page.locator("#filterMode").selectOption("fast"); + await page.locator("#applyFilters").click(); + await delay(600); + await shot("fast", { chips: "#filterContext", hero: ".hero-metrics" }); + + await page.locator("#exportButton").click(); + await expect(page.locator("#exportJson")).toHaveAttribute("href", /^data:application\/json/); + await shot("export", { dialog: "#exportDialog" }); + await page.locator("#exportDialog [data-close]").click(); + await page.locator("#clearFilters").click(); + await delay(400); + + await page.locator("#pricingButton").click(); + await page.locator(".catalog-disclosure summary").click(); + await delay(300); + await shot("pricing", { dialog: "#pricingDialog", catalog: "#pricingCatalog" }); + await page.locator("#pricingDialog .dialog-close").click(); + + await page.evaluate(() => window.scrollTo(0, 0)); + await delay(200); + await shot("light", { hero: ".hero-metrics" }); + await page.locator("#themeButton").click(); + await delay(500); + await shot("dark", { hero: ".hero-metrics" }); + await page.locator("#themeButton").click(); + await delay(300); + + const warnings = await page.evaluate(() => [...new Set(window.mediaWarnings)]); + await context.close(); + + const mobileContext = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, colorScheme: "light", timezoneId: "Asia/Shanghai", reducedMotion: "reduce" }); + const mobile = await openDemo(mobileContext, baseURL, locale); + await mobile.page.addStyleTag({ content: stillStyle }); + await delay(400); + assertSynthetic(await mobile.page.locator("body").innerText()); + await mobile.page.screenshot({ path: path.join(outDir, "mobile.png") }); + shots.mobile = { file: "mobile.png", width: 390, height: 844, focus: {} }; + errors.push(...mobile.errors); + warnings.push(...await mobile.page.evaluate(() => [...new Set(window.mediaWarnings)])); + await mobileContext.close(); + + if (warnings.length || errors.length) throw new Error(JSON.stringify({ locale, warnings, errors })); + const manifest = { locale, viewport, scale, values, shots }; + await writeFile(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); + return manifest; +} diff --git a/scripts/promo/music.mjs b/scripts/promo/music.mjs new file mode 100644 index 0000000..6dfe5b2 --- /dev/null +++ b/scripts/promo/music.mjs @@ -0,0 +1,187 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { repoRoot, run } from "../media-lib.mjs"; + +// A small deterministic synthesizer for the promo soundtrack. Everything is derived +// from the shared timeline so musical accents land on the visual cuts. +const rate = 48_000; +const midi = n => 440 * 2 ** ((n - 69) / 12); + +function noise(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 2 ** 31 - 1; + }; +} + +function writeWav(file, left, right) { + const frames = left.length; + const buffer = Buffer.alloc(44 + frames * 8); + buffer.write("RIFF", 0); buffer.writeUInt32LE(36 + frames * 8, 4); buffer.write("WAVE", 8); + buffer.write("fmt ", 12); buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(3, 20); buffer.writeUInt16LE(2, 22); + buffer.writeUInt32LE(rate, 24); buffer.writeUInt32LE(rate * 8, 28); buffer.writeUInt16LE(8, 32); buffer.writeUInt16LE(32, 34); + buffer.write("data", 36); buffer.writeUInt32LE(frames * 8, 40); + for (let i = 0; i < frames; i++) { buffer.writeFloatLE(left[i], 44 + i * 8); buffer.writeFloatLE(right[i], 48 + i * 8); } + return writeFile(file, buffer); +} + +export async function renderMusic(timeline, output, workDir) { + const length = Math.round(timeline.duration * rate); + const beat = 60 / timeline.bpm; + const scene = Object.fromEntries(timeline.scenes.map(s => [s.id, s])); + const grooveStart = scene.overview.start, grooveEnd = scene.privacy.start, end = scene.cta.start; + const cuts = timeline.scenes.slice(2, -1).map(s => s.start); + + const dry = [new Float32Array(length), new Float32Array(length)]; + const send = [new Float32Array(length), new Float32Array(length)]; + const duck = new Float32Array(length).fill(1); + const add = (bus, t, fn, dur, panning = 0, sendAmount = 0) => { + const start = Math.max(0, Math.round(t * rate)), stop = Math.min(length, Math.round((t + dur) * rate)); + const gl = Math.cos((panning + 1) * Math.PI / 4), gr = Math.sin((panning + 1) * Math.PI / 4); + for (let i = start; i < stop; i++) { + const v = fn((i - start) / rate, i / rate); + bus[0][i] += v * gl; bus[1][i] += v * gr; + if (sendAmount) { send[0][i] += v * gl * sendAmount; send[1][i] += v * gr * sendAmount; } + } + }; + + // Harmony: a dark hook, a bright brand chord, then a four-chord loop into a resolving finale. + const chords = { Am9: [45, 52, 57, 59, 64], C9: [48, 55, 62, 64, 71], F: [41, 53, 57, 60, 64], G: [43, 50, 59, 62, 69], Am: [45, 52, 60, 64, 67], C: [48, 55, 64, 67, 71], Gsus: [43, 50, 60, 62, 67] }; + const segments = [[0, scene.brand.start, "Am9"], [scene.brand.start, grooveStart, "C9"]]; + const loop = ["F", "G", "Am", "C"]; + let t = grooveStart; + for (let i = 0; t < scene.privacy.start + 1; i++, t += beat * 4) segments.push([t, Math.min(t + beat * 4, scene.privacy.start + 1), loop[i % 4]]); + segments.push([scene.privacy.start + 1, end, "Gsus"], [end, timeline.duration, "C9"]); + + // Pad: detuned additive saws with a slowly opening tone. + for (const [a, b, name] of segments) { + const dur = b - a + .6; + chords[name].forEach((note, v) => { + for (const detune of [-.0035, .0035]) { + const f = midi(note) * (1 + detune); + add(dry, a, (lt, at) => { + const bright = Math.min(1, at / 20); + const envelope = Math.min(1, lt / .35) * Math.min(1, Math.max(0, (dur - lt) / .6)); + let sum = 0; + for (let h = 1; h <= 7; h++) sum += Math.sin(2 * Math.PI * f * h * lt + v) / h ** (2.1 - bright * .6); + return sum * envelope * .022 * (at < scene.brand.start ? .8 : 1); + }, dur, detune < 0 ? -.55 : .55, .45); + } + }); + } + + // Bass: eighth-note pulses on the chord root during the groove. + for (const [a, b, name] of segments) { + if (a < grooveStart || a >= grooveEnd) continue; + const f = midi(chords[name][0] - 12); + for (let s = a; s < Math.min(b, grooveEnd) - 1e-6; s += beat / 2) { + add(dry, s, lt => (Math.sin(2 * Math.PI * f * lt) + .3 * Math.sin(4 * Math.PI * f * lt)) * Math.exp(-lt / .22) * Math.min(1, lt / .006) * .16, beat / 2 + .05); + } + } + + // Kick drum, with sidechain ducking of the other parts. + const kick = (at, gain = 1) => { + add(dry, at, lt => { + const phase = 2 * Math.PI * (48 * lt + (150 - 48) * .035 * (1 - Math.exp(-lt / .035))); + return Math.sin(phase) * Math.exp(-lt / .2) * .5 * gain; + }, .5); + const start = Math.round(at * rate); + for (let i = start; i < Math.min(length, start + rate * .4); i++) duck[i] = Math.min(duck[i], 1 - .55 * Math.exp(-(i - start) / rate / .11)); + }; + kick(scene.brand.start, 1.25); + for (let s = grooveStart; s < grooveEnd - 1e-6; s += beat) kick(s); + kick(end, 1.25); + + // Hi-hats on the off-beats once the product tour is moving. + const hatNoise = noise(7); + for (let s = scene.time.start + beat / 2; s < grooveEnd; s += beat) { + let prev = 0; + add(dry, s, lt => { const n = hatNoise(); const hp = n - prev; prev = n; return hp * Math.exp(-lt / .035) * .05; }, .12, .35); + } + + // Pluck arpeggio with a ping-pong echo. + const pattern = [2, 3, 4, 3, 1, 3, 4, 2]; + let step = 0; + for (let s = grooveStart; s < grooveEnd - 1e-6; s += beat / 2, step++) { + const [, , name] = segments.findLast(([a]) => a <= s + 1e-6); + const f = midi(chords[name][pattern[step % pattern.length]] + 12); + add(dry, s, lt => (Math.sin(2 * Math.PI * f * lt) + .35 * Math.sin(6 * Math.PI * f * lt) * Math.exp(-lt / .05)) * Math.exp(-lt / .18) * Math.min(1, lt / .004) * .055, .6, step % 2 ? .3 : -.3, .9); + } + + // Bells: the brand reveal (one per rising bar) and the final resolution. + const bell = (at, note, gain) => add(dry, at, lt => { + const f = midi(note); + return (Math.sin(2 * Math.PI * f * lt) * Math.exp(-lt / 1.4) + .45 * Math.sin(2 * Math.PI * f * 2.76 * lt) * Math.exp(-lt / .45) + .2 * Math.sin(2 * Math.PI * f * 5.4 * lt) * Math.exp(-lt / .18)) * Math.min(1, lt / .003) * gain; + }, 3.2, 0, 1.2); + [72, 76, 79, 83].forEach((note, i) => bell(scene.brand.start + .15 + i * .09, note, .06)); + [60, 67, 72, 76, 79].forEach((note, i) => bell(end + i * .06, note, .055)); + + // Counter ticks during the hook, accelerating with the numbers. + for (let s = .35, i = 0; s < scene.brand.start - .1; i++) { + const p = s / scene.brand.start; + add(dry, s, lt => Math.sin(2 * Math.PI * (1800 + 900 * p) * lt) * Math.exp(-lt / .012) * (.02 + .05 * p), .06, i % 2 ? .25 : -.25); + s += .16 - .11 * p; + } + + // Whooshes: swept band-pass noise into each cut, and a longer riser into the brand reveal. + const whoosh = (center, pre, post, gain, seed) => { + const n = noise(seed); + let low = 0, band = 0; + add(dry, center - pre, lt => { + const p = lt / (pre + post); + const fc = 300 + 4200 * Math.sin(Math.PI * Math.min(1, lt / pre) / 2) ** 2; + const k = 2 * Math.sin(Math.PI * fc / rate); + const high = n() - low - .7 * band; + band += k * high; low += k * band; + const envelope = lt < pre ? (lt / pre) ** 2 : Math.exp(-(lt - pre) / (post / 3)); + return band * envelope * gain * (1 - p * .2); + }, pre + post, 0, .6); + }; + whoosh(scene.brand.start, 2.0, .35, .12, 11); + cuts.forEach((cut, i) => whoosh(cut, .45, .3, .07, 20 + i)); + whoosh(end, 1.4, .4, .09, 99); + + // Send bus: ping-pong delay followed by a light comb reverb. + const wet = [new Float32Array(length), new Float32Array(length)]; + const dl = Math.round(beat * .75 * rate), dr = Math.round(beat * rate); + for (let i = 0; i < length; i++) { + const fl = i >= dl ? wet[1][i - dl] * .38 : 0, fr = i >= dr ? wet[0][i - dr] * .38 : 0; + wet[0][i] = send[0][i] + fr * .9 + fl * .1; + wet[1][i] = send[1][i] + fl * .9 + fr * .1; + } + for (const ch of [0, 1]) { + const combs = (ch ? [1617, 1557, 1491, 1422] : [1557, 1617, 1422, 1491]).map(n => ({ buf: new Float32Array(Math.round(n * rate / 44100)), i: 0, low: 0 })); + for (let i = 0; i < length; i++) { + let r = 0; + for (const c of combs) { + const out = c.buf[c.i]; + c.low = out * .7 + c.low * .3; + c.buf[c.i] = wet[ch][i] * .2 + c.low * .78; + c.i = (c.i + 1) % c.buf.length; + r += out; + } + wet[ch][i] = wet[ch][i] * .55 + r * .25; + } + } + + // Mix, duck, soft-clip, and fade. + const left = new Float32Array(length), right = new Float32Array(length); + for (let i = 0; i < length; i++) { + const s = i / rate; + const fade = Math.min(1, s / .05) * Math.min(1, (timeline.duration - s) / 1.6); + left[i] = Math.tanh((dry[0][i] * duck[i] + wet[0][i] * (.5 + .5 * duck[i])) * 1.3) * fade; + right[i] = Math.tanh((dry[1][i] * duck[i] + wet[1][i] * (.5 + .5 * duck[i])) * 1.3) * fade; + } + const raw = path.join(workDir, "music-raw.wav"); + await writeWav(raw, left, right); + + // Two-pass EBU R128 loudness normalization for consistent playback volume. + const target = "I=-16:TP=-1.5:LRA=11"; + // loudnorm reports its measurement on stderr. + const analysis = spawnSync("ffmpeg", ["-hide_banner", "-nostats", "-i", raw, "-af", `loudnorm=${target}:print_format=json`, "-f", "null", "-"], { cwd: repoRoot, encoding: "utf8", windowsHide: true }); + const measured = JSON.parse(analysis.stderr.match(/\{[^{}]*"input_i"[^{}]*\}/)[0]); + const filter = `loudnorm=${target}:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}:offset=${measured.target_offset}:linear=true`; + run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", raw, "-af", `${filter},aresample=${rate}`, "-c:a", "pcm_s16le", output]); +} diff --git a/scripts/promo/promo.css b/scripts/promo/promo.css new file mode 100644 index 0000000..959d87c --- /dev/null +++ b/scripts/promo/promo.css @@ -0,0 +1,143 @@ +:root { + --ink: #182126; --night: #0e1618; --fog: #f3f5f2; --paper: #fbfcfa; --line: #ccd5d1; + --teal: #247f72; --teal-bright: #3fb3a2; --teal-soft: #dcefeb; --copper: #a96023; --copper-bright: #e39a52; + --muted: #5f6d6b; --mist: #a9bbb7; + --display: "Segoe UI Variable Display", "SF Pro Display", "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif; + --text: "Segoe UI Variable Text", "SF Pro Text", "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif; + --mono: "Cascadia Mono", "SFMono-Regular", Consolas, "Microsoft YaHei UI", monospace; +} +*, *::before, *::after { box-sizing: border-box; transition: none !important; animation: none !important; } +html, body { margin: 0; overflow: hidden; background: var(--night); } +body { font-family: var(--text); -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; } +h1, p, ul { margin: 0; padding: 0; } +ul { list-style: none; } + +#stage { position: relative; overflow: hidden; background: var(--night); color: var(--ink); } +.layer, .bg { position: absolute; inset: 0; } +.bg-dark { background: radial-gradient(120% 90% at 50% 30%, #16262a 0%, var(--night) 62%); } +.bg-light { background: var(--fog); } +.bg .grid { position: absolute; inset: -40px; background-image: radial-gradient(circle, #9fb0ab 1.1px, transparent 1.6px); background-size: 34px 34px; opacity: .32; } +.glow { position: absolute; width: 1400px; height: 1400px; border-radius: 50%; left: 0; top: 0; } +.bg-light .glow-a { background: radial-gradient(circle, rgba(63,179,162,.20), rgba(63,179,162,0) 62%); } +.bg-light .glow-b { background: radial-gradient(circle, rgba(227,154,82,.16), rgba(227,154,82,0) 62%); } +.bg-dark .glow-a { background: radial-gradient(circle, rgba(63,179,162,.22), rgba(63,179,162,0) 62%); } +.bg-dark .glow-b { background: radial-gradient(circle, rgba(227,154,82,.14), rgba(227,154,82,0) 62%); } + +.kicker { font: 700 22px/1 var(--mono); letter-spacing: .16em; color: var(--teal); text-transform: uppercase; } +.line { display: block; } + +/* Hook */ +#hook { color: #eef5f3; } +.hook-bars { position: absolute; left: 0; right: 0; bottom: 0; height: 46%; display: flex; align-items: flex-end; gap: 8px; padding: 0 8px; } +.hook-bars i { flex: 1; border-radius: 6px 6px 0 0; background: linear-gradient(#3fb3a2, rgba(63,179,162,0)); opacity: .22; transform-origin: bottom; } +.hook-copy { position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-58%); text-align: center; } +.hook-copy .kicker { color: var(--teal-bright); } +.hook-title { margin-top: 30px; font: 760 88px/1.08 var(--display); letter-spacing: -.035em; } +.hook-counter { margin-top: 44px; display: inline-flex; align-items: baseline; gap: 18px; padding: 18px 30px; border: 1px solid rgba(169,187,183,.28); border-radius: 18px; background: rgba(255,255,255,.04); } +.hook-counter b { font: 700 64px/1 var(--mono); color: var(--copper-bright); font-variant-numeric: tabular-nums; min-width: 9.4ch; text-align: right; } +.hook-counter span { font: 600 26px/1 var(--mono); color: var(--mist); letter-spacing: .08em; } + +/* Brand */ +#brand { display: grid; place-content: center; justify-items: center; gap: 46px; color: #f3f7f5; } +.brand-lockup { display: flex; align-items: center; gap: 44px; } +.brand-icon { width: 220px; height: 220px; } +.icon-slot svg { width: 100%; height: 100%; display: block; overflow: visible; } +.brand-words h1 { font: 780 128px/1 var(--display); letter-spacing: -.045em; } +.byline { margin-top: 12px; text-align: right; font: 600 30px/1 var(--text); color: var(--mist); letter-spacing: .02em; } +.brand-tagline { font: 650 40px/1.2 var(--display); color: #cfe3de; letter-spacing: -.01em; } + +#corner { inset: auto; display: flex; align-items: center; gap: 14px; font: 750 26px/1 var(--display); letter-spacing: -.02em; } +#corner .icon-slot { width: 44px; height: 44px; } + +/* Copy column */ +.copy-block { position: absolute; } +.copy-block h2 { margin: 26px 0 0; font: 780 76px/1.06 var(--display); letter-spacing: -.04em; } +.copy-block .body { margin-top: 28px; font: 500 28px/1.5 var(--text); color: var(--muted); } +.steps { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 40px; } +.steps span { --a: 0; padding: 12px 18px; border-radius: 12px; border: 1.5px solid color-mix(in srgb, var(--teal) calc(35% + var(--a) * 65%), var(--line)); background: color-mix(in srgb, var(--teal) calc(var(--a) * 100%), var(--paper)); color: color-mix(in srgb, #fff calc(var(--a) * 100%), var(--muted)); font: 700 22px/1 var(--text); white-space: nowrap; } + +/* Browser panel */ +#panel { position: absolute; left: 0; top: 0; border-radius: 22px; background: var(--paper); border: 1px solid #c3cdc9; box-shadow: 0 50px 120px -30px rgba(18,33,34,.38), 0 18px 40px -18px rgba(18,33,34,.25); overflow: hidden; transform-origin: 50% 60%; } +.chrome { height: 46px; display: flex; align-items: center; gap: 9px; padding: 0 18px; background: #e9eeeb; border-bottom: 1px solid #d3dbd7; } +.chrome i { width: 13px; height: 13px; border-radius: 50%; background: #c9d2ce; } +.chrome i:first-child { background: #e59a8b; } .chrome i:nth-child(2) { background: #e8c27d; } .chrome i:nth-child(3) { background: #8fc6a8; } +.chrome .url { margin-left: 18px; padding: 7px 18px; border-radius: 9px; background: var(--paper); color: var(--muted); font: 600 16px/1 var(--mono); } +.viewport { position: relative; overflow: hidden; background: #f3f5f2; } +#content { position: absolute; left: 0; top: 0; transform-origin: 0 0; } +#content img, #content .ring { position: absolute; left: 0; top: 0; } +.ring { border-style: solid; border-color: var(--copper-bright); border-radius: 16px; box-shadow: 0 0 0 6px rgba(227,154,82,.18), 0 0 40px rgba(227,154,82,.35); } + +/* Feature cards */ +.card { position: absolute; left: 0; top: 0; transform-origin: 0 0; overflow: hidden; border-radius: 18px; background: var(--paper); box-shadow: 0 40px 90px -24px rgba(18,33,34,.42), 0 0 0 1px rgba(24,33,38,.12); } +.card img { position: absolute; } +.card .shade { position: absolute; inset: 0; background: #20302f; } + +/* Phone */ +#phone { position: absolute; left: 0; top: 0; width: 430px; height: 884px; padding: 20px; border-radius: 64px; background: #172124; box-shadow: 0 60px 120px -30px rgba(18,33,34,.55), inset 0 0 0 2px #36464a; transform-origin: 0 0; } +.phone-screen { width: 390px; height: 844px; border-radius: 46px; overflow: hidden; background: var(--fog); } +.phone-screen img { width: 390px; height: 844px; display: block; } + +/* Privacy */ +#privacy { color: #eef5f3; display: grid; place-items: center; } +.rings { position: absolute; left: 50%; top: 50%; } +.rings i { position: absolute; left: 0; top: 0; border-radius: 50%; border: 2px solid var(--teal-bright); } +.privacy-copy { position: relative; display: grid; justify-items: center; text-align: center; } +.lock { width: 132px; height: 132px; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle, rgba(63,179,162,.35), rgba(63,179,162,.08)); border: 2px solid rgba(63,179,162,.6); margin-bottom: 34px; } +.lock svg { width: 64px; height: 64px; } +.lock rect, .lock circle { fill: var(--teal-bright); } +.lock path { stroke: var(--teal-bright); stroke-width: 4.5; stroke-linecap: round; } +.lock circle { fill: var(--night); } +#privacy .kicker { color: var(--teal-bright); } +#privacy h1 { margin-top: 26px; font: 780 92px/1.08 var(--display); letter-spacing: -.04em; } +.privacy-chips { display: grid; grid-template-columns: repeat(2, auto); gap: 16px 18px; margin-top: 54px; justify-content: center; } +.privacy-chips li { display: flex; align-items: center; gap: 14px; padding: 18px 26px; border-radius: 16px; background: rgba(255,255,255,.06); border: 1px solid rgba(169,187,183,.26); font: 650 28px/1 var(--text); color: #dbe8e5; white-space: nowrap; } +.privacy-chips li::before { content: "✓"; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--teal); color: #fff; font: 800 20px/1 var(--text); } + +/* Call to action */ +#cta { display: grid; place-content: center; justify-items: center; color: #f3f7f5; text-align: center; } +.cta-lockup { display: flex; align-items: center; gap: 36px; text-align: left; } +.cta-icon { width: 150px; height: 150px; } +#cta h1 { font: 780 104px/1 var(--display); letter-spacing: -.045em; } +.cta-tagline { margin-top: 14px; font: 600 32px/1.2 var(--display); color: #cfe3de; } +.terminal { margin-top: 64px; min-width: 620px; padding: 26px 34px; border-radius: 18px; background: #070c0d; border: 1px solid rgba(169,187,183,.26); text-align: left; font: 600 34px/1 var(--mono); color: #e9f2f0; } +.terminal .prompt { color: var(--teal-bright); } +.caret { display: inline-block; width: 16px; height: 34px; margin-left: 4px; vertical-align: -5px; background: var(--copper-bright); } +.cta-links { margin-top: 40px; display: flex; gap: 28px; } +.cta-links li { display: flex; align-items: center; gap: 14px; padding: 16px 24px; border-radius: 14px; background: rgba(255,255,255,.06); border: 1px solid rgba(169,187,183,.24); font: 600 26px/1 var(--mono); color: #dbe8e5; } +.cta-links b { font: 750 22px/1 var(--text); color: var(--copper-bright); letter-spacing: .02em; } +.cta-meta { margin-top: 34px; font: 600 24px/1 var(--mono); color: var(--mist); letter-spacing: .08em; } + +#badge { inset: auto; font: 600 18px/1 var(--mono); letter-spacing: .06em; color: var(--muted); } + +#play { position: absolute; right: 72px; bottom: 64px; visibility: hidden; display: flex; align-items: center; gap: 16px; padding: 18px 30px 18px 20px; border-radius: 999px; background: rgba(14,22,24,.88); color: #f3f7f5; font: 700 28px/1 var(--text); box-shadow: 0 20px 50px -12px rgba(14,22,24,.5); } +#play svg { width: 52px; height: 52px; padding: 12px 10px 12px 14px; border-radius: 50%; background: var(--copper-bright); fill: #fff; } + +/* Portrait layout */ +.vertical .hook-title { font-size: 92px; } +.vertical .hook-copy { transform: translateY(-62%); } +.vertical .hook-counter b { font-size: 56px; } +.vertical #brand { gap: 60px; } +.vertical .brand-lockup { flex-direction: column; gap: 48px; } +.vertical .brand-icon { width: 260px; height: 260px; } +.vertical .brand-words { text-align: center; } +.vertical .brand-words h1 { font-size: 132px; } +.vertical .byline { text-align: center; } +.vertical .brand-tagline { max-width: 900px; text-align: center; font-size: 46px; } +.vertical .copy-block h2 { font-size: 92px; } +.vertical .copy-block .body { font-size: 32px; } +.vertical .kicker { font-size: 26px; } +.vertical .steps span { font-size: 28px; padding: 16px 22px; } +.vertical #privacy h1 { font-size: 100px; } +.vertical .privacy-chips { grid-template-columns: auto; } +.vertical .privacy-chips li { font-size: 34px; } +.vertical .cta-lockup { flex-direction: column; text-align: center; gap: 40px; } +.vertical .cta-icon { width: 200px; height: 200px; } +.vertical #cta h1 { font-size: 116px; } +.vertical .cta-tagline { font-size: 40px; max-width: 900px; } +.vertical .terminal { min-width: 0; width: 900px; font-size: 38px; } +.vertical .cta-links { flex-direction: column; align-items: center; gap: 18px; } +.vertical .cta-links li { font-size: 30px; } +.vertical .cta-meta { font-size: 26px; } +.vertical #corner { font-size: 32px; } +.vertical #corner .icon-slot { width: 56px; height: 56px; } +.vertical #badge { font-size: 22px; } diff --git a/scripts/promo/promo.html b/scripts/promo/promo.html new file mode 100644 index 0000000..491c945 --- /dev/null +++ b/scripts/promo/promo.html @@ -0,0 +1,76 @@ + + + + + + + +
+
+
+ +
+
+
+

+

+

0

+
+
+ +
+
+
+
+

Codex Usage

+ +
+
+

+
+ +
Codex Usage
+ +
+ +
+
127.0.0.1:43189
+
+
+ +
+ +
+ +
+
+
+
+

+

+
    +
    +
    + +
    +
    +
    +
    +

    Codex Usage

    +

    +
    +
    +
    $
    + +

    +
    + +

    +
    +
    + + + diff --git a/scripts/promo/promo.js b/scripts/promo/promo.js new file mode 100644 index 0000000..ba67ebd --- /dev/null +++ b/scripts/promo/promo.js @@ -0,0 +1,467 @@ +// Deterministic promo composition: every frame is a pure function of time, so the +// renderer can seek frame by frame and capture identical output on every run. +(() => { + const P = window.PROMO; + const zh = P.locale.startsWith("zh"); + const vertical = P.format === "vertical"; + const W = vertical ? 1080 : 1920; + const H = vertical ? 1920 : 1080; + const scene = Object.fromEntries(P.timeline.scenes.map(s => [s.id, s])); + const shots = P.assets.shots; + const dims = { w: shots.overview.width, h: shots.overview.height }; + + const copy = zh ? { + hook: { kicker: "每天都在用 Codex", title: ["Token 一路上涨,", "到底花在了哪?"], unit: "TOKENS" }, + brand: { tagline: "让 Codex 用量一目了然" }, + overview: { kicker: "01 · 总览", title: ["总量与费用,", "一眼看清"], body: "Token 构成、API 等价成本与定价覆盖率,集中在同一个 Dashboard。" }, + time: { kicker: "02 · 时间", title: ["精确到分钟,", "下钻到小时"], body: "任意起止时间查询,再顺着小时趋势与月历找到高峰。", steps: ["分钟级区间", "小时趋势", "每日月历"] }, + attribution: { kicker: "03 · 归属", title: ["从项目到", "每一个子任务"], body: "按项目、模型、来源拆分,任务树看清 Subagent 的消耗。", steps: ["按项目", "任务树", "搜索 Session"] }, + tools: { kicker: "04 · 工具", title: ["筛选、导出", "定价全都有"], body: "组合筛选即时生效,导出与费用始终跟随当前范围。", steps: ["只看 Fast", "JSON / CSV 导出", "模型定价"] }, + interface: { kicker: "05 · 界面", title: ["中英双语,", "明暗随心"], body: "字体、密度与动效可调,手机上同样顺手。", steps: ["中英双语", "明暗主题", "手机适配"] }, + privacy: { kicker: "100% 本地运行", title: ["数据只留在", "你自己的电脑"], chips: ["仅监听 127.0.0.1", "不保存 prompt 与回复", "单文件,无需数据库", "Windows · Linux · macOS"] }, + cta: { demo: "在线试用", meta: "免费开源 · MIT · 单文件安装" }, + badge: "演示画面使用合成数据", + play: "观看 30 秒宣传片" + } : { + hook: { kicker: "Coding with Codex every day", title: ["The tokens keep climbing.", "Where do they go?"], unit: "TOKENS" }, + brand: { tagline: "Every token. Every task. One clear view." }, + overview: { kicker: "01 · Overview", title: ["Totals and cost", "at a glance"], body: "Token mix, API-equivalent cost, and pricing coverage in one dashboard." }, + time: { kicker: "02 · Time", title: ["Down to", "the minute"], body: "Query any exact range, then follow hourly trends and the calendar to the peak.", steps: ["Custom range", "Hourly", "Calendar"] }, + attribution: { kicker: "03 · Attribution", title: ["From projects", "to subagents"], body: "Split usage by project, model, and source. Task trees show every subagent.", steps: ["Projects", "Task tree", "Session search"] }, + tools: { kicker: "04 · Tools", title: ["Filter. Export.", "Price."], body: "Filters apply instantly; exports and costs always follow the current scope.", steps: ["Fast only", "JSON / CSV", "Model pricing"] }, + interface: { kicker: "05 · Interface", title: ["Light or dark.", "English or 中文."], body: "Adjustable type, density, and motion, and a layout that works on your phone.", steps: ["Bilingual", "Themes", "Mobile"] }, + privacy: { kicker: "100% local", title: ["Your data stays", "on your machine"], chips: ["Loopback-only server", "No prompts or replies stored", "Single binary, no database", "Windows · Linux · macOS"] }, + cta: { demo: "Live demo", meta: "FREE & OPEN SOURCE · MIT · ONE-FILE INSTALL" }, + badge: "Synthetic demo data", + play: "Watch the 30s film" + }; + + // ---- math helpers ------------------------------------------------------- + const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); + const prog = (t, a, b) => clamp((t - a) / (b - a)); + const lerp = (a, b, p) => a + (b - a) * p; + const ease = { + out: p => 1 - (1 - p) ** 3, + inOut: p => p < .5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2, + expo: p => p >= 1 ? 1 : 1 - 2 ** (-10 * p), + back: p => { const c = 1.45; return 1 + (c + 1) * (p - 1) ** 3 + c * (p - 1) ** 2; }, + in: p => p ** 2.4 + }; + const union = (...rects) => { + const x = Math.min(...rects.map(r => r.x)), y = Math.min(...rects.map(r => r.y)); + return { x, y, w: Math.max(...rects.map(r => r.x + r.w)) - x, h: Math.max(...rects.map(r => r.y + r.h)) - y }; + }; + const lerpRect = (a, b, p) => ({ x: lerp(a.x, b.x, p), y: lerp(a.y, b.y, p), w: lerp(a.w, b.w, p), h: lerp(a.h, b.h, p) }); + const pad = (r, n) => ({ x: r.x - n, y: r.y - n, w: r.w + 2 * n, h: r.h + 2 * n }); + // Returns in/out envelopes for an element that enters at `a` and leaves at `b`. + const env = (t, a, b, inDur = .55, outDur = .32) => { + const i = ease.out(prog(t, a, a + inDur)); + const o = ease.inOut(prog(t, b, b + outDur)); + return { i, o, v: i * (1 - o) }; + }; + const $ = (sel, root = document) => root.querySelector(sel); + const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; + const set = (node, style) => { for (const [k, v] of Object.entries(style)) node.style[k] = v; }; + + // ---- layout ----------------------------------------------------------------- + const L = vertical ? { + copy: { x: 84, y: 178, w: 912 }, + panel: { x: 60, y: 692, w: 960 }, + cards: { x: 60, y: 720, w: 960, h: 1060 }, + phone: { x: 650, y: 1150, h: 660 }, + corner: { x: 84, y: 88 }, + badge: { x: 84, y: 1866 } + } : { + copy: { x: 128, y: 300, w: 560 }, + panel: { x: 748, y: 160, w: 1100 }, + cards: { x: 748, y: 150, w: 1100, h: 800 }, + phone: { x: 1560, y: 360, h: 660 }, + corner: { x: 128, y: 84 }, + badge: { x: 128, y: 1000 } + }; + const chromeH = 46; + const view = { w: L.panel.w, h: Math.round(L.panel.w * dims.h / dims.w) }; + + // ---- build DOM -------------------------------------------------------------- + const stage = $("#stage"); + document.documentElement.classList.add(vertical ? "vertical" : "landscape"); + set(stage, { width: `${W}px`, height: `${H}px` }); + document.documentElement.lang = zh ? "zh-CN" : "en"; + + const lookup = key => key.split(".").reduce((o, k) => o[k], copy); + for (const node of document.querySelectorAll("[data-copy]")) node.textContent = lookup(node.dataset.copy); + for (const node of document.querySelectorAll("[data-lines]")) for (const line of lookup(node.dataset.lines)) node.append(el("span", "line", line)); + + for (const slot of document.querySelectorAll(".icon-slot")) slot.innerHTML = P.icon; + // Brand reveal: a stroke mask draws the copper Z along its centre line. + const brandSvg = $("#brand svg"); + const ns = "http://www.w3.org/2000/svg"; + const mask = document.createElementNS(ns, "mask"); + mask.id = "zDraw"; + mask.setAttribute("maskUnits", "userSpaceOnUse"); + mask.innerHTML = ''; + brandSvg.querySelector("defs").append(mask); + const zPath = [...brandSvg.querySelectorAll(":scope > path")].at(-1); + zPath.setAttribute("mask", "url(#zDraw)"); + const zStroke = mask.querySelector("path"); + const brandBars = [...brandSvg.querySelectorAll("g path")]; + for (const bar of brandBars) set(bar, { transformBox: "fill-box", transformOrigin: "50% 100%" }); + const brandTiles = [...brandSvg.querySelectorAll(":scope > rect")]; + + const hookBars = $(".hook-bars"); + const barCount = vertical ? 28 : 56; + for (let i = 0; i < barCount; i++) hookBars.append(el("i")); + + const corner = $("#corner"); + set(corner, { left: `${L.corner.x}px`, top: `${L.corner.y}px` }); + const badge = $("#badge"); + set(badge, { left: `${L.badge.x}px`, top: `${L.badge.y}px` }); + + // Copy blocks for the product scenes. + const copyRoot = $("#copy"); + const blocks = {}; + for (const id of ["overview", "time", "attribution", "tools", "interface"]) { + const data = copy[id]; + const block = el("div", "copy-block"); + set(block, { left: `${L.copy.x}px`, top: `${L.copy.y}px`, width: `${L.copy.w}px` }); + const parts = [el("p", "kicker", data.kicker)]; + const h2 = el("h2"); + for (const line of data.title) h2.append(el("span", "line", line)); + parts.push(h2, el("p", "body", data.body)); + let steps = []; + if (data.steps) { + const row = el("div", "steps"); + steps = data.steps.map(s => row.appendChild(el("span", null, s))); + parts.push(row); + } + block.append(...parts); + copyRoot.append(block); + blocks[id] = { block, parts: [parts[0], ...h2.children, ...parts.slice(2)], steps }; + } + + // Browser panel and the stills it frames. + const panel = $("#panel"); + set(panel, { left: `${L.panel.x}px`, top: `${L.panel.y}px`, width: `${view.w}px`, height: `${view.h + chromeH}px` }); + set($(".viewport"), { width: `${view.w}px`, height: `${view.h}px` }); + const content = $("#content"); + set(content, { width: `${dims.w}px`, height: `${dims.h}px` }); + const layers = {}; + const addLayer = (name, src) => { + const img = el("img"); + img.src = src; + img.alt = ""; + set(img, { width: `${dims.w}px`, height: `${dims.h}px` }); + content.append(img); + layers[name] = img; + return img; + }; + for (const name of ["overview", "range", "hourly", "calendar", "projects", "tree", "search", "light", "dark"]) addLayer(name, P.assets.base + shots[name].file); + addLayer("other", P.other.base + P.other.shots.light.file); + + // ---- timings ---------------------------------------------------------------- + const o = scene.overview.start, tm = scene.time.start, at = scene.attribution.start; + const tl = scene.tools.start, ui = scene.interface.start, pv = scene.privacy.start, ct = scene.cta.start; + const F = n => shots[n].focus; + const full = { x: 0, y: 0, w: dims.w, h: dims.h }; + const hero = F("overview").hero; + + // Layer cross-fades: [time, layer, duration, kind] + const layerKeys = [ + [o, "overview", 0], + [tm, "range", .45], + [tm + 1.35, "hourly", .4], + [tm + 2.7, "calendar", .4], + [at, "projects", .45], + [at + 1.35, "tree", .4], + [at + 2.7, "search", .35], + [ui, "light", 0], + [ui + .55, "other", .4], + [ui + 1.2, "dark", .65, "wipe"] + ]; + // Camera moves: [start, duration, rect] + const cameraKeys = [ + [o, 0, full], + [o + 1.35, .95, pad(F("overview").totalCard, 20)], + [o + 2.55, .8, pad(F("overview").costCard, 20)], + [o + 3.6, .7, pad(hero, 60)], + [tm, .7, pad(union(F("range").form, F("range").hero), 24)], + [tm + 1.35, .6, pad(F("hourly").panel, 16)], + [tm + 2.7, .6, pad(union(F("calendar").calendar, F("calendar").day), 30)], + [at, .6, pad(F("projects").panel, 30)], + [at + 1.35, .6, pad(F("tree").panel, 16)], + [at + 2.7, .5, pad(union(F("search").search, { ...F("search").panel, h: Math.min(F("search").panel.h, 330) }), 24)], + [ui, 0, full] + ]; + // Highlight rings drawn over the stills: [layer, rect, in, out] + const ringKeys = [ + ["overview", F("overview").total, o + 2.0, o + 2.55], + ["overview", F("overview").cost, o + 3.1, o + 4.25], + ["range", F("range").form, tm + .75, tm + 1.3], + ["hourly", F("hourly").chart, tm + 1.95, tm + 2.65], + ["calendar", F("calendar").day, tm + 3.25, tm + 3.85], + ["search", F("search").search, at + 3.1, at + 3.8] + ].map(([layer, rect, a, b]) => { + const ring = el("div", "ring"); + content.append(ring); + return { ring, rect: pad(rect, 10), a, b }; + }); + const stepTimes = { + time: [tm, tm + 1.35, tm + 2.7], + attribution: [at, at + 1.35, at + 2.7], + tools: [tl + .35, tl + 1.35, tl + 2.35], + interface: [ui + .55, ui + 1.2, ui + 1.8] + }; + + // Feature cards. + const fast = shots.fast.focus; + const cardSpecs = [ + { shot: "fast", rect: pad(union(fast.chips, fast.hero), 22) }, + { shot: "export", rect: shots.export.focus.dialog }, + { shot: "pricing", rect: shots.pricing.focus.dialog } + ]; + const cards = cardSpecs.map(spec => { + const card = el("div", "card"); + const img = el("img"); + img.src = P.assets.base + shots[spec.shot].file; + set(img, { width: `${dims.w}px`, height: `${dims.h}px`, left: `${-spec.rect.x}px`, top: `${-spec.rect.y}px` }); + const shade = el("i", "shade"); + card.append(img, shade); + set(card, { width: `${spec.rect.w}px`, height: `${spec.rect.h}px` }); + $("#cards").append(card); + const s = Math.min(L.cards.w / spec.rect.w, L.cards.h / spec.rect.h, vertical ? 1.55 : 1.3); + return { card, shade, s, x: L.cards.x + (L.cards.w - spec.rect.w * s) / 2, y: L.cards.y + (L.cards.h - spec.rect.h * s) / 2 }; + }); + + // Phone. + const phone = $("#phone"); + $("#phone img").src = P.assets.base + shots.mobile.file; + const phoneScale = L.phone.h / 884; + + // Privacy chips and rings. + const chipNodes = copy.privacy.chips.map(text => $(".privacy-chips").appendChild(el("li", null, text))); + const ringNodes = [...document.querySelectorAll(".rings i")]; + + const command = "./codex-usage install"; + const typed = $("#typed"); + const ctaParts = [$(".cta-icon"), $("#cta h1"), $(".cta-tagline"), $(".terminal"), ...document.querySelectorAll(".cta-links li"), $(".cta-meta")]; + + const bgLight = $(".bg-light"), bgDark = $(".bg-dark"); + + // ---- per-scene renderers ---------------------------------------------------- + function renderBackground(t) { + const open = ease.inOut(prog(t, scene.brand.end - .4, scene.brand.end + .25)); + const close = ease.inOut(prog(t, pv - .3, pv + .25)); + const r = 140 * open * (1 - close); + set(bgLight, { clipPath: `circle(${r}% at 50% ${vertical ? 42 : 46}%)`, visibility: r > 0 ? "visible" : "hidden" }); + const drift = Math.sin(t * .35); + for (const [bg, dx] of [[bgLight, 1], [bgDark, -1]]) { + set($(".glow-a", bg), { transform: `translate(${W * .55 + drift * 80 * dx}px, ${-H * .35 + t * 6}px)` }); + set($(".glow-b", bg), { transform: `translate(${-W * .3 - drift * 60 * dx}px, ${H * .45 - t * 5}px)` }); + } + set($(".grid", bgLight), { transform: `translate(${(t * 6) % 34}px, ${(t * 4) % 34}px)` }); + } + + function renderHook(t) { + const hook = $("#hook"); + const out = ease.inOut(prog(t, 2.5, 3.0)); + set(hook, { opacity: 1 - out, transform: `scale(${1 + out * .06})`, visibility: t < 3.05 ? "visible" : "hidden" }); + const parts = [$(".kicker", hook), ...$(".hook-title").children, $(".hook-counter")]; + parts.forEach((node, i) => { + const p = ease.out(prog(t, .1 + i * .12, .7 + i * .12)); + set(node, { opacity: p, transform: `translateY(${(1 - p) * 40}px)` }); + }); + const grow = ease.in(prog(t, .35, 2.45)); + $("#hookCount").textContent = Math.round(2371512 * grow).toLocaleString("en-US"); + [...hookBars.children].forEach((bar, i) => { + const n = .5 + .5 * Math.sin(i * 1.7 + t * 2.2) * Math.cos(i * .63 - t * 1.3); + const h = (.12 + .88 * grow) * (.25 + .75 * n) * (.55 + .45 * Math.sin(i / barCount * Math.PI)); + set(bar, { transform: `scaleY(${h})`, height: "100%" }); + }); + } + + function renderBrand(t) { + const b = scene.brand.start; + const node = $("#brand"); + const out = ease.inOut(prog(t, scene.brand.end - .7, scene.brand.end - .3)); + set(node, { visibility: t > b - .05 && t < scene.brand.end + .05 ? "visible" : "hidden", opacity: 1 - out, transform: `scale(${1 + out * .05})` }); + const pop = ease.back(prog(t, b, b + .55)); + set($(".brand-icon"), { transform: `scale(${.35 + .65 * pop}) rotate(${(1 - pop) * -8}deg)`, opacity: prog(t, b, b + .15) }); + brandTiles.forEach(tile => set(tile, { opacity: 1 })); + brandBars.forEach((bar, i) => set(bar, { transform: `scaleY(${ease.back(prog(t, b + .15 + i * .09, b + .5 + i * .09))})` })); + zStroke.setAttribute("stroke-dashoffset", String(1 - ease.inOut(prog(t, b + .45, b + 1.0)))); + const words = [$("#brand h1"), $("#brand .byline"), $(".brand-tagline")]; + words.forEach((w, i) => { + const p = ease.out(prog(t, b + .7 + i * .16, b + 1.3 + i * .16)); + set(w, { opacity: p, transform: `translate${vertical ? "Y" : "X"}(${(1 - p) * (vertical ? 40 : -50)}px)` }); + }); + } + + function renderCopy(t) { + const order = ["overview", "time", "attribution", "tools", "interface"]; + for (const id of order) { + const { block, parts, steps } = blocks[id]; + const a = scene[id].start + .15, b = scene[id].end - .4; + const visible = t > a - .1 && t < b + .8; + block.style.visibility = visible ? "visible" : "hidden"; + if (!visible) continue; + parts.forEach((node, i) => { + const e = env(t, a + i * .07, b + i * .03, .6, .3); + set(node, { opacity: e.v, transform: `translateY(${(1 - e.i) * 46 - e.o * 34}px)` }); + }); + const times = stepTimes[id]; + steps.forEach((step, i) => { + const on = ease.out(prog(t, times[i], times[i] + .25)); + const off = i < times.length - 1 ? ease.out(prog(t, times[i + 1], times[i + 1] + .25)) : 0; + step.style.setProperty("--a", (on * (1 - off) * .999).toFixed(3)); + }); + } + const e = env(t, o + .3, pv - .5, .5, .3); + set(corner, { opacity: e.v, transform: `translateY(${(1 - e.i) * -16}px)` }); + set(badge, { opacity: e.v * .9 }); + } + + function cameraAt(t) { + let rect = cameraKeys[0][2]; + for (const [start, dur, target] of cameraKeys) { + if (t < start) break; + rect = dur ? lerpRect(rect, target, ease.inOut(prog(t, start, start + dur))) : target; + } + const s = Math.min(view.w / rect.w, view.h / rect.h, 1.3); + let tx = view.w / 2 - (rect.x + rect.w / 2) * s; + // Short focus areas sit at the top, like a scrolled page, rather than cutting a heading in half. + let ty = -rect.y * s; + // Keep the still covering the viewport whenever it is large enough to do so. + if (dims.w * s >= view.w) tx = clamp(tx, view.w - dims.w * s, 0); + if (dims.h * s >= view.h) ty = clamp(ty, view.h - dims.h * s, 0); + return { s, tx, ty }; + } + + function renderPanel(t) { + const first = env(t, o + .05, tl - .05, 1.0, .5); + const second = env(t, ui - .25, scene.interface.end - .5, .7, .35); + const inP = t < tl + .5 ? first.i : second.i; + const outP = t < tl + .5 ? first.o : second.o; + const v = t < tl + .5 ? first.v : second.v; + panel.style.visibility = v > .001 ? "visible" : "hidden"; + set(panel, { + opacity: v, + transform: `perspective(2200px) translateY(${(1 - inP) * 150 + outP * 70}px) rotateX(${(1 - inP) * 16}deg) scale(${(.94 + .06 * inP) * (1 - outP * .06)})` + }); + const cam = cameraAt(t); + set(content, { transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }); + + // Determine the active layer and the one it is fading in over. + let current = null, previous = null, p = 1, kind = "fade"; + for (const [start, name, dur, k] of layerKeys) { + if (t < start) break; + previous = current; + current = name; + p = dur ? ease.inOut(prog(t, start, start + dur)) : 1; + kind = k || "fade"; + } + for (const [name, img] of Object.entries(layers)) { + let opacity = 0, z = 0, clip = "none"; + if (name === current) { + z = 2; + if (kind === "wipe") { opacity = 1; const x = p * 130; clip = `polygon(0 0, ${x}% 0, ${x - 30}% 100%, 0 100%)`; } + else opacity = p; + } else if (name === previous && p < 1) { opacity = 1; z = 1; } + set(img, { opacity, zIndex: z, clipPath: clip, visibility: opacity > 0 ? "visible" : "hidden" }); + } + for (const r of ringKeys) { + const e = env(t, r.a, r.b, .3, .3); + const grow = 1.08 - .08 * ease.back(prog(t, r.a, r.a + .4)); + set(r.ring, { + left: `${r.rect.x}px`, top: `${r.rect.y}px`, width: `${r.rect.w}px`, height: `${r.rect.h}px`, + borderWidth: `${3.5 / cam.s}px`, opacity: e.v, zIndex: 3, + transform: `scale(${grow})`, visibility: e.v > 0 ? "visible" : "hidden" + }); + } + } + + function renderCards(t) { + const arrive = cards.map((_, i) => prog(t, tl + .35 + i, tl + .95 + i)); + const out = ease.inOut(prog(t, scene.tools.end - .45, scene.tools.end - .1)); + cards.forEach((c, i) => { + const p = ease.back(arrive[i]); + const depth = arrive.slice(i + 1).reduce((sum, a) => sum + ease.out(a), 0); + const visible = arrive[i] > 0 && out < 1; + c.card.style.visibility = visible ? "visible" : "hidden"; + if (!visible) return; + const s = c.s * (1 - .075 * depth) * (.9 + .1 * p); + const w = parseFloat(c.card.style.width) * c.s, h = parseFloat(c.card.style.height) * c.s; + const cx = c.x + w / 2, cy = c.y + h / 2; + const rw = parseFloat(c.card.style.width) * s, rh = parseFloat(c.card.style.height) * s; + const y = cy - rh / 2 + (1 - p) * 180 - depth * (vertical ? 70 : 54) + out * 120; + set(c.card, { + transform: `translate(${cx - rw / 2}px, ${y}px) scale(${s}) rotate(${(1 - p) * (i % 2 ? 4 : -4)}deg)`, + opacity: clamp(arrive[i] * 3) * (1 - out), + zIndex: 10 + i, borderRadius: `${18 / c.s}px` + }); + c.shade.style.opacity = String(Math.min(.35, depth * .22)); + }); + } + + function renderPhone(t) { + const e = env(t, ui + 1.8, scene.interface.end - .5, .7, .35); + phone.style.visibility = e.v > .001 ? "visible" : "hidden"; + set(phone, { opacity: e.v, transform: `translate(${L.phone.x + (1 - e.i) * 260}px, ${L.phone.y + e.o * 60}px) scale(${phoneScale}) rotate(${(1 - e.i) * 7}deg)` }); + } + + function renderPrivacy(t) { + const node = $("#privacy"); + const visible = t > pv - .1 && t < ct + .1; + node.style.visibility = visible ? "visible" : "hidden"; + if (!visible) return; + const out = ease.inOut(prog(t, ct - .35, ct)); + set(node, { opacity: 1 - out, transform: `scale(${1 - out * .04})` }); + const parts = [$(".lock"), $("#privacy .kicker"), ...$("#privacy h1").children]; + parts.forEach((p, i) => { + const e = ease.out(prog(t, pv + .2 + i * .09, pv + .8 + i * .09)); + set(p, { opacity: e, transform: `translateY(${(1 - e) * 44}px) scale(${i ? 1 : .7 + .3 * ease.back(prog(t, pv + .2, pv + .8))})` }); + }); + chipNodes.forEach((chip, i) => { + const e = ease.back(prog(t, pv + .85 + i * .2, pv + 1.35 + i * .2)); + set(chip, { opacity: clamp(e), transform: `translateY(${(1 - e) * 30}px) scale(${.92 + .08 * e})` }); + }); + const base = vertical ? 360 : 300, span = vertical ? 700 : 820; + ringNodes.forEach((ring, i) => { + const phase = ((t - pv) * .42 + i / ringNodes.length) % 1; + const d = base + phase * span; + set(ring, { width: `${d}px`, height: `${d}px`, transform: `translate(${-d / 2}px, ${-d / 2 - (vertical ? 120 : 110)}px)`, opacity: (1 - phase) * .32 * prog(t, pv, pv + .6) }); + }); + } + + function renderCTA(t) { + const node = $("#cta"); + node.style.visibility = t > ct - .05 ? "visible" : "hidden"; + ctaParts.forEach((part, i) => { + const at = ct + .1 + i * .13; + const p = i === 0 ? ease.back(prog(t, at, at + .6)) : ease.out(prog(t, at, at + .55)); + set(part, { opacity: clamp(p * 1.4), transform: i === 0 ? `scale(${.4 + .6 * p})` : `translateY(${(1 - p) * 36}px)` }); + }); + const chars = Math.round(command.length * prog(t, ct + .75, ct + 1.55)); + typed.textContent = command.slice(0, chars); + $(".caret").style.opacity = chars < command.length || Math.floor((t - ct) * 2.2) % 2 === 0 ? "1" : "0"; + } + + function render(t) { + renderBackground(t); + renderHook(t); + renderBrand(t); + renderCopy(t); + renderPanel(t); + renderCards(t); + renderPhone(t); + renderPrivacy(t); + renderCTA(t); + } + + const images = [...document.images]; + window.promo = { + ready: Promise.all([document.fonts.ready, ...images.map(img => img.decode())]), + seek(t) { render(t); }, + // The README poster links to the video, so it carries a play badge. + poster(on) { $("#play").style.visibility = on ? "visible" : "hidden"; } + }; +})(); diff --git a/scripts/promo/timeline.json b/scripts/promo/timeline.json new file mode 100644 index 0000000..83202a0 --- /dev/null +++ b/scripts/promo/timeline.json @@ -0,0 +1,17 @@ +{ + "fps": 30, + "duration": 30, + "bpm": 120, + "poster": 12.4, + "scenes": [ + { "id": "hook", "start": 0, "end": 3 }, + { "id": "brand", "start": 3, "end": 5.5 }, + { "id": "overview", "start": 5.5, "end": 10 }, + { "id": "time", "start": 10, "end": 14 }, + { "id": "attribution", "start": 14, "end": 18 }, + { "id": "tools", "start": 18, "end": 21.5 }, + { "id": "interface", "start": 21.5, "end": 24.5 }, + { "id": "privacy", "start": 24.5, "end": 27 }, + { "id": "cta", "start": 27, "end": 30 } + ] +}