diff --git a/README.en.md b/README.en.md
index 41fa5c5..9739674 100644
--- a/README.en.md
+++ b/README.en.md
@@ -21,7 +21,7 @@
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) · [30-second promo](docs/media/codex-usage-promo-en.mp4) ([vertical](docs/media/codex-usage-promo-en-vertical.mp4))
+[Try the interactive demo](https://zjay26.github.io/codex-usage/?lang=en) · [High-resolution video](docs/media/codex-usage-demo-en.mp4) · [Promo film](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.
@@ -309,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`, and the 30-second promo with `npm run capture:promo`; 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 promo film 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, and macOS Apple Silicon, Linux concurrency checks, Dashboard tests, and one native macOS install/uninstall/reinstall cycle; merges to main also run six-target cross-builds. Release publication additionally repeats the native install checks three times on both macOS Apple Silicon and Intel.
diff --git a/README.md b/README.md
index b587181..9cca3ec 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
概览 → 分钟级时间查询 → 小时下钻 → 每日月历 → 项目与任务树 → 搜索与 Fast 筛选 → JSON / CSV 导出 → 模型定价 → 明暗主题与中英切换。
-[亲手试用在线 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))
+[亲手试用在线 Demo](https://zjay26.github.io/codex-usage/?lang=zh-CN) · [高清演示视频](docs/media/codex-usage-demo-zh.mp4) · [宣传片](docs/media/codex-usage-promo-zh.mp4)([竖屏版](docs/media/codex-usage-promo-zh-vertical.mp4))
> 演示使用完整定价、无异常记录的合成数据,直接录制当前 Dashboard。在线 Demo 不读取你的文件、不设 Cookie、无埋点或外部请求。
@@ -311,7 +311,7 @@ npm test
`npm test` 默认在临时目录构建并启动真实 Go 二进制;设置 `CODEX_USAGE_BIN` 可以复用已有构建产物。
-README 动图、视频和截图可通过 `npm run capture:media` 重新生成,30 秒宣传片可通过 `npm run capture:promo` 重新生成;依赖、演示场景与录制检查见 [媒体说明](docs/media/README.md)。
+README 动图、视频和截图可通过 `npm run capture:media` 重新生成,宣传片可通过 `npm run capture:promo` 重新生成;依赖、演示场景与录制检查见 [媒体说明](docs/media/README.md)。
当前 [CI](https://github.com/zJay26/codex-usage/actions/workflows/ci.yml) 覆盖 Windows、Linux、macOS Apple Silicon 的 Go 测试与 vet、Linux 并发检查、Dashboard 测试,以及 macOS 上的一轮安装、卸载和重装;合并到 main 后还会做六目标交叉构建。Release 发布前还会在 macOS Apple Silicon / Intel 上各重复三轮原生安装检查。
diff --git a/docs/media/README.md b/docs/media/README.md
index af99be2..3de80d1 100644
--- a/docs/media/README.md
+++ b/docs/media/README.md
@@ -27,7 +27,7 @@ The export dialog is demonstrated without downloading files. Software installati
## Promo film
-`npm run capture:promo` renders a 30-second promotional film from the same synthetic demo, in Chinese and English, landscape and vertical:
+`npm run capture:promo` renders a 59-second promotional film in Chinese and English, in landscape and vertical cuts:
- `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.
@@ -35,12 +35,44 @@ The export dialog is demonstrated without downloading files. Software installati
[](codex-usage-promo-en.mp4)
+The film opens with a question and the brand, then shows ten features, one idea per shot:
+
+1. Totals and cost
+2. The last 30 days of daily usage
+3. Hourly usage
+4. A minute-precision range since the last reset
+5. The calendar with its Standard / Fast split
+6. Breakdowns by model and project
+7. Session search
+8. The task tree
+9. Export, live usage updates, and software updates
+10. Theme, language, and mobile layout
+
+It closes with the privacy promise and install instructions.
+
+Each shot uses the same devices. The camera settles on one area, the rest of the page dims, a label names what matters, and a cursor performs the click that leads to the next still.
+
+The film uses its own synthetic ledger, `?scenario=promo` in `scripts/demo-api.js`, with heavier, more realistic use than the default demo:
+
+- About 2.38B tokens and $3,100 of API-equivalent cost over 30 days.
+- A weekday rhythm, a release-day spike, and idle Sundays.
+- Seven projects and task-like Session titles in the viewer's language.
+- Subagent trees and a mock "update available" state.
+
+The default online demo and its tests are unchanged.
+
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.
+1. `scripts/promo/assets.mjs` captures 2× Dashboard stills for every beat, together with the rectangles that the camera, spotlight, labels, and cursor are anchored to. 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 from a beat sheet. 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 in Node (pads, bass, drums, plucks, bells, and whooshes), 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. Several environment variables help when rendering or iterating:
+
+- `MEDIA_LOCALE=zh-CN|en` and `PROMO_FORMAT=landscape|vertical` render a single version.
+- `PROMO_PREVIEW=1` writes only one review still per second, plus the posters.
+- `PROMO_ASSETS=
` caches the captured stills between runs.
-`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/`.
+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
index 85689a4..4ee6879 100644
Binary files a/docs/media/codex-usage-promo-en-vertical.mp4 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
index c38ed7e..8518274 100644
Binary files a/docs/media/codex-usage-promo-en.mp4 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
index 3734c83..7124618 100644
Binary files a/docs/media/codex-usage-promo-poster-en.jpg 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
index f349b2b..ec92ea0 100644
Binary files a/docs/media/codex-usage-promo-poster.jpg 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
index 4251c04..88c85a1 100644
Binary files a/docs/media/codex-usage-promo-zh-vertical.mp4 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
index 75fdf92..ca88882 100644
Binary files a/docs/media/codex-usage-promo-zh.mp4 and b/docs/media/codex-usage-promo-zh.mp4 differ
diff --git a/scripts/capture-promo.mjs b/scripts/capture-promo.mjs
index b709204..e4e36c6 100644
--- a/scripts/capture-promo.mjs
+++ b/scripts/capture-promo.mjs
@@ -22,15 +22,17 @@ const sizes = { landscape: { width: 1920, height: 1080 }, vertical: { width: 108
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");
+// PROMO_ASSETS keeps captured Dashboard stills between runs while iterating on the composition.
+const assetsDir = process.env.PROMO_ASSETS ? path.resolve(process.env.PROMO_ASSETS) : 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.
+// One review still per second, named after its scene, 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 }));
+ const times = [];
+ for (let t = .5; t < timeline.duration; t += 1) times.push({ name: timeline.scenes.findLast(s => s.start <= t).id, t });
return [...times, { name: "poster", t: timeline.poster }];
}
@@ -112,7 +114,7 @@ async function verify(file, format) {
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
+ size: size > 200_000 && size < 30_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)}`);
@@ -130,7 +132,8 @@ 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]);
+ const cached = process.env.PROMO_ASSETS ? await readFile(path.join(dir, "manifest.json"), "utf8").then(JSON.parse, () => null) : null;
+ const manifest = cached || 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`);
}
diff --git a/scripts/demo-api.js b/scripts/demo-api.js
index a7836db..9f5e526 100644
--- a/scripts/demo-api.js
+++ b/scripts/demo-api.js
@@ -4,7 +4,11 @@
root.CODEX_USAGE_DEMO = true;
// Keep incomplete accounting examples available for diagnostics, while the
// default demo represents a healthy ledger with fully priced current models.
- const diagnostics = new URL(root.location.href).searchParams.get("scenario") === "diagnostics";
+ const params = new URL(root.location.href).searchParams;
+ const diagnostics = params.get("scenario") === "diagnostics";
+ // The promo film uses a larger, heavier-use ledger; the default demo stays unchanged.
+ const promo = params.get("scenario") === "promo";
+ const promoEnglish = (params.get("lang") || "").startsWith("en");
const now = new Date();
let pricingOverrides = {};
@@ -24,30 +28,51 @@
{ model: "gpt-5.6-terra", display_name: "GPT-5.6 Terra", input_usd_per_million: "2.00", cached_input_usd_per_million: "0.20", cache_write_input_usd_per_million: "2.50", output_usd_per_million: "12.00", source: "#synthetic-pricing" },
{ model: "gpt-5.4", display_name: "GPT-5.4", input_usd_per_million: "2.50", cached_input_usd_per_million: "0.25", output_usd_per_million: "15.00", source: "#synthetic-pricing" }
];
- const models = [
+ const models = promo ? [
+ { key: "gpt-6-sol", share: .52, events: 9_610, sessions: 182 },
+ { key: "gpt-6-astra", share: .27, events: 4_870, sessions: 94 },
+ { key: "gpt-6-luna", share: .13, events: 2_740, sessions: 121 },
+ { key: "gpt-5.6-terra", share: .08, events: 1_242, sessions: 37 }
+ ] : [
{ key: diagnostics ? "gpt-5.4" : "gpt-6-sol", share: .57, events: 71, sessions: 15 },
{ key: diagnostics ? "gpt-5.6-terra" : "gpt-6-astra", share: .31, events: 32, sessions: 8 },
{ key: diagnostics ? "codex-auto-review" : "gpt-6-luna", share: .12, events: 11, sessions: 3 }
];
- const agents = [
+ const agents = promo ? [
+ { key: "main", share: .71, events: 12_380, sessions: 214 },
+ { key: "subagent", share: .21, events: 4_960, sessions: 97 },
+ { key: "guardian", share: .05, events: 812, sessions: 23 },
+ { key: "memory", share: .03, events: 310, sessions: 12 }
+ ] : [
{ key: "main", share: .75, events: 80, sessions: 20 },
{ key: "subagent", share: .17, events: 21, sessions: 6 },
{ key: "guardian", share: .05, events: 8, sessions: 4 },
{ key: "memory", share: .03, events: 5, sessions: 2 }
];
- const sources = [
+ const sources = promo ? [
+ { key: "codex_desktop", share: .64, events: 11_730, sessions: 226 },
+ { key: "codex_cli_rs", share: .36, events: 6_732, sessions: 120 }
+ ] : [
{ key: "codex_desktop", share: .68, events: 70, sessions: 16 },
{ key: "codex_cli_rs", share: .32, events: 44, sessions: 10 }
];
- const projects = [
+ const projects = promo ? [
+ { key: "~/code/payments-api", share: .31, events: 5_720, sessions: 96 },
+ { key: "~/code/web-console", share: .22, events: 4_060, sessions: 81 },
+ { key: "~/code/mobile-app", share: .14, events: 2_590, sessions: 52 },
+ { key: "~/code/data-pipeline", share: .12, events: 2_210, sessions: 41 },
+ { key: "~/code/ml-experiments", share: .09, events: 1_660, sessions: 29 },
+ { key: "~/code/infra", share: .08, events: 1_480, sessions: 33 },
+ { key: "~/code/docs-site", share: .04, events: 742, sessions: 14 }
+ ] : [
{ key: "synthetic://visual-lab", share: .62, events: 70, sessions: 12 },
{ key: "synthetic://service-api", share: .38, events: 44, sessions: 8 }
];
- const threads = [
+ const threads = promo ? [] : [
{ key: "Dashboard localization", share: .61, events: 12, sessions: 1 },
{ key: "JSONL fork replay audit", share: .39, events: 9, sessions: 1 }
];
- const sessions = [
+ const sessions = promo ? [] : [
{ session_id: "demo-session-a", title: "Dashboard localization", project_path: "synthetic://visual-lab", model: models[0].key, source: "codex_desktop", agent_type: "main", share: .48, confidence: "exact", hoursAgo: 0 },
{ session_id: "demo-session-b", title: "JSONL fork replay audit", project_path: "synthetic://service-api", model: models[1].key, source: "codex_cli_rs", agent_type: "subagent", share: .31, confidence: diagnostics ? "gap_fallback" : "exact", hoursAgo: 2 },
{ session_id: "demo-session-c", title: "Local data-quality review", project_path: "synthetic://visual-lab", model: models[2].key, source: "codex_desktop", agent_type: "guardian", share: .21, confidence: "exact", hoursAgo: 7 }
@@ -80,11 +105,98 @@
const usageKeys = ["input", "cached_input", "cache_write_input", "output", "reasoning_output", "total"];
const zeroUsage = () => Object.fromEntries(usageKeys.map((key) => [key, 0]));
const addUsage = (a, b) => Object.fromEntries(usageKeys.map((key) => [key, (a[key] || 0) + (b[key] || 0)]));
- function modeUsage(usage, mode) {
- const fast = scaledUsage(usage, .25);
+ // ---- Promo ledger -------------------------------------------------------
+ // Usage is generated hour by hour from a deterministic workday rhythm, so every
+ // view (range totals, daily bars, hours, calendar, breakdowns) agrees exactly.
+ const promoHourWeights = [.3, .14, .05, .02, .02, .03, .06, .22, .62, 1.3, 1.72, 1.6, .86, .74, 1.42, 1.82, 1.94, 1.7, 1.28, .9, .84, .92, .8, .5];
+ const promoHourSum = promoHourWeights.reduce((sum, weight) => sum + weight, 0);
+ const promoWeekday = [.3, 1.02, 1.12, 1.08, 1.18, .94, .38];
+ const promoSpecialDays = { "2026-09-17": 1.85, "2026-09-06": 0, "2026-08-23": 0, "2026-08-09": 0 };
+ const promoDaySeed = (date) => Math.round(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 86_400_000);
+ function promoDayFactor(date) {
+ const seed = promoDaySeed(date);
+ const special = promoSpecialDays[dateKey(date)];
+ const age = (now - date) / 86_400_000;
+ const trend = .8 + .2 * Math.max(0, Math.min(1, 1 - age / 60));
+ return promoWeekday[date.getDay()] * (1 + .15 * Math.sin(seed * 1.37) + .07 * Math.sin(seed * .41 + 1)) * trend * (special ?? 1);
+ }
+ const promoFastShare = (date) => .22 + .11 * Math.sin(promoDaySeed(date) * .9);
+ function promoHourRate(hourStart) {
+ const hourSeed = Math.round(hourStart.getTime() / 3_600_000);
+ return promoDayFactor(hourStart) * promoHourWeights[hourStart.getHours()] / promoHourSum * (1 + .12 * Math.sin(hourSeed * .77));
+ }
+ // Raw token and Fast weights between two instants, never past "now".
+ function promoRaw(start, end) {
+ const stop = Math.min(+end, +now);
+ let total = 0, fast = 0;
+ const cursor = new Date(start);
+ cursor.setMinutes(0, 0, 0);
+ for (; +cursor < stop; cursor.setHours(cursor.getHours() + 1)) {
+ const next = new Date(cursor);
+ next.setHours(next.getHours() + 1);
+ const overlap = Math.max(0, Math.min(stop, +next) - Math.max(+start, +cursor)) / (next - cursor);
+ if (!overlap) continue;
+ const value = promoHourRate(cursor) * overlap;
+ total += value;
+ fast += value * promoFastShare(cursor);
+ }
+ return { total, fast };
+ }
+ const promoScale = promo ? 2_380_000_000 / promoRaw(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29), now).total : 1;
+ const promoComposition = (total) => {
+ const input = Math.round(total * .979);
+ const output = total - input;
+ return { input, cached_input: Math.round(input * .938), cache_write_input: Math.round(input * .006), output, reasoning_output: Math.round(output * .52), total };
+ };
+ function promoUsage(start, end, scale = 1) {
+ const raw = promoRaw(start, end);
+ return { usage: promoComposition(Math.round(raw.total * promoScale * scale)), fast_share: raw.total ? raw.fast / raw.total : 0 };
+ }
+ function promoBounds(url) {
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const { start, end } = requestedBounds(url);
+ return { start: start || new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29), end: end || new Date(+now + 1) };
+ }
+ // Session titles read like real tasks, in the viewer's language.
+ const promoSessionRows = [
+ ["7f3a9c21", "", "重构订单状态机,补齐退款分支", "Refactor the order state machine and add refund paths", "payments-api", "gpt-6-sol", "codex_desktop", "main", 128, .3],
+ ["7f3a9c21-s1", "7f3a9c21", "梳理退款相关的单元测试", "Map out the refund unit tests", "payments-api", "gpt-6-luna", "codex_desktop", "subagent", 42, .4],
+ ["7f3a9c21-s2", "7f3a9c21", "排查幂等键冲突", "Investigate idempotency key collisions", "payments-api", "gpt-6-sol", "codex_desktop", "subagent", 36, .6],
+ ["7f3a9c21-g1", "7f3a9c21", "审查数据库迁移脚本", "Review the database migration scripts", "payments-api", "gpt-6-astra", "codex_desktop", "guardian", 12, .5],
+ ["b41e0d87", "", "修复支付回调重复入账", "Fix duplicate ledger entries from payment webhooks", "payments-api", "gpt-6-astra", "codex_cli_rs", "main", 96, 2],
+ ["b41e0d87-s1", "b41e0d87", "复现支付回调重放场景", "Reproduce payment webhook replays", "payments-api", "gpt-6-sol", "codex_cli_rs", "subagent", 31, 2.2],
+ ["2c9d5f10", "", "Web 控制台新增用量图表", "Add usage charts to the web console", "web-console", "gpt-6-sol", "codex_desktop", "main", 88, 4],
+ ["2c9d5f10-s1", "2c9d5f10", "调整暗色主题配色", "Tune the dark theme palette", "web-console", "gpt-6-luna", "codex_desktop", "subagent", 18, 4.3],
+ ["2c9d5f10-s2", "2c9d5f10", "补充图表 E2E 测试", "Add end-to-end tests for charts", "web-console", "gpt-6-sol", "codex_desktop", "subagent", 27, 4.6],
+ ["e8a1467b", "", "移动端登录改用 Passkey", "Move mobile sign-in to passkeys", "mobile-app", "gpt-6-astra", "codex_desktop", "main", 74, 7],
+ ["e8a1467b-s1", "e8a1467b", "对比 iOS 与 Android 行为差异", "Compare iOS and Android behavior", "mobile-app", "gpt-6-luna", "codex_desktop", "subagent", 16, 7.2],
+ ["5d02bb39", "", "数据管道改为增量同步", "Make the data pipeline sync incrementally", "data-pipeline", "gpt-6-sol", "codex_cli_rs", "main", 69, 22],
+ ["5d02bb39-s1", "5d02bb39", "压测批量写入", "Load-test batched writes", "data-pipeline", "gpt-6-sol", "codex_cli_rs", "subagent", 22, 22.4],
+ ["5d02bb39-m1", "5d02bb39", "总结上周的调优记录", "Summarize last week's tuning notes", "data-pipeline", "gpt-6-luna", "codex_cli_rs", "memory", 6, 22.1],
+ ["90fe13a4", "", "Terraform 拆分生产与预发环境", "Split production and staging in Terraform", "infra", "gpt-5.6-terra", "codex_cli_rs", "main", 51, 26],
+ ["48e2f7d0", "", "文档站迁移到新主题", "Move the docs site to the new theme", "docs-site", "gpt-6-luna", "codex_desktop", "main", 23, 30],
+ ["3b7c68e2", "", "升级依赖并修复破坏性变更", "Upgrade dependencies and fix breaking changes", "web-console", "gpt-6-sol", "codex_desktop", "main", 64, 49],
+ ["c61d0a95", "", "排查支付服务内存泄漏", "Track down a memory leak in the payment service", "payments-api", "gpt-6-astra", "codex_cli_rs", "main", 58, 52],
+ ["a0c93e5b", "", "推荐模型特征回填", "Backfill features for the ranking model", "ml-experiments", "gpt-6-astra", "codex_cli_rs", "main", 47, 70],
+ ["6f18d2c4", "", "API 网关增加限流", "Add rate limiting to the API gateway", "infra", "gpt-6-sol", "codex_cli_rs", "main", 39, 75],
+ ["d7b40a19", "", "CI 迁移到 GitHub Actions", "Move CI to GitHub Actions", "infra", "gpt-5.6-terra", "codex_cli_rs", "main", 33, 96],
+ ["1e6c9f73", "", "修复 Safari 日期选择器", "Fix the date picker on Safari", "web-console", "gpt-6-luna", "codex_desktop", "main", 12, 100],
+ ["8a5b2d06", "", "支付订单导出 CSV", "Export payment orders to CSV", "payments-api", "gpt-6-sol", "codex_desktop", "main", 29, 120]
+ ];
+ if (promo) {
+ for (const [id, parent, zh, en, project, model, source, agent, millions, hoursAgo] of promoSessionRows) {
+ sessions.push({ session_id: `rollout-${id}`, parent: parent ? `rollout-${parent}` : "", title: promoEnglish ? en : zh, project_path: `~/code/${project}`, model, source, agent_type: agent, tokens: millions * 1e6 + (millions * 7_919) % 1e6, confidence: "exact", hoursAgo });
+ }
+ const roots = sessions.filter((item) => !item.parent);
+ const rootTotal = roots.reduce((sum, item) => sum + item.tokens, 0);
+ for (const item of roots.slice(0, 8)) threads.push({ key: item.title, share: item.tokens / rootTotal * .8, events: Math.round(item.tokens / 190_000), sessions: 1 + sessions.filter((child) => child.parent === item.session_id).length });
+ }
+
+ function modeUsage(usage, mode, share = .25) {
+ const fast = scaledUsage(usage, share);
fast.total = fast.input + fast.output;
const regular = Object.fromEntries(usageKeys.map((key) => [key, usage[key] - fast[key]]));
- const unknown = scaledUsage(regular, .2); unknown.total = unknown.input + unknown.output;
+ const unknown = promo ? zeroUsage() : scaledUsage(regular, .2); unknown.total = unknown.input + unknown.output;
if (mode === "fast") return { regular: zeroUsage(), fast, unknown: zeroUsage() };
if (mode === "regular") return { regular, fast: zeroUsage(), unknown };
if (mode === "unknown") return { regular: unknown, fast: zeroUsage(), unknown };
@@ -137,7 +249,8 @@
if (!payload || typeof payload !== "object") return payload;
for (const key of ["points", "models", "items"]) if (payload[key]) payload[key] = withModes(payload[key], url);
if (payload.usage) {
- payload.modes = modeUsage(payload.usage, url.searchParams.get("mode"));
+ payload.modes = modeUsage(payload.usage, url.searchParams.get("mode"), payload.fast_share ?? .25);
+ delete payload.fast_share;
payload.usage = addUsage(payload.modes.regular, payload.modes.fast);
if (payload.grand_total != null) payload.grand_total = payload.usage.total;
if (payload.estimate) payload.estimate = demoEstimate(payload.modes, payload.model || (models.some((m) => m.key === payload.key) ? payload.key : url.searchParams.get("model")), url.searchParams.get("cost_basis") === "codex_fast_weighted");
@@ -161,6 +274,12 @@
}
function summary(url) {
+ if (promo) {
+ const { start, end } = promoBounds(url);
+ const { usage, fast_share } = promoUsage(start, end, filterScale(url));
+ return { usage, fast_share, unattributed: zeroUsage(), grand_total: usage.total,
+ event_count: Math.round(usage.total / 128_000), session_count: Math.min(346, Math.max(1, Math.round(usage.total / 6_900_000))), coverage_incomplete: false };
+ }
if (url.searchParams.get("fill_days") === "0" || /T\d{2}:\d{2}$/.test(url.searchParams.get("since") || "")) {
const points = dailyPoints(url);
const usage = points.reduce((sum, point) => addUsage(sum, point.usage), zeroUsage());
@@ -182,6 +301,16 @@
}
function dailyPoints(url) {
+ if (promo) {
+ const { start, end } = promoBounds(url);
+ const points = [];
+ for (let date = new Date(start.getFullYear(), start.getMonth(), start.getDate()), index = 0; date < end && index < 120; date.setDate(date.getDate() + 1), index++) {
+ const next = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
+ const { usage, fast_share } = promoUsage(new Date(Math.max(+date, +start)), new Date(Math.min(+next, +end)), filterScale(url));
+ points.push({ date: dateKey(date), time: new Date(date).toISOString(), usage, fast_share, estimate: {} });
+ }
+ return points;
+ }
const { start, end } = requestedBounds(
url,
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29),
@@ -231,6 +360,13 @@
if(url.searchParams.get("complete_hours")==="1" && end>currentHour) end=currentHour;
const points = [];
let index = 0;
+ if (promo) {
+ for (let date = new Date(start); date < end && index < 168; date = new Date(date.getTime() + 60 * 60_000), index++) {
+ const { usage, fast_share } = promoUsage(date, new Date(date.getTime() + 60 * 60_000), filterScale(url));
+ points.push({ date: hourKey(date), time: date.toISOString(), usage, fast_share });
+ }
+ return points;
+ }
for (let date = new Date(start); date < end && index < 168; date = new Date(date.getTime() + 60 * 60_000), index++) {
const seed = Math.floor(date.getTime() / 3_600_000);
const total = seed % 13 === 4 ? 0 : Math.round((38_000 + (Math.sin(seed * .61) + 1.2) * 29_000 + (seed % 17) * 900) * filterScale(url));
@@ -245,15 +381,19 @@
return points;
}
+ const fastShareOf = (points) => {
+ const total = points.reduce((sum, point) => sum + point.usage.total, 0);
+ return total ? points.reduce((sum, point) => sum + point.usage.total * (point.fast_share ?? .25), 0) / total : 0;
+ };
function costEstimate(url) {
const points = dailyPoints(url);
const usage = points.reduce((sum, point) => {
for (const key of Object.keys(point.usage)) sum[key] = (sum[key] || 0) + point.usage[key];
return sum;
}, {});
- const pricedTokens = points.reduce((sum, point) => sum + point.estimate.priced_tokens, 0);
- const unpricedTokens = points.reduce((sum, point) => sum + point.estimate.unpriced_tokens, 0);
- const totalCost = points.reduce((sum, point) => sum + Number(point.estimate.usd), 0);
+ const pricedTokens = points.reduce((sum, point) => sum + (point.estimate.priced_tokens ?? point.usage.total), 0);
+ const unpricedTokens = points.reduce((sum, point) => sum + (point.estimate.unpriced_tokens || 0), 0);
+ const totalCost = points.reduce((sum, point) => sum + Number(point.estimate.usd || 0), 0);
const estimate = {
usd: totalCost.toFixed(9), regular_input_usd: totalCost.toFixed(9), cached_input_usd: "0.000000000", cache_write_input_usd: "0.000000000", output_usd: "0.000000000",
priced_tokens: pricedTokens, unpriced_tokens: unpricedTokens,
@@ -264,6 +404,7 @@
basis: "current_standard_api_text_token_prices", currency: "USD", catalog_as_of: "2026-09-23", bucket: "day", summary: estimate, points,
models: models.map((item) => {
const itemUsage = scaledUsage(usage, item.share);
+ if (promo) return { key: item.key, usage: itemUsage, fast_share: fastShareOf(points), estimate: {} };
const unknown = item.key === "codex-auto-review" && !pricingOverrides[item.key];
return { key: item.key, usage: itemUsage, estimate: { ...estimate, usd: unknown ? "0.000000000" : (totalCost * item.share).toFixed(9), priced_tokens: unknown ? 0 : itemUsage.total, unpriced_tokens: unknown ? itemUsage.total : 0, coverage_ratio: unknown ? 0 : 1, reasons: unknown ? estimate.reasons : [] } };
})
@@ -275,6 +416,11 @@
const source = { model: models, source: sources, agent_type: agents, project: projects, thread: threads }[dimension] || models;
const requested = url.searchParams.get(dimension);
const entries = requested ? source.filter((item) => item.key === requested) : source;
+ if (promo) {
+ const { usage, fast_share } = summary(url);
+ const eventScale = usage.total / 2_380_000_000;
+ return { dimension, items: entries.map((item) => ({ key: item.key, usage: scaledUsage(usage, item.share), fast_share, events: Math.max(1, Math.round(item.events * eventScale)), sessions: Math.max(1, Math.round(item.sessions * Math.min(1, eventScale * 1.6))) })) };
+ }
return { dimension, items: entries.map((item) => ({ key: item.key, usage: scaledUsage(baseUsage, item.share * filterScale(url)), events: item.events, sessions: item.sessions })) };
}
@@ -291,7 +437,10 @@
if (query && ![item.title, item.session_id, item.project_path, item.model, item.source]
.some((value) => String(value || "").toLocaleLowerCase().includes(query))) return false;
return true;
- }).map(({ share, hoursAgo, ...item }) => {
+ }).map(({ share, hoursAgo, tokens, parent, ...item }) => {
+ if (promo) {
+ return { ...item, usage: promoComposition(tokens), fast_share: .12 + (tokens / 1e6 % 7) / 25, estimate: {}, parent_id: parent, last_usage: new Date(now.getTime() - hoursAgo * 3_600_000).toISOString() };
+ }
const usage = scaledUsage(baseUsage, share);
const usd = (usage.total / 1_000_000 * 3.18).toFixed(9);
return {
@@ -317,7 +466,8 @@
};
}
- const demoUpdates = { current_version: "2.7.1-demo", latest_version: "2.7.1", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: false, can_install: false, phase: "idle", download_dir: "C:\\Users\\Demo\\Downloads\\codex-usage", default_download_dir: "C:\\Users\\Demo\\Downloads\\codex-usage", custom_download_dir: "", can_open_download_dir: false };
+ const promoUpdate = promo && params.get("update") === "available";
+ const demoUpdates = promo ? { current_version: "2.7.1", latest_version: promoUpdate ? "2.8.0" : "2.7.1", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: promoUpdate, can_install: true, phase: "idle", download_dir: "~/Downloads/codex-usage", default_download_dir: "~/Downloads/codex-usage", custom_download_dir: "", can_open_download_dir: false } : { current_version: "2.7.1-demo", latest_version: "2.7.1", release_url: "https://github.com/zJay26/codex-usage/releases", auto_check: true, available: false, can_install: false, phase: "idle", download_dir: "C:\\Users\\Demo\\Downloads\\codex-usage", default_download_dir: "C:\\Users\\Demo\\Downloads\\codex-usage", custom_download_dir: "", can_open_download_dir: false };
async function syntheticFetch(input, init = {}) {
const raw = typeof input === "string" ? input : input.url;
const url = new URL(raw, root.location.href);
@@ -336,10 +486,10 @@
if (endpoint === "/api/v1/status") return jsonResponse({
version: "2.7.1-demo", scanning: false,
status: {
- machine: { id: "synthetic-machine", label: "Synthetic Windows · demo", hostname: "synthetic-host", os: "windows", arch: "amd64" },
+ machine: promo ? { id: "workstation", label: "Workstation · Windows 11", hostname: "workstation", os: "windows", arch: "amd64" } : { id: "synthetic-machine", label: "Synthetic Windows · demo", hostname: "synthetic-host", os: "windows", arch: "amd64" },
last_scan: now.toISOString(), accounting_mode: "jsonl_only", otel_active: false,
- event_count: 114, session_count: 26, warning_count: diagnostics ? 2 : 0, data_revision: "demo-1",
- codex_homes: [{ path: "synthetic://codex-home", last_scan: now.toISOString(), files_scanned: 26 }]
+ event_count: promo ? 18_462 : 114, session_count: promo ? 346 : 26, warning_count: diagnostics ? 2 : 0, data_revision: "demo-1",
+ codex_homes: [{ path: promo ? "~/.codex" : "synthetic://codex-home", last_scan: now.toISOString(), files_scanned: promo ? 346 : 26 }]
}
});
if (endpoint === "/api/v1/summary") return jsonResponse(withModes(summary(url),url));
@@ -356,6 +506,16 @@
sources: sources.map((item) => item.key),
projects: projects.map((item) => item.key)
});
+ if (endpoint === "/api/v1/session-tree" && promo) {
+ const payload = withModes(sessionPayload(url), url);
+ const children = (id) => payload.items.filter((item) => item.parent_id === id);
+ const items = payload.items.filter((item) => !item.parent_id).flatMap((root) => {
+ const kids = children(root.session_id);
+ const subtree_usage = kids.reduce((sum, kid) => addUsage(sum, kid.usage), root.usage);
+ return [{ ...root, depth: 0, children: kids.length, subtree_usage }, ...kids.map((kid) => ({ ...kid, depth: 1, children: 0, subtree_usage: { ...kid.usage } }))];
+ });
+ return jsonResponse({ items, root_count: items.filter((item) => !item.depth).length, offset: 0, limit: 20 });
+ }
if (endpoint === "/api/v1/session-tree") {
const payload=withModes(sessionPayload(url),url);
const items=payload.items.map((item,index)=>({...item,depth:index%3===0?0:1,children:index%3===0?Math.min(2,payload.items.length-index-1):0,parent_id:index%3===0?"":payload.items[index-index%3].session_id,subtree_usage:{...item.usage}}));
diff --git a/scripts/media-lib.mjs b/scripts/media-lib.mjs
index 6238f3f..27cfe9f 100644
--- a/scripts/media-lib.mjs
+++ b/scripts/media-lib.mjs
@@ -54,15 +54,15 @@ export async function startDemoServer() {
return { baseURL, stop };
}
-export async function openDemo(context, baseURL, locale) {
+export async function openDemo(context, baseURL, locale, { query = "", time = fixedTime } = {}) {
const page = await context.newPage();
- await page.clock.setFixedTime(fixedTime);
+ await page.clock.setFixedTime(time);
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 page.goto(`${baseURL}?lang=${locale}${query}`, { waitUntil: "networkidle" });
await expect(page.locator("#overviewTotal")).not.toHaveText("—");
await expect(page.locator("#overviewCoverage")).toContainText("100");
await expect(page.locator("#coverageBanner")).toBeHidden();
diff --git a/scripts/promo/assets.mjs b/scripts/promo/assets.mjs
index 90671c1..aac95c9 100644
--- a/scripts/promo/assets.mjs
+++ b/scripts/promo/assets.mjs
@@ -2,134 +2,251 @@ 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";
+import { assertSynthetic, fixedTime, openDemo } from "../media-lib.mjs";
const scale = 2;
+const scenario = "&scenario=promo";
// 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}";
+const hideStyle = selectors => `${selectors}{color:transparent!important;-webkit-text-fill-color:transparent!important}`;
+const searchTerms = { "zh-CN": ["支", "支付"], en: ["pay", "payment"] };
+const rangeStart = "2026-09-18T10:00";
-// Captures crisp Dashboard stills plus the on-screen rectangles the promo camera zooms into.
+// Captures crisp Dashboard stills for every beat of the promo, plus the on-screen
+// rectangles the camera, spotlight, cursor and labels are anchored to.
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 contextOptions = { viewport, deviceScaleFactor: scale, colorScheme: "light", timezoneId: "Asia/Shanghai", reducedMotion: "reduce" };
const shots = {};
-
- async function rect(selector) {
- const box = await page.locator(selector).first().evaluate(node => {
+ const errors = [];
+ const warnings = [];
+
+ async function open(options = {}) {
+ const context = await browser.newContext(contextOptions);
+ const { page, errors: pageErrors } = await openDemo(context, baseURL, locale, { query: scenario + (options.query || ""), time: options.time });
+ await page.addStyleTag({ content: stillStyle });
+ await page.locator('[data-overview-range="30d"]').click();
+ await settle(page);
+ return {
+ page,
+ async close() {
+ warnings.push(...await page.evaluate(() => [...new Set(window.mediaWarnings)]));
+ errors.push(...pageErrors);
+ await context.close();
+ }
+ };
+ }
+ async function settle(page, ms = 450) {
+ await page.waitForLoadState("networkidle");
+ await delay(ms);
+ }
+ async function rect(page, target) {
+ const locator = typeof target === "string" ? page.locator(target).first() : target;
+ const box = await locator.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) {
+ async function scrollTo(page, selector, top = 90) {
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) {
+ async function shot(page, name, focus = {}, extra = {}) {
await page.mouse.move(viewport.width - 4, viewport.height - 4);
- await delay(350);
+ await delay(300);
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 };
+ const rects = {};
+ for (const [key, target] of Object.entries(focus)) rects[key] = await rect(page, target);
+ shots[name] = { file, width: viewport.width, height: viewport.height, focus: rects, ...extra };
+ }
+ // The typography of a value, so the promo can count it up in place.
+ async function textStyle(page, selector) {
+ return page.locator(selector).evaluate(node => {
+ const style = getComputedStyle(node);
+ const r = node.getBoundingClientRect();
+ return {
+ text: node.textContent.trim(),
+ x: Math.round(r.x + parseFloat(style.paddingLeft)), y: Math.round(r.y + parseFloat(style.paddingTop)),
+ font: { fontFamily: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, letterSpacing: style.letterSpacing, lineHeight: style.lineHeight, color: style.color, fontVariantNumeric: style.fontVariantNumeric, fontFeatureSettings: style.fontFeatureSettings }
+ };
+ });
}
- 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();
+ // Overview: totals and cost, then the 30-day daily bars.
+ {
+ const { page, close } = await open();
+ const heroFocus = { hero: ".hero-metrics", totalCard: ".token-metric", costCard: ".cost-metric", total: "#overviewTotal", cost: "#overviewCost" };
+ const counters = { total: await textStyle(page, "#overviewTotal"), cost: await textStyle(page, "#overviewCost") };
+ await shot(page, "overview", heroFocus, { counters });
+ const blank = await page.addStyleTag({ content: hideStyle("#overviewTotal,#overviewCost") });
+ await shot(page, "overviewBlank", heroFocus);
+ await blank.evaluate(node => node.remove());
+
+ await scrollTo(page, "#usageTrendPanel", 80);
+ const dailyFocus = { panel: "#usageTrendPanel", belt: "#pulseScroll", inspector: "#pulseInspector", peak: '[data-pulse-date="2026-09-17"]' };
+ const bars = await page.addStyleTag({ content: ".pulse-bar,.pulse-fast-bar{visibility:hidden!important}" });
+ await shot(page, "dailyBlank", dailyFocus);
+ await bars.evaluate(node => node.remove());
+ await shot(page, "daily", dailyFocus);
+ await page.locator('[data-pulse-date="2026-09-17"]').click();
+ await settle(page);
+ await shot(page, "dailyPeak", dailyFocus);
+
+ // Hourly drill-down on the busiest hour of the day.
+ await page.locator("#trendHourlyTab").click();
+ await settle(page);
+ const hourlyFocus = { panel: "#usageTrendPanel", chart: "#hourlyChart", ledger: "#hourlyLedger" };
+ const peakIndex = await page.locator("#hourlyPoints [data-hour-point]").evaluateAll(nodes => {
+ let best = 0;
+ nodes.forEach((node, index) => { if (node.getBoundingClientRect().y < nodes[best].getBoundingClientRect().y) best = index; });
+ return best;
+ });
+ const peakPoint = page.locator(`#hourlyPoints [data-hour-point="${peakIndex}"]`);
+ await shot(page, "hourly", { ...hourlyFocus, peak: peakPoint });
+ await peakPoint.click();
+ await settle(page);
+ await shot(page, "hourlyPeak", { ...hourlyFocus, peak: peakPoint });
+
+ // Minute-precision range: since the last reset, until now.
+ await page.locator("#trendDailyTab").click();
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await page.locator('[data-overview-range="custom"]').click();
+ await settle(page);
+ const rangeFocus = { form: "#customRangeForm", start: "#rangeStart", end: "#rangeEnd", now: "#rangeNow", query: "#queryRange", hero: ".hero-metrics", total: "#overviewTotal" };
+ await shot(page, "range0", rangeFocus);
+ await page.locator("#rangeStart").fill(rangeStart);
+ await shot(page, "range1", rangeFocus);
+ await page.locator("#rangeNow").click();
+ await shot(page, "range2", rangeFocus);
+ await page.locator("#queryRange").click();
+ await expect(page.locator("#overviewExactTotal")).toBeVisible();
+ await settle(page);
+ await shot(page, "range3", { ...rangeFocus, exact: "#overviewExactTotal", totalCard: ".token-metric" });
+
+ // Calendar with the Standard / Fast split of one day.
+ await page.locator("#tab-daily").click();
+ await settle(page);
+ const dayCard = await page.locator("#dayTotal").evaluateHandle(node => node.closest(".surface") || node.parentElement.parentElement);
+ const calendarFocus = { grid: "#calendarGrid", cell: '[data-calendar-date="2026-09-17"]', dayCard, dayTotal: "#dayTotal", dayModes: "#dayModes", composition: "#dayComposition" };
+ await shot(page, "calendar", calendarFocus);
+ await page.locator('[data-calendar-date="2026-09-17"]').click();
+ await expect(page.locator("#dayTotal")).not.toHaveText("0");
+ await settle(page);
+ await shot(page, "calendarPeak", calendarFocus);
+
+ // Details: by model, then by project.
+ await page.locator("#tab-details").click();
+ await page.locator('[data-dimension="model"]').click();
+ await settle(page);
+ await shot(page, "models", { panel: "#breakdownPanel", list: "#detailBreakdown", projectTab: '[data-dimension="project"]' });
+ await page.locator('[data-dimension="project"]').click();
+ await settle(page);
+ await shot(page, "projects", { panel: "#breakdownPanel", list: "#detailBreakdown", projectTab: '[data-dimension="project"]' });
+
+ // Session list and search.
+ await scrollTo(page, "#sessionsPanel", 70);
+ const sessionFocus = { panel: "#sessionsPanel", search: "#sessionSearch", rows: "#sessionRows" };
+ await shot(page, "sessions", sessionFocus);
+ const terms = searchTerms[locale] || searchTerms.en;
+ for (const [index, term] of terms.entries()) {
+ await page.locator("#sessionSearch").fill(term);
+ await delay(400);
+ await settle(page);
+ await shot(page, `search${index + 1}`, sessionFocus);
+ }
+ await page.locator("#sessionSearchClear").click();
+ await settle(page);
+
+ // Task tree: collapsed, then expanded to show the subagents.
+ await page.locator('[data-session-view="tree"]').click();
+ await settle(page);
+ await scrollTo(page, "#sessionsPanel", 70);
+ const toggle = page.locator("[data-tree-toggle]").first();
+ await expect(toggle).toBeVisible();
+ // Re-rendering replaces rows, so the row is located afresh for each still.
+ const row = '#sessionRows .session-row:has([data-tree-toggle]) .task-name';
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await settle(page, 300);
+ await shot(page, "tree0", { panel: "#sessionsPanel", rows: "#sessionRows", toggle, row });
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-expanded", "true");
+ await settle(page, 300);
+ const children = await page.locator("#sessionRows").evaluateHandle(rows => {
+ const all = [...rows.children];
+ const first = all.findIndex(node => node.querySelector("[data-tree-toggle]"));
+ const next = all.findIndex((node, index) => index > first && node.querySelector("[data-tree-toggle]"));
+ const wrap = document.createElement("div");
+ const top = all[first].getBoundingClientRect().top, bottom = all[(next > 0 ? next : all.length) - 1].getBoundingClientRect().bottom;
+ const r = rows.getBoundingClientRect();
+ Object.assign(wrap.style, { position: "fixed", left: `${r.left}px`, top: `${top}px`, width: `${r.width}px`, height: `${bottom - top}px`, pointerEvents: "none" });
+ document.body.append(wrap);
+ return wrap;
+ });
+ await shot(page, "tree1", { panel: "#sessionsPanel", rows: "#sessionRows", toggle, row, group: children, subtotal: ".tree-subtotal" });
+ await children.evaluate(node => node.remove());
+
+ // Export dialog.
+ await page.locator("#exportButton").click();
+ await expect(page.locator("#exportJson")).toHaveAttribute("href", /^data:application\/json/);
+ await delay(300);
+ await shot(page, "export", { dialog: "#exportDialog", json: "#exportJson", csv: "#exportCsv" });
+ await page.locator("#exportDialog [data-close]").click();
+
+ // Light theme, then dark.
+ await page.locator("#tab-overview").click();
+ await page.locator('[data-overview-range="30d"]').click();
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await settle(page);
+ await shot(page, "light", { hero: ".hero-metrics", theme: "#themeButton", locale: "#localeButton" });
+ await page.locator("#themeButton").click();
+ await settle(page);
+ await shot(page, "dark", { hero: ".hero-metrics", theme: "#themeButton", locale: "#localeButton" });
+ await page.locator("#themeButton").click();
+ await close();
+ }
+
+ // Usage keeps counting on its own: the same view a minute and two minutes later.
+ for (const [index, minutes] of [0, 1, 2].entries()) {
+ const { page, close } = await open({ time: new Date(+fixedTime + minutes * 60_000) });
+ await page.locator('[data-overview-range="today"]').click();
+ await settle(page);
+ await shot(page, `live${index}`, { hero: ".hero-metrics", totalCard: ".token-metric", total: "#overviewTotal" });
+ await close();
+ }
+
+ // Software updates: the banner, then the dialog.
+ {
+ const { page, close } = await open({ query: "&update=available" });
+ await expect(page.locator("#updateBanner")).toBeVisible();
+ await shot(page, "update0", { banner: "#updateBanner", review: "#reviewUpdate" });
+ await page.locator("#reviewUpdate").click();
+ await expect(page.locator("#updateDialog")).toBeVisible();
+ await delay(300);
+ await shot(page, "update1", { dialog: "#updateDialog", versions: ".update-versions", status: "#updateStatus", install: "#installUpdate" });
+ await close();
+ }
+
+ {
+ const context = await browser.newContext({ ...contextOptions, viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true });
+ const mobile = await openDemo(context, baseURL, locale, { query: scenario });
+ await mobile.page.addStyleTag({ content: stillStyle });
+ await mobile.page.locator('[data-overview-range="30d"]').click();
+ await settle(mobile.page);
+ 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 context.close();
+ }
if (warnings.length || errors.length) throw new Error(JSON.stringify({ locale, warnings, errors }));
- const manifest = { locale, viewport, scale, values, shots };
+ const manifest = { locale, viewport, scale, 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
index 6dfe5b2..48e47d4 100644
--- a/scripts/promo/music.mjs
+++ b/scripts/promo/music.mjs
@@ -31,7 +31,9 @@ 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;
+ // The groove runs through the feature tour: from the first feature until privacy.
+ const features = timeline.scenes.slice(2, timeline.scenes.findIndex(s => s.id === "privacy"));
+ const grooveStart = features[0].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)];
@@ -96,7 +98,7 @@ export async function renderMusic(timeline, output, workDir) {
// 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) {
+ for (let s = features[1].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);
}
@@ -115,7 +117,7 @@ export async function renderMusic(timeline, output, workDir) {
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));
+ [72, 76, 79, 83].forEach((note, i) => bell(scene.brand.start + .1 + i * .07, 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.
@@ -140,7 +142,7 @@ export async function renderMusic(timeline, output, workDir) {
}, pre + post, 0, .6);
};
whoosh(scene.brand.start, 2.0, .35, .12, 11);
- cuts.forEach((cut, i) => whoosh(cut, .45, .3, .07, 20 + i));
+ cuts.forEach((cut, i) => whoosh(cut, .45, .3, .05, 20 + i));
whoosh(end, 1.4, .4, .09, 99);
// Send bus: ping-pong delay followed by a light comb reverb.
diff --git a/scripts/promo/promo.css b/scripts/promo/promo.css
index 959d87c..79759c6 100644
--- a/scripts/promo/promo.css
+++ b/scripts/promo/promo.css
@@ -9,52 +9,57 @@
*, *::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; }
+h1, h2, 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; }
+.bg .grid { position: absolute; inset: -40px; background-image: radial-gradient(circle, #9fb0ab 1.1px, transparent 1.6px); background-size: 34px 34px; opacity: .3; }
.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-a { background: radial-gradient(circle, rgba(63,179,162,.2), 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; }
+.icon-slot svg { width: 100%; height: 100%; display: block; overflow: visible; }
/* 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; }
+.hook-bars { position: absolute; left: 0; right: 0; bottom: 0; height: 44%; display: flex; align-items: flex-end; gap: 8px; padding: 0 8px; }
+.hook-bars i { flex: 1; height: 100%; border-radius: 6px 6px 0 0; background: linear-gradient(#3fb3a2, rgba(63,179,162,0)); opacity: .2; transform-origin: bottom; }
+.hook-copy { position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-60%); display: grid; justify-items: center; text-align: center; }
+.hook-title { font: 780 92px/1.12 var(--display); letter-spacing: -.035em; }
+.hook-title .line:first-child { color: var(--copper-bright); }
+.hook-gauges { margin-top: 54px; display: flex; gap: 22px; align-items: stretch; }
+.hook-counter, .hook-meter { display: flex; align-items: center; gap: 18px; padding: 20px 30px; border: 1px solid rgba(169,187,183,.28); border-radius: 18px; background: rgba(255,255,255,.05); }
+.hook-counter span, .hook-meter span { font: 700 22px/1 var(--mono); color: var(--mist); letter-spacing: .1em; }
+.hook-counter b { font: 700 54px/1 var(--mono); color: #eef5f3; font-variant-numeric: tabular-nums; min-width: 9.6ch; text-align: right; }
+.hook-meter i { position: relative; display: block; width: 260px; height: 16px; border-radius: 8px; background: rgba(255,255,255,.1); overflow: hidden; }
+.hook-meter i b { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 8px; }
+.hook-meter em { font: 700 30px/1 var(--mono); font-style: normal; font-variant-numeric: tabular-nums; min-width: 3.6ch; text-align: right; }
/* 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; }
+.byline { margin-top: 12px; text-align: right; font: 600 30px/1 var(--text); color: var(--mist); }
+.brand-tagline { font: 700 50px/1.2 var(--display); color: #dcebe7; 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; }
+#corner .icon-slot { width: 42px; height: 42px; }
+#badge { inset: auto; font: 600 18px/1 var(--mono); letter-spacing: .04em; color: var(--muted); text-align: right; }
-/* Copy column */
+/* Copy: one headline per beat, read like a subtitle */
.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; }
+.copy-block .kicker { display: flex; align-items: center; gap: 14px; font: 700 22px/1 var(--mono); letter-spacing: .14em; color: var(--teal); text-transform: uppercase; }
+.copy-block .kicker b { padding: 7px 11px; border-radius: 8px; background: var(--teal); color: #fff; letter-spacing: .06em; }
+.copy-block h2 { margin-top: 16px; font: 780 60px/1.14 var(--display); letter-spacing: -.03em; }
+.copy-block h2 .line, #privacy h1 .line, .cta-title { text-wrap: balance; }
+html:lang(en) .landscape .copy-block h2, .landscape:lang(en) .copy-block h2 { font-size: 54px; }
+.copy-block .note { margin-top: 12px; font: 550 27px/1.35 var(--text); color: var(--muted); }
/* 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%; }
@@ -64,13 +69,20 @@ ul { list-style: none; }
.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; }
+#content img { position: absolute; left: 0; top: 0; }
+.counter { position: absolute; white-space: nowrap; z-index: 5; }
+#marks { position: absolute; inset: 0; pointer-events: none; }
+#spot { position: absolute; border: 3px solid var(--copper-bright); border-radius: 14px; box-shadow: 0 0 0 4000px rgba(12,21,23,.46), 0 0 36px rgba(227,154,82,.45); }
+.label { position: absolute; left: 0; top: 0; padding: 10px 16px; border-radius: 11px; background: var(--copper-bright); color: #1d1206; font: 750 24px/1 var(--text); white-space: nowrap; box-shadow: 0 12px 28px -10px rgba(0,0,0,.45); }
+.label::after { content: ""; position: absolute; left: 22px; width: 14px; height: 14px; background: inherit; transform: rotate(45deg); }
+.label.top::after { bottom: -6px; }
+.label.bottom::after { top: -6px; }
+.label.left::after { left: auto; right: -6px; top: calc(50% - 7px); }
+.label .dot { display: inline-block; width: 12px; height: 12px; margin-right: 10px; border-radius: 50%; background: #1d7a4e; vertical-align: 1px; }
+#cursor { position: absolute; left: 0; top: 0; width: 34px; height: 42px; transform-origin: 3px 2px; filter: drop-shadow(0 3px 4px rgba(0,0,0,.35)); }
+#cursor svg { width: 100%; height: 100%; }
+#cursor path { fill: #172226; stroke: #fff; stroke-width: 1.8; stroke-linejoin: round; }
+#ripple { position: absolute; border-radius: 50%; border: 3px solid var(--copper-bright); background: rgba(227,154,82,.18); }
/* 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; }
@@ -82,62 +94,59 @@ ul { list-style: none; }
.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 { 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: 40px; }
.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 rect { fill: var(--teal-bright); }
.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; }
+.lock path { stroke: var(--teal-bright); stroke-width: 4.5; stroke-linecap: round; }
+#privacy h1 { font: 780 84px/1.14 var(--display); letter-spacing: -.035em; }
+.privacy-chips { display: flex; gap: 18px; margin-top: 56px; justify-content: center; }
+.privacy-chips li { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-radius: 16px; background: rgba(255,255,255,.06); border: 1px solid rgba(169,187,183,.26); font: 650 30px/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; }
+.cta-icon { width: 140px; height: 140px; }
+#cta h1 { font: 780 96px/1 var(--display); letter-spacing: -.045em; }
+.cta-tagline { margin-top: 12px; font: 600 30px/1.2 var(--display); color: #bcd3ce; }
+.cta-title { margin-top: 56px; font: 780 64px/1.15 var(--display); letter-spacing: -.03em; color: var(--copper-bright); }
+.terminal { margin-top: 40px; 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 { margin-top: 32px; display: flex; gap: 24px; }
.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); }
+.cta-links b { font: 750 22px/1 var(--text); color: var(--copper-bright); }
-#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 { 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,.9); color: #f3f7f5; font: 700 28px/1 var(--text); box-shadow: 0 20px 50px -12px rgba(14,22,24,.5); z-index: 50; }
#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 .hook-title { font-size: 84px; }
+.vertical .hook-gauges { flex-direction: column; align-items: center; }
+.vertical .hook-counter b { font-size: 50px; }
.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 .brand-tagline { max-width: 900px; text-align: center; font-size: 54px; }
+.vertical .copy-block h2 { font-size: 70px; }
+.vertical .copy-block .note { font-size: 30px; }
+.vertical .copy-block .kicker { font-size: 24px; }
+.vertical .label { font-size: 28px; padding: 12px 18px; }
+.vertical #privacy h1 { font-size: 96px; }
+.vertical .privacy-chips { flex-direction: column; align-items: center; }
+.vertical .privacy-chips li { font-size: 36px; }
+.vertical .cta-lockup { flex-direction: column; text-align: center; gap: 36px; }
+.vertical .cta-icon { width: 190px; height: 190px; }
+.vertical #cta h1 { font-size: 112px; }
+.vertical .cta-tagline { font-size: 38px; }
+.vertical .cta-title { font-size: 76px; 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; }
+.vertical #corner { font-size: 30px; }
+.vertical #corner .icon-slot { width: 52px; height: 52px; }
+.vertical #badge { font-size: 22px; text-align: left; }
diff --git a/scripts/promo/promo.html b/scripts/promo/promo.html
index 491c945..c98c156 100644
--- a/scripts/promo/promo.html
+++ b/scripts/promo/promo.html
@@ -12,9 +12,11 @@
@@ -30,23 +32,24 @@ Codex Usage
+
-
-
@@ -60,15 +63,14 @@ Codex Usage
+
$
- GitHubgithub.com/zJay26/codex-usage
- zjay26.github.io/codex-usage
-
-
diff --git a/scripts/promo/promo.js b/scripts/promo/promo.js
index ba67ebd..9e0b4c4 100644
--- a/scripts/promo/promo.js
+++ b/scripts/promo/promo.js
@@ -11,41 +11,60 @@
const dims = { w: shots.overview.width, h: shots.overview.height };
const copy = zh ? {
- hook: { kicker: "每天都在用 Codex", title: ["Token 一路上涨,", "到底花在了哪?"], unit: "TOKENS" },
+ hook: { title: ["Codex 额度猛掉,", "Token 到底花在哪了?"], unit: "TOKENS", credits: "额度" },
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 · 单文件安装" },
+ blocks: {
+ total: ["总览", ["用了多少、花了多少,一眼看清"], "费用按公开 API 单价估算,并非真实账单", ["用了多少、花了多少,", "一眼看清"]],
+ daily: ["每日用量", ["每天用了多少 Token 一眼看清"], null, ["每天用了多少 Token", "一眼看清"]],
+ hourly: ["小时", ["按小时统计,找到用量高峰"], null, ["按小时统计,", "找到用量高峰"]],
+ range: ["时间区间", ["查询任意时间区间,", "看看上次重置到现在用了多少 Token"], null, ["查询任意时间区间,", "看看上次重置到现在", "用了多少 Token"]],
+ calendar: ["月历", ["每天的常规与 Fast,分开算清"], "Fast 费用按额度倍率折算", ["每天的常规与 Fast,", "分开算清"]],
+ breakdown: ["明细", ["哪个模型、哪个项目最费 Token?"], null, ["哪个模型、哪个项目", "最费 Token?"]],
+ sessions: ["Session", ["每次会话都有记录,一搜就找到"], null, ["每次会话都有记录,", "一搜就找到"]],
+ tree: ["任务树", ["主任务和 Subagent,用量分开算"], "含子任务的合计也能一眼看到", ["主任务和 Subagent,", "用量分开算"]],
+ export: ["导出", ["一键导出 JSON / CSV"]],
+ live: ["自动统计", ["用量自动统计,随时最新"]],
+ update: ["软件更新", ["新版本提醒,一键升级"]],
+ interface: ["界面", ["明暗、中英、手机,都顺手"]]
+ },
+ labels: { total: "总 Token", cost: "API 等价成本", peakDay: "用量最高的一天", peakHour: "高峰时段", lastReset: "上次重置", now: "现在", sinceReset: "上次重置至今", split: "常规 / Fast", byModel: "按模型", byProject: "按项目", search: "搜索", matches: "匹配的会话", mainTask: "主任务", subtotal: "含子任务合计", formats: "JSON / CSV", live: "自动更新", newVersion: "发现新版本" },
+ privacy: { title: ["本地运行,", "不上传用量和对话"], chips: ["开源 · MIT", "Windows · Linux · macOS", "单文件"] },
+ cta: { title: "免费开源,一条命令安装", demo: "在线试用" },
badge: "演示画面使用合成数据",
- play: "观看 30 秒宣传片"
+ play: "观看宣传片"
} : {
- 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" },
+ hook: { title: ["Codex credits draining fast?", "Where are all those tokens going?"], unit: "TOKENS", credits: "CREDITS" },
+ brand: { tagline: "Your Codex usage, crystal clear." },
+ blocks: {
+ total: ["Overview", ["Your usage and its API cost, at a glance."], "Estimated at public API rates, not your actual bill."],
+ daily: ["Daily", ["Daily token usage, at a glance."]],
+ hourly: ["Hourly", ["Track usage by the hour and find your peaks."]],
+ range: ["Custom range", ["Query any time range —", "like everything since your last reset."]],
+ calendar: ["Calendar", ["Standard vs. Fast, split for every day."], "Fast costs include the credit multiplier."],
+ breakdown: ["Breakdown", ["Which models and projects burn the most?"]],
+ sessions: ["Sessions", ["Every session on record. Find any one in seconds."]],
+ tree: ["Task tree", ["Main tasks and subagents, tallied separately."], "Subtree totals at a glance."],
+ export: ["Export", ["Export to JSON or CSV in one click."]],
+ live: ["Live", ["Usage stays up to date, automatically."]],
+ update: ["Updates", ["New version? Update in one click."]],
+ interface: ["Interface", ["Light or dark, English or Chinese, desktop or phone."]]
+ },
+ labels: { total: "Total tokens", cost: "API-equivalent cost", peakDay: "Busiest day", peakHour: "Peak hour", lastReset: "Last reset", now: "Now", sinceReset: "Since last reset", split: "Standard / Fast", byModel: "By model", byProject: "By project", search: "Search", matches: "Matching sessions", mainTask: "Main task", subtotal: "Subtree total", formats: "JSON / CSV", live: "Live", newVersion: "Update available" },
+ privacy: { title: ["Runs locally.", "Your usage and chats", "never leave your machine."], chips: ["Open source · MIT", "Windows · Linux · macOS", "Single binary"] },
+ cta: { title: "Free and open source. Install with one command.", demo: "Live demo" },
badge: "Synthetic demo data",
- play: "Watch the 30s film"
+ play: "Watch the promo"
};
// ---- 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 prog = (t, a, b) => b <= a ? (t >= a ? 1 : 0) : 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
+ in: p => p ** 2.2
};
const union = (...rects) => {
const x = Math.min(...rects.map(r => r.x)), y = Math.min(...rects.map(r => r.y));
@@ -53,8 +72,7 @@
};
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 env = (t, a, b, inDur = .5, outDur = .3) => {
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) };
@@ -65,83 +83,54 @@
// ---- 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: 72, y: 168, w: 936 },
+ panel: { x: 60, y: 580, w: 960, h: 1206 },
+ phone: { x: 640, y: 1060, h: 700 },
+ corner: { x: 72, y: 70 },
+ badge: { x: 72, y: 1852 }
} : {
- 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 }
+ copy: { x: 160, y: 58, w: 1280 },
+ panel: { x: 160, y: 272, w: 1600, h: 740 },
+ phone: { x: 1480, y: 330, h: 690 },
+ corner: { x: 1510, y: 60 },
+ badge: { x: 1400, y: 118 }
};
const chromeH = 46;
- const view = { w: L.panel.w, h: Math.round(L.panel.w * dims.h / dims.w) };
+ const view = { w: L.panel.w, h: L.panel.h - chromeH };
+ const coverScale = Math.max(view.w / dims.w, view.h / dims.h);
// ---- 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";
-
+ set(stage, { width: `${W}px`, height: `${H}px` });
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");
+ const mask = document.createElementNS("http://www.w3.org/2000/svg", "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)");
+ [...brandSvg.querySelectorAll(":scope > path")].at(-1).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");
+ const corner = $("#corner"), badge = $("#badge");
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` });
+ set(badge, { left: `${L.badge.x}px`, top: `${L.badge.y}px`, width: vertical ? "auto" : "360px" });
- // 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(panel, { left: `${L.panel.x}px`, top: `${L.panel.y}px`, width: `${view.w}px`, height: `${L.panel.h}px` });
set($(".viewport"), { width: `${view.w}px`, height: `${view.h}px` });
const content = $("#content");
set(content, { width: `${dims.w}px`, height: `${dims.h}px` });
@@ -150,298 +139,475 @@
const img = el("img");
img.src = src;
img.alt = "";
- set(img, { width: `${dims.w}px`, height: `${dims.h}px` });
+ set(img, { width: `${dims.w}px`, height: `${dims.h}px`, visibility: "hidden" });
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]
};
+ for (const [name, shot] of Object.entries(shots)) if (name !== "mobile") addLayer(name, P.assets.base + shot.file);
+ addLayer("otherDark", P.other.base + P.other.shots.dark.file);
+ const reveal = $("#reveal");
+ set(reveal, { width: `${dims.w}px`, height: `${dims.h}px`, zIndex: 4 });
- // 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 };
- });
+ // ---- beat sheet ---------------------------------------------------------------
+ // Each feature is a short sequence: the camera settles on one area, the rest
+ // dims, a label names what matters, and a cursor performs the action.
+ const R = (still, key) => shots[still].focus[key];
+ const K = copy.labels;
+ const beats = { copy: [], layers: [], cams: [], spots: [], labels: [], cursor: [], counters: [], reveals: [] };
+ const S = id => scene[id].start;
+ const block = (id, number, a, b) => beats.copy.push({ a, b, number, data: copy.blocks[id] });
+ const layer = (t, name, fade = .35, kind = "fade") => beats.layers.push({ t, name, fade, kind });
+ const cam = (t, dur, rect) => beats.cams.push({ t, dur, rect });
+ const spot = (t, dur, rect) => beats.spots.push({ t, dur, rect });
+ const label = (a, b, rect, text, extra = {}) => beats.labels.push({ a, b, rect, text, ...extra });
+ const click = (t, rect) => beats.cursor.push({ t, rect });
- // 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)` });
+ { // 01 totals and cost, counted up in place
+ const t = S("total");
+ block("total", "01", t + .15, scene.total.end - .3);
+ layer(t, "overviewBlank", 0);
+ const hero = R("overview", "hero");
+ cam(t, 0, { x: hero.x - 48, y: hero.y - 130, w: hero.w + 96, h: hero.h + 178 });
+ beats.counters.push({ a: t + .7, b: t + 2.1, off: t + 2.2, key: "total" }, { a: t + .8, b: t + 2.2, off: t + 2.2, key: "cost" });
+ layer(t + 2.2, "overview", 0);
+ spot(t + 2.3, .4, pad(R("overview", "totalCard"), 6));
+ label(t + 2.4, scene.total.end - .2, R("overview", "total"), K.total);
+ spot(t + 3.05, .45, pad(R("overview", "costCard"), 6));
+ label(t + 3.15, scene.total.end - .2, R("overview", "cost"), K.cost);
}
-
- 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%" });
- });
+ { // 02 daily bars for the last 30 days
+ const t = S("daily");
+ block("daily", "02", t + .1, scene.daily.end - .3);
+ spot(t, .3, null);
+ layer(t, "dailyBlank", .4);
+ cam(t, .8, pad(R("daily", "panel"), 14));
+ beats.reveals.push({ a: t + .55, b: t + 1.6, rect: pad(R("daily", "belt"), 4), name: "daily" });
+ layer(t + 1.6, "daily", 0);
+ click(t + 2.1, R("daily", "peak"));
+ layer(t + 2.15, "dailyPeak", .2);
+ spot(t + 2.2, .35, pad(R("dailyPeak", "peak"), 6));
+ label(t + 2.3, scene.daily.end - .2, R("dailyPeak", "peak"), K.peakDay);
+ }
+ { // 03 hourly line, click the busiest hour
+ const t = S("hourly");
+ block("hourly", "03", t + .1, scene.hourly.end - .3);
+ spot(t, .3, null);
+ layer(t, "hourly", .4);
+ cam(t, .8, pad(R("hourly", "panel"), 14));
+ click(t + 1.35, R("hourly", "peak"));
+ layer(t + 1.4, "hourlyPeak", .25);
+ spot(t + 1.6, .45, pad(R("hourlyPeak", "ledger"), 8));
+ label(t + 1.75, scene.hourly.end - .2, R("hourlyPeak", "ledger"), K.peakHour);
+ }
+ { // 04 minute-precision range: since the last reset, until now
+ const t = S("range");
+ block("range", "04", t + .1, scene.range.end - .3);
+ spot(t, .3, null);
+ layer(t, "range0", .4);
+ cam(t, .8, pad(union(R("range0", "form"), R("range3", "hero")), 20));
+ click(t + 1.0, R("range0", "start"));
+ layer(t + 1.05, "range1", .15);
+ spot(t + 1.0, .3, pad(R("range1", "start"), 6));
+ label(t + 1.1, scene.range.end - .2, R("range1", "start"), K.lastReset);
+ click(t + 1.8, R("range1", "now"));
+ layer(t + 1.85, "range2", .15);
+ spot(t + 1.8, .3, pad(union(R("range2", "end"), R("range2", "now")), 6));
+ label(t + 1.9, scene.range.end - .2, R("range2", "end"), K.now);
+ click(t + 2.45, R("range2", "query"));
+ layer(t + 2.5, "range3", .25);
+ spot(t + 2.75, .45, pad(R("range3", "totalCard"), 6));
+ label(t + 2.9, scene.range.end - .2, R("range3", "total"), K.sinceReset, { place: "bottom" });
}
+ { // 05 calendar and the Standard / Fast split of one day
+ const t = S("calendar");
+ block("calendar", "05", t + .1, scene.calendar.end - .3);
+ spot(t, .3, null);
+ layer(t, "calendar", .4);
+ cam(t, .8, pad(union(R("calendar", "grid"), R("calendar", "dayCard")), 14));
+ click(t + 1.3, R("calendar", "cell"));
+ layer(t + 1.35, "calendarPeak", .25);
+ spot(t + 1.6, .45, pad(union(R("calendarPeak", "dayTotal"), R("calendarPeak", "dayModes"), R("calendarPeak", "composition")), 10));
+ label(t + 1.75, scene.calendar.end - .2, union(R("calendarPeak", "dayTotal"), R("calendarPeak", "dayModes")), K.split, { place: "left" });
+ }
+ { // 06 breakdown by model, then by project
+ const t = S("breakdown");
+ block("breakdown", "06", t + .1, scene.breakdown.end - .3);
+ spot(t, .3, null);
+ layer(t, "models", .4);
+ cam(t, .8, pad(R("models", "panel"), 16));
+ spot(t + .9, .4, pad(R("models", "list"), 6));
+ label(t + 1.0, t + 1.9, R("models", "list"), K.byModel);
+ click(t + 2.05, R("models", "projectTab"));
+ layer(t + 2.1, "projects", .25);
+ spot(t + 2.2, .4, pad(R("projects", "list"), 6));
+ label(t + 2.35, scene.breakdown.end - .2, R("projects", "list"), K.byProject);
+ }
+ { // 07 session list and search
+ const t = S("sessions");
+ block("sessions", "07", t + .1, scene.sessions.end - .3);
+ spot(t, .3, null);
+ layer(t, "sessions", .4);
+ cam(t, .8, pad(R("sessions", "panel"), 12));
+ click(t + 1.0, R("sessions", "search"));
+ spot(t + 1.0, .3, pad(R("sessions", "search"), 6));
+ label(t + 1.1, t + 2.2, R("sessions", "search"), K.search);
+ layer(t + 1.45, "search1", .12);
+ layer(t + 1.9, "search2", .12);
+ cam(t + 2.1, .7, pad(union(R("search2", "search"), R("search2", "rows")), 24));
+ spot(t + 2.25, .45, pad(R("search2", "rows"), 6));
+ label(t + 2.4, scene.sessions.end - .2, R("search2", "rows"), K.matches);
+ }
+ { // 08 task tree: expand a main task to reveal its subagents
+ const t = S("tree");
+ block("tree", "08", t + .1, scene.tree.end - .3);
+ spot(t, .3, null);
+ layer(t, "tree0", .4);
+ cam(t, .8, pad(union(R("tree0", "row"), R("tree1", "group")), 90));
+ click(t + 1.15, R("tree0", "toggle"));
+ layer(t + 1.2, "tree1", .25);
+ spot(t + 1.45, .45, pad(R("tree1", "group"), 4));
+ label(t + 1.6, scene.tree.end - .2, R("tree1", "row"), K.mainTask);
+ label(t + 1.85, scene.tree.end - .2, R("tree1", "subtotal"), K.subtotal, { place: "left" });
+ }
+ { // 09 export, live usage, and software updates: three equal segments
+ const t = S("extras");
+ const d = (scene.extras.end - t) / 3;
+ const pair = union(R("export", "json"), R("export", "csv"));
+ block("export", "09", t + .1, t + d - .4);
+ spot(t, .3, null);
+ layer(t, "export", .4);
+ cam(t, .7, pad(R("export", "dialog"), 40));
+ spot(t + .7, .35, pad(pair, 8));
+ label(t + .8, t + d - .2, pair, K.formats);
- 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)` });
- });
+ const u = t + d;
+ block("live", "09", u + .1, u + d - .4);
+ spot(u - .1, .25, null);
+ layer(u, "live0", .35);
+ cam(u, .6, pad(R("live0", "hero"), 30));
+ spot(u + .5, .35, pad(R("live0", "totalCard"), 6));
+ label(u + .6, u + d - .2, R("live0", "total"), K.live, { dot: true });
+ layer(u + 1.2, "live1", .2);
+ layer(u + 1.9, "live2", .2);
+
+ const v = t + 2 * d;
+ block("update", "09", v + .1, scene.extras.end - .3);
+ spot(v - .1, .25, null);
+ layer(v, "update0", .35);
+ cam(v, .5, pad(R("update0", "banner"), 60));
+ click(v + .85, R("update0", "review"));
+ layer(v + .9, "update1", .25);
+ cam(v + .9, .6, pad(R("update1", "dialog"), 30));
+ spot(v + 1.4, .35, pad(union(R("update1", "versions"), R("update1", "status")), 8));
+ label(v + 1.5, scene.extras.end - .2, R("update1", "versions"), K.newVersion);
}
+ { // 10 theme, language and phone
+ const t = S("interface");
+ block("interface", "10", t + .1, scene.interface.end - .35);
+ spot(t, .3, null);
+ layer(t, "light", .4);
+ cam(t, .7, { x: 0, y: 0, w: dims.w, h: dims.h });
+ click(t + 1.3, R("light", "theme"));
+ layer(t + 1.35, "dark", .8, "wipe");
+ click(t + 2.8, R("dark", "locale"));
+ layer(t + 2.85, "otherDark", .35);
+ }
+ beats.cursor.sort((a, b) => a.t - b.t);
+ if (beats.reveals.length) reveal.src = layers[beats.reveals[0].name].src;
+ // ---- renderers ----------------------------------------------------------------
+ const copyRoot = $("#copy");
+ const copyNodes = beats.copy.map(item => {
+ // Portrait frames use hand-set Chinese line breaks where the default would orphan a character.
+ const [kicker, landscapeLines, note, portraitLines] = item.data;
+ const lines = vertical && portraitLines ? portraitLines : landscapeLines;
+ const node = el("div", "copy-block");
+ set(node, { left: `${L.copy.x}px`, top: `${L.copy.y}px`, width: `${L.copy.w}px` });
+ const k = el("p", "kicker");
+ k.append(el("b", null, item.number), el("span", null, kicker));
+ const h2 = el("h2");
+ for (const line of lines) h2.append(el("span", "line", line));
+ const parts = [k, h2];
+ if (note) parts.push(el("p", "note", note));
+ node.append(...parts);
+ copyRoot.append(node);
+ return { ...item, node, parts };
+ });
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";
+ for (const item of copyNodes) {
+ const visible = t > item.a - .05 && t < item.b + .5;
+ item.node.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));
+ item.parts.forEach((part, i) => {
+ const e = env(t, item.a + i * .08, item.b + i * .02, .5, .28);
+ set(part, { opacity: e.v, transform: `translateY(${(1 - e.i) * 34 - e.o * 24}px)` });
});
}
- 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) {
+ let rect = beats.cams[0].rect;
+ for (const { t: start, dur, rect: target } of beats.cams) {
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);
+ const s = clamp(Math.min(view.w / rect.w, view.h / rect.h), coverScale, 1.6);
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);
+ // Focus areas sit in the upper part of the frame, like a scrolled page.
+ let ty = -rect.y * s + Math.max(0, view.h - rect.h * s) * .15;
+ tx = clamp(tx, view.w - dims.w * s, 0);
+ ty = clamp(ty, view.h - dims.h * s, 0);
return { s, tx, ty };
}
+ const toView = (cam, x, y) => ({ x: cam.tx + x * cam.s, y: cam.ty + y * cam.s });
+
+ const counterNodes = beats.counters.map(item => {
+ const style = shots.overview.counters[item.key];
+ const node = el("div", "counter");
+ set(node, { left: `${style.x}px`, top: `${style.y}px`, ...style.font });
+ content.append(node);
+ const value = item.key === "cost" ? Number(style.text.replace(/[$,]/g, "")) : parseToken(style.text);
+ return { ...item, node, value, final: style.text };
+ });
+ function parseToken(text) {
+ const match = text.match(/^([\d.]+)([KMBT]?)$/);
+ return Number(match[1]) * ({ "": 1, K: 1e3, M: 1e6, B: 1e9, T: 1e12 }[match[2]]);
+ }
+ // Mirrors the Dashboard's compact number and currency formats.
+ function formatToken(number) {
+ const a = Math.abs(number);
+ if (a >= 1e9) return `${(number / 1e9).toFixed(a >= 1e10 ? 1 : 2)}B`;
+ if (a >= 1e6) return `${(number / 1e6).toFixed(a >= 1e7 ? 1 : 2)}M`;
+ if (a >= 1e3) return `${(number / 1e3).toFixed(a >= 1e4 ? 1 : 2)}K`;
+ return Math.round(number).toLocaleString("en-US");
+ }
+ const formatUSD = amount => `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+
+ const labelNodes = beats.labels.map(item => {
+ const node = el("div", `label ${item.place || "top"}`);
+ if (item.dot) node.append(el("i", "dot"));
+ node.append(document.createTextNode(item.text));
+ $("#labels").append(node);
+ return { ...item, node };
+ });
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 e = env(t, S("total"), scene.interface.end - .45, .9, .4);
+ panel.style.display = e.v > .001 ? "block" : "none";
+ if (e.v <= .001) return;
+ set(panel, { opacity: e.v, transform: `perspective(2200px) translateY(${(1 - e.i) * 150 + e.o * 60}px) rotateX(${(1 - e.i) * 14}deg) scale(${(.95 + .05 * e.i) * (1 - e.o * .05)})` });
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.
+ // Stills: the active one fades (or wipes) in over the previous one.
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 k of beats.layers) {
+ if (t < k.t) break;
+ if (k.name !== current) previous = current;
+ current = k.name;
+ p = k.fade ? ease.inOut(prog(t, k.t, k.t + k.fade)) : 1;
+ kind = k.kind;
}
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%)`; }
+ if (kind === "wipe") { opacity = 1; const x = p * 135; clip = `polygon(0 0, ${x}% 0, ${x - 35}% 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"
- });
+
+ // Bars grow in from the left.
+ const r = beats.reveals.find(item => t >= item.a && t < item.b);
+ reveal.style.visibility = r ? "visible" : "hidden";
+ if (r) {
+ const x = r.rect.x + r.rect.w * ease.inOut(prog(t, r.a, r.b));
+ reveal.style.clipPath = `polygon(${r.rect.x}px ${r.rect.y}px, ${x}px ${r.rect.y}px, ${x}px ${r.rect.y + r.rect.h}px, ${r.rect.x}px ${r.rect.y + r.rect.h}px)`;
}
+
+ for (const c of counterNodes) {
+ const visible = t < c.off;
+ c.node.style.visibility = visible ? "visible" : "hidden";
+ if (!visible) continue;
+ const value = c.value * ease.out(prog(t, c.a, c.b));
+ c.node.textContent = t >= c.b ? c.final : c.key === "cost" ? formatUSD(value) : formatToken(value);
+ }
+
+ // Spotlight: dims everything but the focus area.
+ let rect = null, prevRect = null, sp = 1;
+ for (const k of beats.spots) {
+ if (t < k.t) break;
+ prevRect = rect;
+ rect = k.rect;
+ sp = k.dur ? ease.inOut(prog(t, k.t, k.t + k.dur)) : 1;
+ }
+ const spotNode = $("#spot");
+ let spotRect = rect, spotOpacity = 1;
+ if (rect && prevRect) spotRect = lerpRect(prevRect, rect, sp);
+ else if (rect) spotOpacity = sp;
+ else if (prevRect) { spotRect = prevRect; spotOpacity = 1 - sp; }
+ spotNode.style.visibility = spotRect && spotOpacity > .001 ? "visible" : "hidden";
+ if (spotRect) {
+ const a = toView(cam, spotRect.x, spotRect.y);
+ set(spotNode, { left: `${a.x}px`, top: `${a.y}px`, width: `${spotRect.w * cam.s}px`, height: `${spotRect.h * cam.s}px`, opacity: spotOpacity });
+ }
+
+ for (const item of labelNodes) {
+ const e = env(t, item.a, item.b, .35, .25);
+ item.node.style.visibility = e.v > .001 ? "visible" : "hidden";
+ if (e.v <= .001) continue;
+ const a = toView(cam, item.rect.x, item.rect.y);
+ const b = toView(cam, item.rect.x + item.rect.w, item.rect.y + item.rect.h);
+ const w = item.node.offsetWidth, h = item.node.offsetHeight;
+ let place = item.place || "top";
+ if (place === "top" && a.y - h - 18 < 8) place = "bottom";
+ if (place === "left" && a.x - w - 20 < 8) place = "top";
+ for (const name of ["top", "bottom", "left"]) item.node.classList.toggle(name, name === place);
+ let x = clamp(a.x, 12, view.w - w - 12);
+ let y = place === "bottom" ? Math.min(b.y + 18, view.h - h - 12) : a.y - h - 18;
+ if (place === "left") { x = a.x - w - 20; y = clamp((a.y + b.y - h) / 2, 12, view.h - h - 12); }
+ const slide = (1 - e.i) * 10;
+ set(item.node, { opacity: e.v, transform: place === "left" ? `translate(${x - slide}px, ${y}px)` : `translate(${x}px, ${y + (place === "bottom" ? -slide : slide)}px)` });
+ }
+
+ renderCursor(t, cam);
}
- 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 renderCursor(t, cam) {
+ const cursor = $("#cursor"), ripple = $("#ripple");
+ const points = beats.cursor;
+ const center = r => toView(cam, r.x + r.w / 2, r.y + r.h / 2);
+ let pos = null, opacity = 0, press = 1;
+ const next = points.findIndex(point => point.t > t);
+ const target = next === -1 ? null : points[next];
+ const last = next === -1 ? points.at(-1) : next > 0 ? points[next - 1] : null;
+ // Consecutive clicks glide from one control to the next without hiding the pointer.
+ const chained = Boolean(target && last && target.t - last.t < 1.6);
+ if (target && target.t - t <= .6) {
+ const to = center(target.rect);
+ const from = chained ? center(last.rect) : { x: to.x + 170, y: to.y + 150 };
+ const p = ease.inOut(prog(t, target.t - .55, target.t));
+ pos = { x: lerp(from.x, to.x, p), y: lerp(from.y, to.y, p) };
+ opacity = chained ? 1 : prog(t, target.t - .6, target.t - .4);
+ } else if (last && t - last.t < 1.6) {
+ pos = center(last.rect);
+ opacity = chained ? 1 : 1 - prog(t, last.t + .6, last.t + .9);
+ }
+ cursor.style.visibility = pos && opacity > .001 ? "visible" : "hidden";
+ if (last && t - last.t < .2) press = 1 - .16 * Math.sin(Math.PI * (t - last.t) / .2);
+ if (pos) set(cursor, { opacity, transform: `translate(${pos.x - 3}px, ${pos.y - 2}px) scale(${press})` });
+ const rp = last ? prog(t, last.t, last.t + .5) : 1;
+ ripple.style.visibility = last && rp < 1 ? "visible" : "hidden";
+ if (last && rp < 1) {
+ const c = center(last.rect);
+ const r = 10 + 36 * ease.out(rp);
+ set(ripple, { left: `${c.x - r}px`, top: `${c.y - r}px`, width: `${2 * r}px`, height: `${2 * r}px`, opacity: 1 - rp });
+ }
}
+ const phone = $("#phone");
+ $("#phone img").src = P.assets.base + shots.mobile.file;
+ const phoneScale = L.phone.h / 884;
function renderPhone(t) {
- const e = env(t, ui + 1.8, scene.interface.end - .5, .7, .35);
+ const e = env(t, S("interface") + 3.7, scene.interface.end - .45, .7, .4);
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)` });
+ set(phone, { opacity: e.v, transform: `translate(${L.phone.x + (1 - e.i) * 280}px, ${L.phone.y + e.o * 60}px) scale(${phoneScale}) rotate(${(1 - e.i) * 7}deg)` });
}
+ const bgLight = $(".bg-light"), bgDark = $(".bg-dark");
+ function renderBackground(t) {
+ const open = ease.inOut(prog(t, scene.brand.end - .35, scene.brand.end + .3));
+ const close = ease.inOut(prog(t, S("privacy") - .3, S("privacy") + .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 * .3);
+ for (const [bg, dx] of [[bgLight, 1], [bgDark, -1]]) {
+ set($(".glow-a", bg), { transform: `translate(${W * .55 + drift * 80 * dx}px, ${-H * .35 + t * 5}px)` });
+ set($(".glow-b", bg), { transform: `translate(${-W * .3 - drift * 60 * dx}px, ${H * .45 - t * 4}px)` });
+ }
+ set($(".grid", bgLight), { transform: `translate(${(t * 6) % 34}px, ${(t * 4) % 34}px)` });
+ const e = env(t, S("total") + .2, S("privacy") - .5, .5, .3);
+ set(corner, { opacity: e.v });
+ set(badge, { opacity: e.v * .9 });
+ }
+
+ function renderHook(t) {
+ const hook = $("#hook");
+ const end = scene.hook.end;
+ const out = ease.inOut(prog(t, end - .45, end));
+ set(hook, { opacity: 1 - out, transform: `scale(${1 + out * .06})`, visibility: t < end + .05 ? "visible" : "hidden" });
+ const parts = [...$(".hook-title").children, $(".hook-gauges")];
+ parts.forEach((node, i) => {
+ const p = ease.out(prog(t, .1 + i * .16, .7 + i * .16));
+ set(node, { opacity: p, transform: `translateY(${(1 - p) * 40}px)` });
+ });
+ const grow = ease.in(prog(t, .5, end - .35));
+ $("#hookCount").textContent = Math.round(186_402_917 * grow).toLocaleString("en-US");
+ const left = 1 - .88 * grow;
+ set($("#hookMeter"), { width: `${left * 100}%`, background: left > .5 ? "var(--teal-bright)" : left > .25 ? "var(--copper-bright)" : "#e0654f" });
+ $("#hookPercent").textContent = `${Math.round(left * 100)}%`;
+ $("#hookPercent").style.color = left > .5 ? "#bfe6df" : left > .25 ? "var(--copper-bright)" : "#f08a74";
+ [...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));
+ bar.style.transform = `scaleY(${h})`;
+ });
+ }
+
+ function renderBrand(t) {
+ const b = scene.brand.start, end = scene.brand.end;
+ const node = $("#brand");
+ const out = ease.inOut(prog(t, end - .55, end - .2));
+ set(node, { visibility: t > b - .05 && t < end ? "visible" : "hidden", opacity: 1 - out, transform: `scale(${1 + out * .05})` });
+ const pop = ease.back(prog(t, b, b + .45));
+ set($(".brand-icon"), { transform: `scale(${.35 + .65 * pop}) rotate(${(1 - pop) * -8}deg)`, opacity: prog(t, b, b + .12) });
+ brandBars.forEach((bar, i) => set(bar, { transform: `scaleY(${ease.back(prog(t, b + .1 + i * .07, b + .4 + i * .07))})` }));
+ zStroke.setAttribute("stroke-dashoffset", String(1 - ease.inOut(prog(t, b + .35, b + .8))));
+ [$("#brand h1"), $("#brand .byline"), $(".brand-tagline")].forEach((w, i) => {
+ const p = ease.out(prog(t, b + .45 + i * .12, b + .95 + i * .12));
+ set(w, { opacity: p, transform: `translate${vertical ? "Y" : "X"}(${(1 - p) * (vertical ? 40 : -50)}px)` });
+ });
+ }
+
+ const chipNodes = copy.privacy.chips.map(text => $(".privacy-chips").appendChild(el("li", null, text)));
+ const ringNodes = [...document.querySelectorAll(".rings i")];
function renderPrivacy(t) {
const node = $("#privacy");
- const visible = t > pv - .1 && t < ct + .1;
+ const pv = S("privacy"), end = scene.privacy.end;
+ const visible = t > pv - .1 && t < end + .05;
node.style.visibility = visible ? "visible" : "hidden";
if (!visible) return;
- const out = ease.inOut(prog(t, ct - .35, ct));
+ const out = ease.inOut(prog(t, end - .35, end));
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))})` });
+ [$(".lock"), ...$("#privacy h1").children].forEach((p, i) => {
+ const e = ease.out(prog(t, pv + .15 + i * .1, pv + .7 + i * .1));
+ set(p, { opacity: e, transform: `translateY(${(1 - e) * 40}px) scale(${i ? 1 : .7 + .3 * ease.back(prog(t, pv + .15, pv + .7))})` });
});
chipNodes.forEach((chip, i) => {
- const e = ease.back(prog(t, pv + .85 + i * .2, pv + 1.35 + i * .2));
+ const e = ease.back(prog(t, pv + .8 + i * .18, pv + 1.3 + i * .18));
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) });
+ set(ring, { width: `${d}px`, height: `${d}px`, transform: `translate(${-d / 2}px, ${-d / 2 - (vertical ? 160 : 120)}px)`, opacity: (1 - phase) * .3 * prog(t, pv, pv + .6) });
});
}
+ const command = "./codex-usage install";
+ const ctaParts = [$(".cta-icon"), $("#cta h1"), $(".cta-tagline"), $(".cta-title"), $(".terminal"), ...document.querySelectorAll(".cta-links li")];
function renderCTA(t) {
- const node = $("#cta");
- node.style.visibility = t > ct - .05 ? "visible" : "hidden";
+ const ct = S("cta");
+ $("#cta").style.visibility = t > ct - .05 ? "visible" : "hidden";
ctaParts.forEach((part, i) => {
- const at = ct + .1 + i * .13;
+ const at = ct + .1 + i * .12;
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)` });
+ set(part, { opacity: clamp(p * 1.4), transform: i === 0 ? `scale(${.4 + .6 * p})` : `translateY(${(1 - p) * 34}px)` });
});
- const chars = Math.round(command.length * prog(t, ct + .75, ct + 1.55));
- typed.textContent = command.slice(0, chars);
+ const chars = Math.round(command.length * prog(t, ct + .9, ct + 1.7));
+ $("#typed").textContent = command.slice(0, chars);
$(".caret").style.opacity = chars < command.length || Math.floor((t - ct) * 2.2) % 2 === 0 ? "1" : "0";
}
@@ -451,7 +617,6 @@
renderBrand(t);
renderCopy(t);
renderPanel(t);
- renderCards(t);
renderPhone(t);
renderPrivacy(t);
renderCTA(t);
@@ -459,7 +624,7 @@
const images = [...document.images];
window.promo = {
- ready: Promise.all([document.fonts.ready, ...images.map(img => img.decode())]),
+ ready: Promise.all([document.fonts.ready, ...images.filter(img => img.src).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
index 83202a0..f1f6c29 100644
--- a/scripts/promo/timeline.json
+++ b/scripts/promo/timeline.json
@@ -1,17 +1,22 @@
{
"fps": 30,
- "duration": 30,
+ "duration": 59.4,
"bpm": 120,
- "poster": 12.4,
+ "poster": 13.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 }
+ { "id": "hook", "start": 0, "end": 3.5 },
+ { "id": "brand", "start": 3.5, "end": 6.5 },
+ { "id": "total", "start": 6.5, "end": 10.5 },
+ { "id": "daily", "start": 10.5, "end": 14 },
+ { "id": "hourly", "start": 14, "end": 17.5 },
+ { "id": "range", "start": 17.5, "end": 22 },
+ { "id": "calendar", "start": 22, "end": 26 },
+ { "id": "breakdown", "start": 26, "end": 30 },
+ { "id": "sessions", "start": 30, "end": 34 },
+ { "id": "tree", "start": 34, "end": 38 },
+ { "id": "extras", "start": 38, "end": 46.4 },
+ { "id": "interface", "start": 46.4, "end": 52.4 },
+ { "id": "privacy", "start": 52.4, "end": 55.4 },
+ { "id": "cta", "start": 55.4, "end": 59.4 }
]
}