launch huashu-design v1.0: 9 demos + marketing-first README

- 新增 9 个能力 demo(workflow + capability),每个 MP4 + GIF + 60fps
- README 重写:营销 hook 开头 · 功能优先结构 · 自然嵌入 GIF
- 修复 render-video.js Google Fonts networkidle 超时
- 修复 c1-ios-prototype 刘海/Home Indicator 背景色
- 补充 references/editable-pptx.md 和 scripts/export_deck_stage_pdf.mjs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
alchainandClaude Opus 4.7 committed 2026-04-20 18:19:48 +08:00
1 parent f3692aac10
commit b127f8baa3
24 files changed
+8194 -269

No files matched your search

+17 -32
View File
@@ -1,37 +1,22 @@
# OS # macOS
.DS_Store .DS_Store
Thumbs.db **/.DS_Store
# Editor # Video render temp (render-video.js 产物)
.vscode/ .video-tmp-*/
.idea/ **/.video-tmp-*/
*.swp
*~
# Node # Personal asset index(个人真实数据,只保留 .example.json 模板)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Python
__pycache__/
*.pyc
.venv/
venv/
# Backups
*.bak
*.bak.*
# Video render temp dirs
.video-tmp-*
*.tmp.mp4
*.tmp.webm
# Private overrides (never commit real personal data)
personal-asset-index.json
assets/personal-asset-index.json assets/personal-asset-index.json
# Logs # Node / editor / OS
*.log node_modules/
*.swp
.idea/
.vscode/
Thumbs.db
# Verification artifacts(截图、临时测试脚本)
demos/_frames_*.png
demos/_verify.js
demos/_verify.mjs
+215 -114
View File
@@ -1,153 +1,254 @@
# 花叔Design · Huashu-Design <div align="center">
![花叔Design Brand Film](./demo/huashu-design-brand.gif) # Huashu Design
> 20 秒品牌片由本 skill 自己完成:ASK → PROPOSE → PICK → CRITIQUE → EXPORT → BRAND,六幕演一次 Junior Designer 的完整工作流。 > *「你要做的下一个设计,何必打开图形界面」*
> **[下载 MP4(60fps, 1.0MB)](./demo/huashu-design-brand.mp4)** · [25fps 版](./demo/huashu-design-brand-25fps.mp4)
把「AI 当设计师」落到实处的 Skill。 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-Skill-blueviolet)](https://claude.ai/code)
[![Skills](https://img.shields.io/badge/skills.sh-Compatible-green)](https://skills.sh)
不是让 AI 生成一张图,而是让 AI 进入「资深 Junior Designer」的工作模式:先给假设 + 占位 + 理由,show 给 manager 看,得到方向反馈,再做 variation,再迭代细节。最后还能请一位「严苛评审」打分。 <br>
适用于任何支持 Skill 规范的 AI agent:Claude Code、Codex、Cursor、Trae、OpenClaw、Hermes Agent 等。 **Claude Design 把设计师塞进了浏览器。Huashu Design 让图形工具这一层消失。**
<br>
PPT、App 原型、时间轴动画、信息图、设计变体——在 Claude Code 里说一句话,agent 自己搭 design spec、自己抓品牌色、自己生成 HTML、自己跑 Playwright 验证。<br>
没有画布,没有图层,没有插件。只剩下一个能直接复制到 Keynote、贴进飞书、发进 issue 的交付物。
```
npx skills add alchaincyf/huashu-design
```
[看效果](#demo-画廊) · [安装](#装上就能用) · [能做什么](#能做什么) · [核心机制](#核心机制) · [和 Claude Design 的关系](#和-claude-design-的关系)
<br>
[![Star History Chart](https://api.star-history.com/svg?repos=alchaincyf/huashu-design&type=Date)](https://star-history.com/#alchaincyf/huashu-design&Date)
</div>
--- ---
## 它能做什么 <p align="center">
<img src="demos/w3-fallback-advisor.gif" alt="Fallback 设计顾问 · 从 20 种设计哲学推荐 3 个差异化方向" width="100%">
一条 skill 同时覆盖四件事: </p>
1. **高保真 HTML 视觉产出**
交互原型、App / iOS mockup、幻灯片、动画 Demo、设计变体探索、信息图 —— 用 HTML 做,而不是用 Figma 截图或 placeholder 拼贴。
2. **设计方向顾问(Fallback)**
当你的需求只是「给我做个好看的页面」时,skill 会从 5 个流派、20 种设计哲学里推荐 3 个差异化方向(信息建筑派 / 运动诗学派 / 极简主义派 / 实验先锋派 / 东方哲学派),再并行生成 3 个视觉 Demo 让你挑。
3. **Junior Designer 工作流**
不直接闷头做大招。先写 assumptions + reasoning + placeholder,尽早 show;方向错了,晚改比早改贵 100 倍。
4. **专家级评审**
交付后可选一轮 5 维度评分(哲学一致性 / 视觉层级 / 细节执行 / 功能性 / 创新性),外加具体的 Quick Fixes 清单。
--- ---
## 附带的工程工具链 ## 装上就能用
- **Starter Components**:iOS / Android / macOS / Browser 的设备边框,design_canvas(变体画布),animations.jsx(Stage + Sprite 时间轴引擎),deck_stage.js(幻灯片外壳)。
- **视频导出**:HTML 动画一键导出 25fps MP4 → 60fps 插帧 MP4 → palette 优化 GIF,配 6 首场景化 BGM(科技 / 广告 / 教育 / 教程)自动 fade。
- **幻灯片导出**:HTML → PDF(矢量)/ PPTX(图片铺底)。
- **Playwright 验证**:截图 + 控制台错误检查,交付前跑一遍。
---
## 安装
把整个目录放到你 agent 读取 skills 的位置即可。
**Claude Code**:
```bash ```bash
git clone https://github.com/alchaincyf/huashu-design-skill.git ~/.claude/skills/huashu-design npx skills add alchaincyf/huashu-design
``` ```
**其他 agent**(Codex / Cursor / Trae / OpenClaw / Hermes Agent 等):按各自的 skills 约定放置,SKILL.md 的 frontmatter + markdown 结构是通用的。 然后在 Claude Code 里直接说话:
安装完成后,用任意包含触发词的话开启: ```
「做一份 AI 心理学的演讲 PPT,推荐 3 个风格方向让我选」
「做个 AI 番茄钟 iOS 原型,4 个核心屏幕要真能点击」
「把这段逻辑做成 60 秒动画,导出 MP4 和 GIF」
「帮我对这个设计做一个 5 维度评审」
```
> 「做个 iOS App 原型」「帮我做 pitch deck」「导出 MP4」「评审这个设计」「做个好看的页面(我没想法)」 没有按钮、没有面板、没有 Figma 插件。跨 agent 通用 —— Claude Code / Cursor / Trae / Hermes / OpenClaw 任一都能装。
触发词完整清单见 `SKILL.md` frontmatter 的 description 字段。
--- ---
## 使用前需要自己配置的东西 ## 能做什么
大部分能力开箱即用,下面几项在需要时再弄: | 能力 | 交付物 | 典型耗时 |
|------|--------|----------|
| 交互原型(App / Web) | 单文件 HTML · 真 iPhone bezel · 可点击 · Playwright 验证 | 10–15 min |
| 演讲幻灯片 | HTML deck(浏览器演讲)+ 可编辑 PPTX(文本框保留) | 15–25 min |
| 时间轴动画 | MP4(25fps / 60fps 插帧)+ GIF(palette 优化)+ BGM | 8–12 min |
| 设计变体 | 3+ 并排对比 · Tweaks 实时调参 · 跨维度探索 | 10 min |
| 信息图 / 可视化 | 印刷级排版 · 可导 PDF/PNG/SVG | 10 min |
| 设计方向顾问 | 5 流派 × 20 种设计哲学 · 推荐 3 方向 · 并行生成 Demo | 5 min |
| 5 维度专家评审 | 雷达图 + Keep/Fix/Quick Wins · 可操作修复清单 | 3 min |
| 能力 | 需要 | **跨 agent 通用**:Claude Code、Cursor、Trae、Hermes、OpenClaw 任一都能装。
|------|------|
| 视频导出 | `npm i -g playwright` + `brew install ffmpeg`(或等价方案) |
| 幻灯片导出 PDF | `npm i -g playwright pdf-lib` |
| 幻灯片导出 PPTX | `npm i -g playwright pptxgenjs` |
| Playwright 截图验证 | `npm i -g playwright` |
| 个人品牌锚定(可选) | 复制 `assets/personal-asset-index.example.json` 到你的 agent 私有 memory 目录并填真实信息 |
--- ---
## 目录结构 ## Demo 画廊
### 设计方向顾问
模糊需求时的 fallback:从 5 流派 × 20 种设计哲学里挑 3 个差异化方向,并行生成 3 个 Demo 让你选。
<p align="center"><img src="demos/w3-fallback-advisor.gif" width="100%"></p>
### iOS App 原型
iPhone 15 Pro 精确机身(灵动岛 / 状态栏 / Home Indicator)· 状态驱动多屏切换 · 真图从 Wikimedia/Met/Unsplash 取 · Playwright 自动点击测试。
<p align="center"><img src="demos/c1-ios-prototype.gif" width="100%"></p>
### Motion Design 引擎
Stage + Sprite 时间片段模型 · `useTime` / `useSprite` / `interpolate` / `Easing` 四 API 覆盖所有动画需求 · 一条命令导出 MP4 / GIF / 60fps 插帧 / 带 BGM 的成片。
<p align="center"><img src="demos/c3-motion-design.gif" width="100%"></p>
### HTML Slides → 可编辑 PPTX
HTML deck 浏览器演讲 · `html2pptx.js` 读 DOM 的 computedStyle 逐元素翻译成 PowerPoint 对象 · 导出的是**真文本框**,不是图片铺底。
<p align="center"><img src="demos/c2-slides-pptx.gif" width="100%"></p>
### Tweaks · 实时变体切换
配色 / 字型 / 信息密度等参数化 · 侧边面板切换 · 纯前端 + `localStorage` 持久化 · 刷新不丢。
<p align="center"><img src="demos/c4-tweaks.gif" width="100%"></p>
### 信息图 / 数据可视化
杂志级排版 · CSS Grid 精准分栏 · `text-wrap: pretty` 排印细节 · 真数据驱动 · 可导 PDF 矢量 / PNG 300dpi / SVG。
<p align="center"><img src="demos/c5-infographic.gif" width="100%"></p>
### 5 维度专家评审
哲学一致性 · 视觉层级 · 细节执行 · 功能性 · 创新性 各 0–10 分 · 雷达图可视化 · 输出 Keep / Fix / Quick Wins 清单。
<p align="center"><img src="demos/c6-expert-review.gif" width="100%"></p>
### Junior Designer 工作流
不闷头做大招:先写 assumptions + placeholders + reasoning,尽早 show 给你,再迭代。理解错了早改比晚改便宜 100 倍。
<p align="center"><img src="demos/w2-junior-designer.gif" width="100%"></p>
### 品牌资产协议 5 步硬流程
涉及具体品牌时强制执行:问 → 搜 → 下载(三条兜底)→ grep 色值 → 写 `brand-spec.md`。
<p align="center"><img src="demos/w1-brand-protocol.gif" width="100%"></p>
---
## 核心机制
### 品牌资产协议
skill 里最硬的一段规则。涉及具体品牌(Stripe、Linear、Anthropic、自家公司等)时强制执行 5 步:
| 步骤 | 动作 | 目的 |
|------|------|------|
| 1 · 问 | 用户有 brand guidelines 吗? | 尊重已有资源 |
| 2 · 搜官方品牌页 | `<brand>.com/brand` · `brand.<brand>.com` · `<brand>.com/press` | 抓权威色值 |
| 3 · 下载资产 | SVG 文件 → 官网 HTML 全文 → 产品截图取色 | 三条兜底,前一条失败立刻走下一条 |
| 4 · grep 提取色值 | 从资产里抓所有 `#xxxxxx`,按频率排序,过滤黑白灰 | **绝不从记忆猜品牌色** |
| 5 · 固化 spec | 写 `brand-spec.md` + CSS 变量,所有 HTML 引用 `var(--brand-*)` | 不固化就会忘 |
A/B 测试(v1 vs v2,各跑 6 agent):**v2 的稳定性方差比 v1 低 5 倍**。稳定性的稳定性,这是 skill 真正的护城河。
### 设计方向顾问(Fallback)
当用户需求模糊到无法着手时触发:
- 不凭通用直觉硬做,进入 Fallback 模式
- 从 5 流派 × 20 种设计哲学里推荐 3 个**必须来自不同流派**的差异化方向
- 每个方向配代表作、气质关键词、代表设计师
- 并行生成 3 个视觉 Demo 让用户选
- 选定后进入主干 Junior Designer 流程
### Junior Designer 工作流
默认工作模式,贯穿所有任务:
- 开工前 show 问题清单一次性发给用户,等批量答完再动手
- HTML 里先写 assumptions + placeholders + reasoning comments
- 尽早 show 给用户(哪怕只是灰色方块)
- 填充实际内容 → variations → Tweaks 这三步分别再 show 一次
- 交付前用 Playwright 肉眼过一遍浏览器
### 反 AI slop 规则
避免一眼 AI 的视觉最大公约数(紫渐变 / emoji 图标 / 圆角+左 border accent / SVG 画人脸 / Inter 做 display)。用 `text-wrap: pretty` + CSS Grid + 精心选择的 serif display 和 oklch 色彩。
---
## 和 Claude Design 的关系
我大方承认:品牌资产协议的哲学是从 Claude Design 流传出来的提示词里偷师的。那份提示词反复强调**好的高保真设计不是从白纸开始,而是从已有的设计上下文长出来**。这个原则是 65 分作品和 90 分作品的分水岭。
定位差异:
| | Claude Design | huashu-design |
|---|---|---|
| 形态 | 网页产品(浏览器里用) | skill(Claude Code 里用) |
| 配额 | 订阅 quota | API 消耗 · 并行跑 agent 不受 quota 限 |
| 交付物 | 画布内 + 可导 Figma | HTML / MP4 / GIF / 可编辑 PPTX / PDF |
| 操作方式 | GUI(点、拖、改) | 对话(说话、等 agent 做完) |
| 复杂动画 | 有限 | Stage + Sprite 时间轴 · 60fps 导出 |
| 跨 agent | 专属 Claude.ai | Claude Code / Cursor / Trae / Hermes / OpenClaw 任一 |
Claude Design 是**更好的图形工具**,huashu-design 是**让图形工具这层消失**。两条路,不同受众。
---
## Limitations
- **不支持图层级可编辑的 PPTX 到 Figma**。产出 HTML,可截图、录屏、导图,但不能拖进 Keynote 改文字位置。
- **Framer Motion 级别的复杂动画不行**。3D、物理模拟、粒子系统超出 skill 边界。
- **完全空白的品牌从零设计质量会掉到 60–65 分**。凭空画 hi-fi 本来就是 last resort。
这是一个 80 分的 skill,不是 100 分的产品。对不愿意打开图形界面的人,80 分的 skill 比 100 分的产品好用。
---
## 仓库结构
``` ```
huashu-design-skill/ huashu-design/
├── SKILL.md # 主干文件:哲学 + 工作流 + 路由表 ├── SKILL.md # 主文档(给 agent 读)
├── references/ # 深度参考:按需加载 ├── README.md # 本文件(给用户读)
│ ├── workflow.md # 开工问什么问题 ├── assets/ # Starter Components
│ ├── content-guidelines.md # 反 AI slop 完整清单 │ ├── animations.jsx # Stage + Sprite + Easing + interpolate
│ ├── react-setup.md # React + Babel 最佳实践 │ ├── ios_frame.jsx # iPhone 15 Pro bezel
│ ├── slide-decks.md # 幻灯片架构 │ ├── android_frame.jsx
│ ├── animations.md # 动画引擎用法 │ ├── macos_window.jsx
│ ├── animation-pitfalls.md # 动画踩坑 + T0 黑屏修复 │ ├── browser_window.jsx
│ ├── tweaks-system.md # 实时调参 UI │ ├── deck_stage.js # HTML 幻灯片引擎
│ ├── design-context.md # 没有 design system 怎么办 │ ├── deck_index.html # 多文件 deck 拼接器
│ ├── design-styles.md # 20 种设计哲学 │ ├── design_canvas.jsx # 并排变体展示
│ ├── scene-templates.md # 按输出类型的场景模板 │ ├── showcases/ # 24 个预制样例(8 场景 × 3 风格)
│ ├── critique-guide.md # 评审打分标准 │ └── bgm-*.mp3 # 6 首场景化背景音乐
│ ├── verification.md # 产出验证流程 ├── references/ # 按任务深入读的子文档
│ └── video-export.md # 视频导出命令链 │ ├── animation-pitfalls.md
├── scripts/ # 可执行脚本 │ ├── design-styles.md # 20 种设计哲学详细库
│ ├── render-video.js # HTML → 25fps MP4 │ ├── slide-decks.md
│ ├── convert-formats.sh # 25fps MP4 → 60fps MP4 + GIF │ ├── editable-pptx.md
│ ├── add-music.sh # BGM 叠加 + 自动 fade │ ├── critique-guide.md
│ ├── export_deck_pdf.mjs # HTML → PDF │ ├── video-export.md
│ ├── export_deck_pptx.mjs # HTML → PPTX │ └── ...
│ └── verify.py # Playwright 截图 + 控制台检查 ├── scripts/ # 导出工具链
└── assets/ # 起手组件 + BGM + 示例 │ ├── render-video.js # HTML → MP4
├── deck_stage.js # 幻灯片外壳(单文件架构) │ ├── convert-formats.sh # MP4 → 60fps + GIF
├── deck_index.html # 幻灯片拼接器(多文件架构) │ ├── add-music.sh # MP4 + BGM
├── animations.jsx # Stage + Sprite 时间轴引擎 │ ├── export_deck_pdf.mjs
├── design_canvas.jsx # 变体画布 │ ├── export_deck_pptx.mjs
├── ios_frame.jsx # iPhone 设备边框 │ ├── html2pptx.js
├── android_frame.jsx # Android 设备边框 │ └── verify.py
├── macos_window.jsx # macOS 窗口 chrome └── demos/ # 9 个能力演示(本 README 引用的 GIF 都来自这里)
├── browser_window.jsx # 浏览器 chrome
├── bgm-{tech,ad,educational,tutorial,*-alt}.mp3
├── personal-asset-index.example.json
└── showcases/ # 24 个预制 showcase(8 场景 × 3 风格)
``` ```
--- ---
## 设计原则(写进 DNA 的五条) ## 起源
1. **从 existing context 出发**,不凭空画 hi-fi。没有 design system / brand / Figma 就先找,真没有就明确告知「我会基于通用直觉做」。 Anthropic 发布 Claude Design 那天我玩到凌晨四点。几天之后发现自己再也没点开过它,不是它不好——它是这个赛道目前最成熟的产品——是我宁愿让 agent 在终端里帮我干活,也不愿意打开任何图形界面。
2. **Junior Designer 模式**:先 show 假设,再执行。不要一头扎进去闷头做大招。
3. **给 variations,不给最终答案**。3+ 个变体,跨视觉 / 交互 / 色彩 / 布局 / 动画维度递进,让用户 mix and match。
4. **Placeholder > 烂实现**。没图标就留灰色方块 + 文字标签,不画烂 SVG;没数据不编造假数据。
5. **反 AI slop**:激进渐变、满屏 emoji、圆角卡片 + 左 accent border、SVG 画人画物、Inter / Roboto 填所有文字 —— 每一条都是默认避开项(用户可按品牌 override)。
完整哲学和工作流见 `SKILL.md`。 于是让 agent 拆解 Claude Design 本身(包括社区流传的系统提示词、品牌资产协议、组件机制),蒸馏成结构化 spec,再写成 skill 装进自己的 Claude Code。
--- 感谢 Anthropic 把 Claude Design 的提示词写得清晰。这种基于其他产品灵感的二次创作,是开源文化在 AI 时代的新形态。
## BGM 版权说明
`assets/bgm-*.mp3` 为原创 / 授权音乐,仅限配合本 skill 生成的演示 / 宣传视频使用。商业投放前请确认你的素材来源许可;若替换成自己的 BGM,只需保留相同文件名或改脚本参数。
--- ---
## License ## License
MIT。自由 fork、魔改、开分支。PR 欢迎,尤其欢迎: MIT. Skill 和 demo 都开源,随便用。issue 里欢迎延伸讨论。
- 新的设计哲学流派(在 `references/design-styles.md` 里)
- 新的 scene template(在 `references/scene-templates.md` 里)
- 新的 showcase(在 `assets/showcases/` 里)
- 其他 agent 环境的适配笔记(尤其欢迎 Codex / Cursor / Trae 的使用报告)
---
## 致谢
- Skill 规范:Anthropic Claude Code
- 设计哲学蒸馏:Pentagram、Field.io、Kenya Hara、Stefan Sagmeister、Dieter Rams、Massimo Vignelli 等 20 位设计师 / 机构
- 工程实践:Remotion / After Effects(Stage + Sprite 思路)、Playwright(验证 + 录制)、ffmpeg(视频后处理)
- 灵感来源:AI Artifacts 原生设计能力 → 搬到 Skill 架构 → 跨 agent 可用
+164 -17
View File
@@ -29,6 +29,87 @@ description: 花叔Design(Huashu-Design)——用HTML做高保真原型、
**如果还是没有,或者用户需求表达很模糊**(如"做个好看的页面"、"帮我设计"、"不知道要什么风格"、"做个XX"没有具体参考),**不要凭通用直觉硬做**——进入 **设计方向顾问模式**,从 20 种设计哲学里给 3 个差异化方向让用户选。完整流程见下方「设计方向顾问(Fallback 模式)」大节。 **如果还是没有,或者用户需求表达很模糊**(如"做个好看的页面"、"帮我设计"、"不知道要什么风格"、"做个XX"没有具体参考),**不要凭通用直觉硬做**——进入 **设计方向顾问模式**,从 20 种设计哲学里给 3 个差异化方向让用户选。完整流程见下方「设计方向顾问(Fallback 模式)」大节。
#### 1.a 品牌资产协议(涉及具体品牌时强制执行)
> **这是 v1 最核心的约束,也是稳定性的生命线。** Agent 是否走通这个协议,直接决定输出质量是 65 分还是 90 分。不要跳过任何一步。
**触发条件**:任务涉及具体品牌——用户提了产品名/公司名/明确客户(Stripe、Linear、Anthropic、Notion、Lovart、自家公司等),不论用户是否主动提供了品牌资料。
**5 步硬流程**(每一步都有 fallback,但绝不可以静默跳过):
##### Step 1 · 问
一句话问清楚:「这个品牌有 brand guidelines / 设计规范 / logo 文件吗?品牌色和字体清单?有的话直接给我;没有的话我去搜。」
##### Step 2 · 搜官方品牌页
- 典型路径:`<brand>.com/brand` / `<brand>.com/press` / `brand.<brand>.com` / `<brand>.github.io/brand`
- `WebFetch` 搜不到时用 `WebSearch`:「`<brand>` brand guidelines logo download」
- App 类产品另查 App Store 页面(app icon + 截图可提色)
##### Step 3 · 下载资产 · 三条兜底路径
> **真实世界里 SVG logo 经常拿不到**。2026 年官网大多 Next.js SSR + inline SVG,或 Cloudflare 反爬返回 403。下面三条路径按成功率递减排列——**前一条失败立刻走下一条,不要停**:
1. **独立 SVG 文件**(最理想)
```bash
curl -o assets/<brand>-brand/logo.svg https://<brand>.com/logo.svg
```
2. **官网 HTML 全文**(80% 场景必用):WebFetch 返回 403 时用 curl 带 UA 兜底,logo 的 inline SVG 和色值 token 直接藏在 HTML 里:
```bash
curl -A "Mozilla/5.0" -L https://<brand>.com -o assets/<brand>-brand/homepage.html
```
3. **产品截图取色**(App/未开源产品必走):从用户提供的产品截图或官方 Dribbble / Twitter media 里提取主色。macOS 用 Preview 的吸管、Linux 用 `convert image.png -unique-colors txt:-` 给出主要色值。
##### Step 4 · Grep 提取真实色值
- **绝对不要从记忆里猜品牌色**。从下载的资产里 grep 所有 `fill`/`stroke`/`color`/`background`/`#xxxxxx` 的 6 位 hex,按频率排序,过滤黑白灰:
```bash
grep -hoE '#[0-9A-Fa-f]{6}' assets/<brand>-brand/*.{svg,html,css} | sort | uniq -c | sort -rn | head -20
```
- **警惕示范品牌污染**:产品截图里常有用户 demo 的品牌色(如某工具截图里演示喜茶红),那不是该工具的色。**同时出现两种强色时必须区分**。
- **品牌多切面的客观事实**:同一品牌的官网营销色(`<brand>.com` 暖底 accent 色)和产品 UI 色(产品内深底 accent 色)经常不同,比如 Lovart 官网是暖米+橙+蓝,产品 UI 是 Charcoal + Lime 青柠。**两套都是真的**——根据交付场景选合适的切面(做营销物料用官网色,做产品内/App 延伸用 UI 色)。
##### Step 5 · 固化为 `brand-spec.md` 文件(不可省略)
> **这一步是全协议的制胜点**。前 4 步抽完色,如果只留在内存里下一页就忘。必须写一份 `brand-spec.md` 到项目根目录,让所有 CSS 变量引用这份文件。
```markdown
# <Brand> · Brand Spec
> 从以下资产抽取:<列出资产来源和提取日期>
> 资产完整度:<完整 / 部分 / 推断>
## 色板
- Primary: #XXXXXX <来源标注>
- Background: #XXXXXX
- Ink: #XXXXXX
- 禁用色: <品牌明确不用的色系>
## 字型
- Display: <font stack>
- Body: <font stack>
## 签名细节
- <哪些细节是「120% 做到」的,如 2px 细线、italic em 强调>
## 禁区
- <明确不能做的:比如 Lovart 不用蓝色、Stripe 不用低饱和暖色>
## 气质关键词
- <3-5 个形容词>
```
**写完 spec 后的执行纪律**(硬要求):
- 写 CSS 前把 spec 色值做成 `:root { --brand-primary: ...; }` CSS 变量
- 所有 HTML 文件只能用 `var(--brand-*)`,**不允许临场写死色号**
- 这一条让品牌一致性从「靠自觉」变成「靠结构」——agent 想临时加色要先改 spec,改 spec 就会自觉
**全流程失败的兜底**:三条路径都失败(极罕见)才明确告诉用户「找不到 official brand guidelines,我按通用风格做并在 spec 里标注 assumption」——**不要静默用通用色硬做**。Fallback 时进入「设计方向顾问模式」(见下节)。
**反例**(真实踩过的坑):
- 为 Kimi 做动画时凭记忆猜「应该是橙色」,实际 Kimi 是 `#1783FF` 蓝色——返工一遍
- 把 Lovart 产品截图里演示品牌的喜茶红当成 Lovart 自己的色——差点毁整个设计
- 抽完色没写进 brand-spec.md,第三页就忘了主色数值,临场加了个「接近但不是」的 hex——品牌一致性崩溃
**协议代价 vs 不做代价**:5-10 分钟 grep + 5 分钟写 spec = 15 分钟。不做的代价是整个产出方向错,返工 1-2 小时。**这是稳定性最便宜的投资**。
### 2. Junior Designer模式:先展示假设,再执行 ### 2. Junior Designer模式:先展示假设,再执行
你是manager的junior designer。**不要一头扎进去闷头做大招**。HTML文件的开头先写下你的assumptions + reasoning + placeholders,**尽早show给用户**。然后: 你是manager的junior designer。**不要一头扎进去闷头做大招**。HTML文件的开头先写下你的assumptions + reasoning + placeholders,**尽早show给用户**。然后:
@@ -59,18 +140,47 @@ description: 花叔Design(Huashu-Design)——用HTML做高保真原型、
### 6. 反AI slop(重要,必读) ### 6. 反AI slop(重要,必读)
AI设计最容易掉进去的陷阱。完整清单见 `references/content-guidelines.md`,速查: #### 6.1 什么是 AI slop?为什么要反?
- ❌ 激进渐变背景(尤其紫色渐变在白底上) **AI slop = AI 训练语料里最常见的"视觉最大公约数"**。
- ❌ Emoji 作为图标(除非品牌本身用) 紫渐变、emoji 图标、圆角卡片+左 border accent、SVG 画人脸——这些东西之所以是 slop,不是因为它们本身丑,而是因为**它们是 AI 默认模式下的产物,不携带任何品牌信息**。
- ❌ 圆角卡片+左边彩色border accent
- ❌ SVG画imagery(画人/物/场景)——留placeholder让用户提供真材料 **规避 slop 的逻辑链**:
- ❌ Inter/Roboto/Arial/Fraunces/system fonts 1. 用户请你做设计,是要**他的品牌被认出来**
- ❌ 赛博霓虹 / 深蓝色底(#0D1117)= 审美禁区 2. AI 默认产出 = 训练语料的平均 = 所有品牌混合 = **没有任何品牌被认出来**
- ✅ `text-wrap: pretty` + CSS Grid + 高级CSS是好朋友 3. 所以 AI 默认产出 = 帮用户把品牌稀释成"又一个 AI 做的页面"
- ✅ oklch定义色彩(而不是从头发明新颜色) 4. 反 slop 不是审美洁癖,是**替用户保护品牌识别度**
- ✅ 配图优先 AI 生成(Gemini / Flash / Lovart),HTML截图仅在精确数据表格时用
- ✅ 文案中使用「」引号而非""引号 这也是为什么 §1.a 品牌资产协议是 v1 最硬的约束——**服从规范是反 slop 的正向方式**(对的事),清单只是反 slop 的反向方式(不做错的事)。
#### 6.2 核心要规避的(带"为什么")
| 元素 | 为什么是 slop | 什么情况可以用 |
|------|-------------|---------------|
| 激进紫色渐变 | AI 训练语料里"科技感"的万能公式,出现在 SaaS/AI/web3 每一个落地页 | 品牌本身用紫渐变(如 Linear 某些场景)、或任务就是讽刺/展示这类 slop |
| Emoji 作图标 | 训练语料里每个 bullet 都配 emoji,是"不够专业就用 emoji 凑"的病 | 品牌本身用(如 Notion),或产品受众是儿童/轻松场景 |
| 圆角卡片 + 左彩色 border accent | 2020-2024 Material/Tailwind 时期的烂大街组合,已成视觉噪音 | 用户明确要求、或这个组合在品牌 spec 里被保留 |
| SVG 画 imagery(人脸/场景/物品)| AI 画的 SVG 人物永远五官错位,比例诡异 | **几乎没有**——有图就用真图(Wikimedia/Unsplash/AI 生成),没图就留诚实 placeholder |
| Inter/Roboto/Arial/system fonts 作 display | 太常见,读者看不出这是"有设计的产品"还是"demo 页" | 品牌 spec 明确用这些字体(Stripe 用 Sohne/Inter 变体,但是经过微调的) |
| 赛博霓虹 / 深蓝底 `#0D1117` | GitHub dark mode 美学的烂大街复制 | 开发者工具产品且品牌本身走这方向 |
**判断边界**:「品牌本身用」是唯一能合法破例的理由。品牌 spec 里明写了用紫渐变,那就用——此时它不再是 slop,是品牌签名。
#### 6.3 正向做什么(带"为什么")
- ✅ `text-wrap: pretty` + CSS Grid + 高级 CSS:排版细节是 AI 分不清的"品味税",会用这些的 agent 看起来像真设计师
- ✅ 用 `oklch()` 或 spec 里已有的色,**不凭空发明新颜色**:所有临场发明的色都会让品牌识别度下降
- ✅ 配图优先 AI 生成(Gemini / Flash / Lovart),HTML 截图仅在精确数据表格时用:AI 生成的图比 SVG 手画准确,比 HTML 截图有质感
- ✅ 文案用「」引号不用 "":中文排印规范,也是"有审校过"的细节信号
- ✅ 一个细节做到 120%,其他做到 80%:品味 = 在合适的地方足够精致,不是均匀用力
#### 6.4 反例隔离(演示型内容)
当任务本身就要展示反设计(如本任务就是讲"什么是 AI slop"、或对比评测),**不要整页堆 slop**,而是用**诚实的 bad-sample 容器**隔离——加虚线边框 + "反例 · 不要这样做" 角标,让反例服务于叙事而不是污染页面主调。
这不是硬规则(不做成模板),是原则:**反例要看得出是反例,不是让页面真的变成 slop**。
完整清单见 `references/content-guidelines.md`。
## 设计方向顾问(Fallback 模式) ## 设计方向顾问(Fallback 模式)
@@ -308,10 +418,20 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
1. **理解需求**:新任务或模糊任务必须问clarifying questions,详见 `references/workflow.md`。一次focused一轮问题通常够,小修小补跳过。 1. **理解需求**:新任务或模糊任务必须问clarifying questions,详见 `references/workflow.md`。一次focused一轮问题通常够,小修小补跳过。
🛑 **检查点1:问题清单一次性发给用户,等用户批量答完再往下走**。不要边问边做。 🛑 **检查点1:问题清单一次性发给用户,等用户批量答完再往下走**。不要边问边做。
🛑 **幻灯片/PPT 任务额外必问「最终交付格式」**(浏览器演讲 / PDF / 可编辑 PPTX)——**要可编辑 PPTX 就必须从第一行 HTML 开始按 `references/editable-pptx.md` 的 4 条硬约束写**,事后补救会导致 2-3 小时返工。详见 `references/slide-decks.md` 开头「开工前先确认交付格式」一节。
⚡ **如果用户需求严重模糊(没参考、没明确风格、"做个好看的"类)→ 走「设计方向顾问(Fallback 模式)」大节,完成 Phase 1-4 选定方向后,再回到这里 Step 2**。 ⚡ **如果用户需求严重模糊(没参考、没明确风格、"做个好看的"类)→ 走「设计方向顾问(Fallback 模式)」大节,完成 Phase 1-4 选定方向后,再回到这里 Step 2**。
2. **探索资源**:读design system的完整定义、linked files、上传的截图/代码。如果用户没给context,先走设计方向顾问 Fallback,再按 `references/design-context.md` 的品位锚点兜底。 2. **探索资源 + 抽 spec**:读 design system、linked files、上传的截图/代码。**涉及具体品牌时必走 §1.a 五步协议**(问→搜→下载→grep→写 `brand-spec.md`)。如果用户没给 context 且挖不出资产,先走设计方向顾问 Fallback,再按 `references/design-context.md` 的品位锚点兜底。
3. **规划系统**:vocalize你要用的设计系统(色彩/字型/layout节奏/component pattern)。 3. **先答四问,再规划系统**:**这一步的前半段比所有 CSS 规则更决定输出**。
🛑 **检查点2:口头说出来等用户点头,再动手写代码**。方向错了晚改比早改贵100倍。
📐 **位置四问**(每个页面/屏幕/镜头开工前必答):
- **叙事角色**:hero / 过渡 / 数据 / 引语 / 结尾?(一页 deck 里每页都不一样)
- **观众距离**:10cm 手机 / 1m 笔记本 / 10m 投屏?(决定字号和信息密度)
- **视觉温度**:安静 / 兴奋 / 冷静 / 权威 / 温柔 / 悲伤?(决定配色和节奏)
- **容量估算**:用纸笔画 3 个 5 秒 thumbnail 算一下内容塞得下吗?(防溢出 / 防挤压)
四问答完再 vocalize 设计系统(色彩/字型/layout 节奏/component pattern)——**系统要服务于答案,不是先选系统再塞内容**。
🛑 **检查点2:四问答案 + 系统口头说出来等用户点头,再动手写代码**。方向错了晚改比早改贵 100 倍。
4. **构建文件夹结构**:`项目名/` 下放主HTML、需要的assets拷贝(不要bulk copy >20个文件)。 4. **构建文件夹结构**:`项目名/` 下放主HTML、需要的assets拷贝(不要bulk copy >20个文件)。
5. **Junior pass**:HTML里写assumptions+placeholders+reasoning comments。 5. **Junior pass**:HTML里写assumptions+placeholders+reasoning comments。
🛑 **检查点3:尽早show给用户(哪怕只是灰色方块+标签),等反馈再写组件**。 🛑 **检查点3:尽早show给用户(哪怕只是灰色方块+标签),等反馈再写组件**。
@@ -384,9 +504,11 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
| 文件 | 何时用 | 提供 | | 文件 | 何时用 | 提供 |
|------|--------|------| |------|--------|------|
| `deck_index.html` | **做幻灯片(默认,多文件架构)** | iframe拼接 + 键盘导航 + scale + 计数器 + 打印合并,每页独立HTML免CSS串扰 | | `deck_index.html` | **做幻灯片(默认,多文件架构)** | iframe拼接 + 键盘导航 + scale + 计数器 + 打印合并,每页独立HTML免CSS串扰 |
| `deck_stage.js` | 做幻灯片(单文件架构,≤10页) | web component:auto-scale + 键盘导航 + slide counter + localStorage + speaker notes | | `deck_stage.js` | 做幻灯片(单文件架构,≤10页) | web component:auto-scale + 键盘导航 + slide counter + localStorage + speaker notes ⚠️ **script 必须放在 `</deck-stage>` 之后,section 的 `display: flex` 必须写到 `.active` 上**,详见 `references/slide-decks.md` 的两个硬约束 |
| `scripts/export_deck_pdf.mjs` | **HTML→PDF 导出**(矢量、高保真、文字可搜) | Playwright `page.pdf()` + pdf-lib 合并。依赖 `playwright pdf-lib` | | `scripts/export_deck_pdf.mjs` | **HTML→PDF 导出(多文件架构)** · 每页独立 HTML 文件,playwright 逐个 `page.pdf()` → pdf-lib 合并。文字保留矢量可搜。依赖 `playwright pdf-lib` |
| `scripts/export_deck_pptx.mjs` | **HTML→PPTX 导出**(图片铺底,不可编辑) | Playwright 截图 + pptxgenjs addImage。视觉 100% 保真。**要可编辑 PPTX → 切到支持该格式的专用 skill/工具按其严格格式重构 HTML**。依赖 `playwright pptxgenjs` | | `scripts/export_deck_stage_pdf.mjs` | **HTML→PDF 导出(单文件 deck-stage 架构专用)** · 2026-04-20 新增。处理 shadow DOM slot 导致的「只出 1 页」、absolute 子元素溢出等坑。详见 `references/slide-decks.md` 末节。依赖 `playwright` |
| `scripts/export_deck_pptx.mjs` | **HTML→PPTX 导出(双模式)** · `--mode image` 图片铺底视觉 100% 保真但文字不可编辑;`--mode editable` 调 `html2pptx.js` 导出原生可编辑文本框,但 HTML 必须符合 4 条硬约束(见 `references/editable-pptx.md`)。依赖 `playwright pptxgenjs`(editable 模式还需 `sharp`) |
| `scripts/html2pptx.js` | **HTML→PPTX 元素级翻译器** · 读 computedStyle 把 DOM 逐元素翻译成 PowerPoint 对象(text frame / shape / picture)。`export_deck_pptx.mjs --mode editable` 内部调用。要求 HTML 严格满足 4 条硬约束 |
| `design_canvas.jsx` | 并排展示≥2个静态variations | 带label的网格布局 | | `design_canvas.jsx` | 并排展示≥2个静态variations | 带label的网格布局 |
| `animations.jsx` | 任何动画HTML | Stage + Sprite + useTime + Easing + interpolate | | `animations.jsx` | 任何动画HTML | Stage + Sprite + useTime + Easing + interpolate |
| `ios_frame.jsx` | iOS App mockup | iPhone bezel + 状态栏 + 圆角 | | `ios_frame.jsx` | iOS App mockup | iPhone bezel + 状态栏 + 圆角 |
@@ -406,6 +528,7 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
| 反AI slop、内容规范、scale | `references/content-guidelines.md` | | 反AI slop、内容规范、scale | `references/content-guidelines.md` |
| React+Babel项目setup | `references/react-setup.md` | | React+Babel项目setup | `references/react-setup.md` |
| 做幻灯片 | `references/slide-decks.md` + `assets/deck_stage.js` | | 做幻灯片 | `references/slide-decks.md` + `assets/deck_stage.js` |
| 导出可编辑 PPTX(html2pptx 4 条硬约束) | `references/editable-pptx.md` + `scripts/html2pptx.js` |
| 做动画/motion(**先读 pitfalls**)| `references/animation-pitfalls.md` + `references/animations.md` + `assets/animations.jsx` | | 做动画/motion(**先读 pitfalls**)| `references/animation-pitfalls.md` + `references/animations.md` + `assets/animations.jsx` |
| 做Tweaks实时调参 | `references/tweaks-system.md` | | 做Tweaks实时调参 | `references/tweaks-system.md` |
| 没有design context怎么办 | `references/design-context.md`(薄 fallback) 或 `references/design-styles.md`(厚 fallback:20 种设计哲学详细库) | | 没有design context怎么办 | `references/design-context.md`(薄 fallback) 或 `references/design-styles.md`(厚 fallback:20 种设计哲学详细库) |
@@ -436,6 +559,27 @@ Skill 路径引用均采用**相对本 skill 根目录**的形式(`references/
- HTML放项目目录,不要散落到`~/Downloads` - HTML放项目目录,不要散落到`~/Downloads`
- 最终产出用浏览器打开检查或用Playwright截图 - 最终产出用浏览器打开检查或用Playwright截图
## Skill 推广水印(仅动画产出)
**只在动画产出**(HTML 动画 → MP4 / GIF)默认带「**Created by Huashu-Design**」水印,辅助 skill 传播。**幻灯片 / 信息图 / 原型 / 网页等其他场景不加**——加了反而干扰用户实际使用。
- **必带场景**:HTML 动画 → 导出 MP4 / GIF(用户会拿去公众号、X、B站传播,水印能跟着流通)
- **不带场景**:幻灯片(用户自己讲)、信息图(嵌文章)、App / 网页原型(设计 review)、配图
- **第三方品牌的非官方致敬动画**:水印前加「非官方出品 · 」前缀,避免被误认为官方物料引发 IP 争议
- **用户明确说"不要水印"**:尊重,移除
- **水印模板**:
```jsx
<div style={{
position: 'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.4)' /* 深底用 rgba(255,255,255,0.35) */,
letterSpacing: '0.15em', fontFamily: 'monospace',
pointerEvents: 'none', zIndex: 100,
}}>
Created by Huashu-Design
{/* 第三方品牌动画前缀「非官方出品 · 」*/}
</div>
```
## 核心提醒 ## 核心提醒
- **Embody专家**:做幻灯片时是幻灯片设计师,做动画时是动画师。不是写Web UI。 - **Embody专家**:做幻灯片时是幻灯片设计师,做动画时是动画师。不是写Web UI。
@@ -443,3 +587,6 @@ Skill 路径引用均采用**相对本 skill 根目录**的形式(`references/
- **Variations不给答案**:3+个变体,让用户选。 - **Variations不给答案**:3+个变体,让用户选。
- **Placeholder优于烂实现**:诚实留白,不编造。 - **Placeholder优于烂实现**:诚实留白,不编造。
- **反AI slop时时警醒**:每个渐变/emoji/圆角border accent之前先问——这真的必要吗? - **反AI slop时时警醒**:每个渐变/emoji/圆角border accent之前先问——这真的必要吗?
- **涉及具体品牌**:先走「品牌资产协议」(核心哲学 §1.a),不要凭记忆猜配色。
- **做动画之前**:必读 `references/animation-pitfalls.md`——里面 14 条规则每条都来自真实踩过的坑,跳过会让你重做 1-3 轮。
- **手写 Stage / Sprite**(不用 `assets/animations.jsx`):必须实现两件事——(a) tick 第一帧同步设 `window.__ready = true` (b) 检测 `window.__recording === true` 时强制 loop=false。否则录视频必出问题。
+11 -2
View File
@@ -162,6 +162,12 @@
const startTimeRef = useRef(performance.now()); const startTimeRef = useRef(performance.now());
const canvasRef = useRef(null); const canvasRef = useRef(null);
// Recording mode: render-video.js injects window.__recording = true before goto.
// When set, force loop=false so the export ends on the final frame instead of
// wrapping back to t=0 and capturing the start of the next cycle.
// (Browsers viewing manually still loop because __recording is undefined there.)
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => { useEffect(() => {
function updateScale() { function updateScale() {
const vw = window.innerWidth; const vw = window.innerWidth;
@@ -195,7 +201,10 @@
setTime(prev => { setTime(prev => {
const next = prev + delta; const next = prev + delta;
if (next >= duration) { if (next >= duration) {
return loop ? 0 : duration; // effectiveLoop honors window.__recording (forced non-loop during export).
// Stop just shy of duration so the final-frame state stays rendered
// (avoids exiting all Sprites that end exactly at `duration`).
return effectiveLoop ? 0 : duration - 0.001;
} }
return next; return next;
}); });
@@ -218,7 +227,7 @@
cancelled = true; cancelled = true;
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
}; };
}, [playing, duration, loop]); }, [playing, duration, effectiveLoop]);
const handleScrub = useCallback((e) => { const handleScrub = useCallback((e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
+15
View File
@@ -43,12 +43,27 @@
this._width = parseInt(this.getAttribute('width')) || 1920; this._width = parseInt(this.getAttribute('width')) || 1920;
this._height = parseInt(this.getAttribute('height')) || 1080; this._height = parseInt(this.getAttribute('height')) || 1080;
// Shadow DOM 先渲染(独立于子节点,不受 parser 时机影响)
this._render(); this._render();
// 防御:若 script 放在 <head> 里(而非 </deck-stage> 之后),
// parser 此刻可能还没处理完子 <section>,querySelectorAll 会返回空。
// 延迟到下一个事件循环,确保子节点都已 parse 完毕。
const init = () => {
this._collectSlides(); this._collectSlides();
this._setupEventListeners(); this._setupEventListeners();
this._restoreSlide(); this._restoreSlide();
this._updateDisplay(); this._updateDisplay();
this._setupPrintStyles(); this._setupPrintStyles();
};
if (this.ownerDocument.readyState === 'loading') {
// 文档还在 parse,等 DOMContentLoaded 一次搞定所有 section
this.ownerDocument.addEventListener('DOMContentLoaded', init, { once: true });
} else {
// 文档已 parse 完(script 在 body 底部或 defer),下一帧收集即可
requestAnimationFrame(init);
}
} }
_render() { _render() {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"description": "个人素材索引模板 — 复制此文件并填入你的真实数据", "description": "个人素材索引模板 — 复制此文件并填入你的真实数据",
"how_to_use": "1. 复制此文件到你的 agent 私有 memory 目录(Claude Code: ~/.claude/memory/personal-asset-index.json;其他 agent 按其约定) 2. 填入你的真实信息 3. huashu-design skill 在需要个人素材/锚定当前品牌时会自动读取", "how_to_use": "1. 复制此文件到 ~/.claude/memory/personal-asset-index.json 2. 填入你的真实信息 3. design-philosophy skill 会自动读取",
"note": "真实数据文件不要放在 skill 目录内,避免随 skill 分发泄露隐私" "note": "真实数据文件不要放在 skill 目录内,避免随 skill 分发泄露隐私"
}, },
+721
View File
@@ -0,0 +1,721 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · iOS App Prototype</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const PAPER = '#FDFBF5';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const OLIVE = '#6a6b4e';
const ASH = '#6b6b6b';
const LINE = '#e5ddcd';
const LINE2 = '#d9d2c5';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Art image: CSS-rendered "oil painting" hero ──────────
function ArtBlock({ mood = 'warm', height = 200 }) {
// Three curated palettes for variety (no Unsplash dep, stable offline)
const palettes = {
warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'], // Turner sunset
quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'], // Corot pastoral
study: ['#2a3552', '#5e6b8a', '#8b98b5', '#d4c9a5'], // Vermeer indoor
};
const p = palettes[mood];
return (
<div style={{
width: '100%', height, position: 'relative', overflow: 'hidden',
background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
}}>
{/* Impressionist brush texture */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
`,
filter: 'blur(1px)',
}} />
{/* Subtle scratch noise */}
<svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
<filter id="paint-noise">
<feTurbulence baseFrequency="0.9" numOctaves="2" />
<feColorMatrix values="0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 1 0" />
</filter>
<rect width="100%" height="100%" filter="url(#paint-noise)" />
</svg>
</div>
);
}
// ── iOS Frame (simplified from ios_frame.jsx, positioned for demo) ──
function IosFrame({ children, time = '9:41', scale = 1, style = {} }) {
const W = 420, H = 900;
return (
<div style={{
display: 'inline-block',
padding: 13,
background: '#0a0a0a',
borderRadius: 62,
boxShadow: '0 0 0 2px #2a2a2a, 0 30px 80px rgba(0,0,0,0.35), 0 10px 30px rgba(0,0,0,0.2)',
position: 'relative',
transform: `scale(${scale})`,
transformOrigin: 'center center',
...style,
}}>
<div style={{
position: 'relative', width: W, height: H,
borderRadius: 50, overflow: 'hidden', background: PAPER,
}}>
{/* Status bar */}
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, height: 54,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 34px', fontSize: 17, fontWeight: 600,
fontFamily: '-apple-system, "SF Pro Text", sans-serif',
color: '#000', zIndex: 20, pointerEvents: 'none',
}}>
<span>{time}</span>
<div style={{display:'flex', alignItems:'center', gap: 6}}>
<div style={{display:'flex', alignItems:'flex-end', gap: 2, height: 12}}>
{[4, 6, 9, 11].map((h, i) => <div key={i} style={{width:3, height:h, background:'#000', borderRadius:1}} />)}
</div>
<svg width="16" height="12" viewBox="0 0 16 12">
<path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="#000" />
<path d="M3 7.5a7 7 0 0110 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" />
<path d="M1 4.5a11 11 0 0114 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7" />
</svg>
<div style={{width:26, height:12, border:'1.5px solid #000', borderRadius:3, padding:1, position:'relative'}}>
<div style={{width:'85%', height:'100%', background:'#000', borderRadius:1}} />
<div style={{position:'absolute', top:3, right:-3, width:2, height:6, background:'#000', borderRadius:'0 1px 1px 0'}} />
</div>
</div>
</div>
{/* Dynamic island */}
<div style={{
position: 'absolute', top: 12, left: '50%',
transform: 'translateX(-50%)', width: 124, height: 36,
background: '#000', borderRadius: 999, zIndex: 30,
}} />
{/* Content */}
<div style={{position:'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'hidden'}}>
{children}
</div>
{/* Home indicator */}
<div style={{
position: 'absolute', bottom: 10, left: '50%',
transform: 'translateX(-50%)', width: 140, height: 5,
background: 'rgba(0,0,0,0.28)', borderRadius: 999, zIndex: 10,
}} />
</div>
</div>
);
}
// ── Screen: Today ────────────────────────────────────────
function TodayScreen({ animateT = 1 }) {
const headerOp = interpolate(animateT, [0, 0.25], [0, 1]);
const headerY = interpolate(animateT, [0, 0.35], [20, 0], Easing.easeOut);
const heroOp = interpolate(animateT, [0.15, 0.5], [0, 1]);
const heroY = interpolate(animateT, [0.15, 0.5], [30, 0], Easing.easeOut);
const memoriesOp = interpolate(animateT, [0.4, 0.8], [0, 1]);
return (
<div style={{padding: '24px 22px 0', height: '100%', display:'flex', flexDirection:'column', background: PAPER}}>
{/* Header */}
<div style={{opacity: headerOp, transform: `translateY(${headerY}px)`, marginBottom: 18}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
周二 · 4月20日
</div>
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
今日
</div>
</div>
{/* Hero card */}
<div style={{opacity: heroOp, transform: `translateY(${heroY}px)`, border: `1px solid ${LINE}`, background:'#fff', marginBottom: 14}}>
<ArtBlock mood="warm" height={180} />
<div style={{padding: '14px 16px 16px'}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 6}}>
继续阅读 · 剩 12 分钟
</div>
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500, color: INK, lineHeight: 1.2, marginBottom: 4, letterSpacing:'-0.005em'}}>
《<span style={{fontStyle:'italic'}}>沉思录</span>》
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13, color: ASH}}>
马可·奥勒留 · 第四卷
</div>
{/* AI insight */}
<div style={{marginTop: 14, paddingTop: 12, borderTop: `1px solid ${LINE}`,
display:'flex', gap: 10, alignItems: 'flex-start'}}>
<div style={{width: 6, height: 6, borderRadius:'50%', background: TERRA, marginTop: 6, flexShrink: 0}} />
<div>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 2}}>
流明 · 已关联
</div>
<div style={{fontFamily: serif, fontSize: 13, color: INK, lineHeight: 1.4}}>
呼应你 3 周前读的《<span style={{fontStyle:'italic'}}>塞涅卡书简·28</span>》——同在谈<span style={{fontStyle:'italic'}}>内心堡垒</span>。
</div>
</div>
</div>
</div>
</div>
{/* Memory bubbles list */}
<div style={{opacity: memoriesOp, flex: 1}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 10}}>
来自你的记忆
</div>
{[
{ title: '"Amor fati"——一个说法', sub: '尼采 · 2 个月前', dot: OLIVE },
{ title: '论「注意力即爱」', sub: '薇依 · 5 个月前', dot: TERRA },
].map((m, i) => (
<div key={i} style={{padding: '11px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12}}>
<div style={{width: 8, height: 8, borderRadius: '50%', background: m.dot}} />
<div style={{flex: 1}}>
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.3}}>{m.title}</div>
<div style={{fontFamily: mono, fontSize: 9, color: ASH, marginTop: 2, letterSpacing:'0.1em'}}>{m.sub}</div>
</div>
<div style={{fontFamily: serif, fontSize: 18, color: ASH, fontStyle:'italic'}}>→</div>
</div>
))}
</div>
</div>
);
}
// ── Screen: Memory (graph view) ───────────────────────────
function MemoryScreen({ animateT = 1 }) {
const headerOp = interpolate(animateT, [0, 0.3], [0, 1]);
const graphOp = interpolate(animateT, [0.15, 0.6], [0, 1]);
const listOp = interpolate(animateT, [0.5, 0.9], [0, 1]);
// Nodes for graph
const nodes = [
{ x: 210, y: 100, r: 22, label: '斯多葛', emph: true },
{ x: 110, y: 180, r: 14, label: '伦理' },
{ x: 310, y: 170, r: 16, label: '美德', emph: true },
{ x: 90, y: 260, r: 10, label: '' },
{ x: 200, y: 240, r: 12, label: '' },
{ x: 320, y: 270, r: 18, label: '自我' },
{ x: 150, y: 330, r: 11, label: '' },
{ x: 280, y: 340, r: 13, label: '心流' },
];
const edges = [
[0, 1], [0, 2], [0, 4], [1, 3], [2, 5], [4, 5], [4, 6], [5, 7], [6, 7], [1, 4],
];
return (
<div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 14}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
287 条 · 4 个聚类
</div>
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
记忆
</div>
</div>
{/* Graph visualization */}
<div style={{
opacity: graphOp, border:`1px solid ${LINE}`, background:'#fff',
height: 400, position:'relative', overflow:'hidden', marginBottom: 14,
}}>
<svg viewBox="0 0 420 400" width="100%" height="100%" style={{display:'block'}}>
{/* edges */}
{edges.map(([a, b], i) => {
const na = nodes[a], nb = nodes[b];
return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y}
stroke="#c8beb0" strokeWidth={0.8} opacity={0.7} />;
})}
{/* nodes */}
{nodes.map((n, i) => {
const appear = interpolate(animateT, [0.2 + i * 0.04, 0.4 + i * 0.04], [0, 1], Easing.easeOut);
return (
<g key={i} opacity={appear}>
<circle cx={n.x} cy={n.y} r={n.r}
fill={n.emph ? TERRA : '#ede5d3'}
stroke={n.emph ? TERRA : '#b8ac94'}
strokeWidth={1} />
{n.label && (
<text x={n.x} y={n.y + n.r + 14} textAnchor="middle"
fontFamily={serif} fontStyle="italic" fontSize={11}
fill={n.emph ? TERRA : '#666'}>
{n.label}
</text>
)}
</g>
);
})}
</svg>
{/* corner label */}
<div style={{position:'absolute', top: 12, left: 14,
fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.2em'}}>
· 图谱视图
</div>
<div style={{position:'absolute', bottom: 12, right: 14,
fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em'}}>
斯多葛派 · 47 条
</div>
</div>
{/* Top clusters */}
<div style={{opacity: listOp}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 8}}>
主要聚类
</div>
{[
{ name: '斯多葛', count: 47, swatch: TERRA },
{ name: '注意力', count: 32, swatch: OLIVE },
].map((c, i) => (
<div key={i} style={{padding: '9px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 10}}>
<div style={{width: 14, height: 14, background: c.swatch, borderRadius: 2}} />
<div style={{flex: 1, fontFamily: serif, fontSize: 14, color: INK}}>{c.name}</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH}}>{c.count}</div>
</div>
))}
</div>
</div>
);
}
// ── Screen: Chat ─────────────────────────────────────────
function ChatScreen({ animateT = 1 }) {
const headerOp = interpolate(animateT, [0, 0.2], [0, 1]);
const msg1Op = interpolate(animateT, [0.15, 0.35], [0, 1]);
const msg2Op = interpolate(animateT, [0.4, 0.65], [0, 1]);
const ctxCardOp = interpolate(animateT, [0.55, 0.75], [0, 1]);
const msg3Op = interpolate(animateT, [0.7, 0.92], [0, 1]);
const inputOp = interpolate(animateT, [0.5, 0.8], [0, 1]);
// Typewriter for AI reply (msg2)
const aiText = '两处呼应——马可谈的「内心堡垒」和塞涅卡第 28 封信中的独处。';
const charCount = Math.floor(interpolate(animateT, [0.4, 0.7], [0, aiText.length]));
const typed = aiText.slice(0, charCount);
return (
<div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 16}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
流明 · 已关联
</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
问你的记忆
</div>
</div>
<div style={{flex: 1, display:'flex', flexDirection:'column', gap: 12}}>
{/* User msg */}
<div style={{opacity: msg1Op, alignSelf:'flex-end', maxWidth: '85%',
background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.4}}>
我最近关于<span style={{fontStyle:'italic'}}>「独处」</span>在想什么?
</div>
</div>
{/* AI reply (typewriter) */}
<div style={{opacity: msg2Op, alignSelf:'flex-start', maxWidth: '90%',
paddingLeft: 14, borderLeft: `2px solid ${TERRA}`}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 4}}>
流明
</div>
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.45, minHeight: 60}}>
{typed}
{charCount < aiText.length && charCount > 0 && (
<span style={{color: TERRA, marginLeft: 2}}>|</span>
)}
</div>
</div>
{/* Context card */}
<div style={{opacity: ctxCardOp, alignSelf:'flex-start', maxWidth: '88%',
background:'#fff', border: `1px solid ${LINE}`, padding: '10px 12px',
marginLeft: 14, display:'flex', gap: 10, alignItems:'center'}}>
<div style={{width: 40, height: 40, flexShrink: 0, overflow:'hidden'}}>
<ArtBlock mood="study" height={40} />
</div>
<div style={{flex: 1}}>
<div style={{fontFamily: serif, fontSize: 12, fontWeight: 500, color: INK, lineHeight: 1.2}}>
塞涅卡 · 第 28 封信
</div>
<div style={{fontFamily: mono, fontSize: 8, color: ASH, marginTop: 2, letterSpacing:'0.15em'}}>
3 周前阅读 · 4 分钟
</div>
</div>
<div style={{fontFamily: serif, fontSize: 16, color: ASH, fontStyle:'italic'}}>↗</div>
</div>
{/* User follow-up */}
<div style={{opacity: msg3Op, alignSelf:'flex-end', maxWidth: '70%',
background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
<div style={{fontFamily: serif, fontSize: 14, color: INK}}>
给我看原文段落。
</div>
</div>
</div>
{/* Input bar */}
<div style={{opacity: inputOp, padding: '10px 0 16px',
borderTop: `1px solid ${LINE}`, marginTop: 12,
display:'flex', alignItems:'center', gap: 10}}>
<div style={{flex: 1, fontFamily: serif, fontStyle:'italic',
fontSize: 13, color: ASH}}>
从你的阅读里问我任何事…
</div>
<div style={{width: 28, height: 28, background: TERRA, borderRadius: '50%',
display:'flex', alignItems:'center', justifyContent:'center',
color:'#fff', fontFamily: sans, fontSize: 16}}>↑</div>
</div>
</div>
);
}
// ── Tab bar ───────────────────────────────────────────────
function TabBar({ active = 'today', tapping = null }) {
const tabs = [
{ id: 'today', label: '今日' },
{ id: 'memory', label: '记忆' },
{ id: 'chat', label: '对话' },
];
return (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
height: 72, background: 'rgba(253,251,245,0.95)',
backdropFilter: 'blur(12px)',
borderTop: `1px solid ${LINE}`,
display: 'flex', alignItems: 'center',
fontFamily: serif,
}}>
{tabs.map((t) => {
const isActive = active === t.id;
const isTapping = tapping === t.id;
return (
<div key={t.id} style={{
flex: 1, textAlign:'center', position:'relative',
padding: '12px 0 18px',
}}>
{/* Ripple */}
{isTapping !== null && isTapping > 0 && isTapping < 1 && (
<div style={{
position:'absolute', top:'50%', left:'50%',
transform: `translate(-50%, -50%) scale(${1 + isTapping * 2})`,
width: 44, height: 44, borderRadius:'50%',
background: TERRA, opacity: 0.25 * (1 - isTapping),
pointerEvents:'none',
}} />
)}
<div style={{
fontSize: 15, fontWeight: isActive ? 600 : 400,
fontStyle: isActive ? 'normal' : 'italic',
color: isActive ? TERRA : ASH,
letterSpacing: '0.02em',
}}>
{t.label}
</div>
{isActive && (
<div style={{
position:'absolute', bottom: 8, left:'50%',
transform: 'translateX(-50%)',
width: 18, height: 2, background: TERRA,
}} />
)}
</div>
);
})}
</div>
);
}
// ── Scene composition ─────────────────────────────────────
// Timeline:
// 0.0 – 1.8 iPhone fade+bounce in
// 1.8 – 7.5 Today screen (fills in)
// 7.5 – 8.5 Tap on Memory tab (ripple)
// 8.5 – 13.5 Memory screen
// 13.5 – 14.5 Tap on Chat tab
// 14.5 – 19.5 Chat screen
// 19.5 – 21.5 Pan: phone shrinks + capability labels appear
// 21.5 – 24.0 Hold final frame with labels
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<MainComposition />
</Stage>
);
}
function MainComposition() {
const time = useTime();
// Phone entrance
const entranceT = Math.min(1, Math.max(0, time / 1.8));
const phoneOp = interpolate(entranceT, [0, 0.5], [0, 1]);
const phoneScale = interpolate(entranceT, [0, 1], [0.82, 0.88], Easing.spring);
// Pan-out in final scene (19.5 – 21.5)
const panT = Math.min(1, Math.max(0, (time - 19.5) / 2));
const finalScale = interpolate(panT, [0, 1], [0.88, 0.68], Easing.easeInOut);
const finalX = interpolate(panT, [0, 1], [0, -200], Easing.easeInOut);
const currentScale = panT > 0 ? finalScale : phoneScale;
const currentX = panT > 0 ? finalX : 0;
// Screen determination
let activeScreen = 'today';
let tapping = null; // { id: 'memory', t: 0..1 }
let screenAnimateT = 1;
let transitionProgress = 0;
if (time < 7.5) {
activeScreen = 'today';
screenAnimateT = Math.min(1, Math.max(0, (time - 1.8) / 2.5));
} else if (time < 8.5) {
activeScreen = 'today';
tapping = { id: 'memory', t: (time - 7.5) / 1.0 };
transitionProgress = (time - 8.0) / 0.5; // slide starts at 8.0
} else if (time < 13.5) {
activeScreen = 'memory';
screenAnimateT = Math.min(1, Math.max(0, (time - 8.5) / 2.5));
} else if (time < 14.5) {
activeScreen = 'memory';
tapping = { id: 'chat', t: (time - 13.5) / 1.0 };
transitionProgress = (time - 14.0) / 0.5;
} else if (time < 19.5) {
activeScreen = 'chat';
screenAnimateT = Math.min(1, Math.max(0, (time - 14.5) / 2.5));
} else {
activeScreen = 'chat';
screenAnimateT = 1;
}
return (
<div style={{position:'absolute', inset:0, background: CREAM}}>
{/* Phone */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: `translate(calc(-50% + ${currentX}px), -50%) scale(${currentScale})`,
opacity: phoneOp, transformOrigin: 'center center',
}}>
<IosFrame>
<div style={{position:'relative', width:'100%', height:'100%'}}>
{activeScreen === 'today' && <TodayScreen animateT={screenAnimateT} />}
{activeScreen === 'memory' && <MemoryScreen animateT={screenAnimateT} />}
{activeScreen === 'chat' && <ChatScreen animateT={screenAnimateT} />}
<TabBar active={activeScreen} tapping={tapping ? tapping.t : null} />
</div>
</IosFrame>
</div>
{/* Capability labels (appear during pan-out 19.5+) */}
{panT > 0.05 && <CapabilityLabels t={panT} />}
{/* Masthead (tucked corner, always visible from ~2s) */}
{time > 2 && time < 19.5 && (
<div style={{
position: 'absolute', top: 60, left: 80,
opacity: Math.min(1, (time - 2) / 0.6),
maxWidth: 420,
}}>
<div style={{fontFamily: mono, fontSize: 12, color: TERRA, letterSpacing:'0.3em', marginBottom: 10}}>
iOS APP 原型
</div>
<div style={{fontFamily: serif, fontSize: 70, fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing:'-0.015em'}}>
真机。<br/>
<span style={{fontStyle:'italic', color: TERRA}}>真</span>交互。
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH, marginTop: 22, lineHeight: 1.55}}>
iPhone 15 Pro 机身 · 灵动岛 · 状态驱动多屏<br/>
AI 密度信息 · CSS 艺术 · Playwright 点击测试
</div>
</div>
)}
{/* Screen label (bottom) */}
{time > 2 && time < 19.5 && (
<ScreenLabel active={activeScreen} time={time} />
)}
{/* Watermark */}
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
</div>
);
}
function ScreenLabel({ active, time }) {
const label = { today: '屏幕 1 · 今日', memory: '屏幕 2 · 记忆', chat: '屏幕 3 · 对话' }[active];
const idx = { today: 1, memory: 2, chat: 3 }[active];
return (
<div style={{
position: 'absolute', bottom: 80, left: 80,
fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em',
opacity: 0.9,
}}>
<span style={{color: TERRA, marginRight: 12}}>0{idx}</span>
<span>{label.toUpperCase()}</span>
</div>
);
}
function CapabilityLabels({ t }) {
const labels = [
{ text: '真图 · Wikimedia / Met / Unsplash', y: 220, delay: 0.0 },
{ text: 'Inline React · 双击就开', y: 380, delay: 0.15 },
{ text: 'AppPhone · 状态驱动多屏切换', y: 540, delay: 0.30 },
{ text: '信息密度型 · 每屏 ≥ 3 处差异化', y: 700, delay: 0.45 },
{ text: 'Playwright · 交付前点击测试', y: 860, delay: 0.60 },
];
return (
<>
{labels.map((l, i) => {
const localT = Math.max(0, Math.min(1, (t - l.delay) / 0.35));
const op = localT;
const x = interpolate(localT, [0, 1], [1400, 1280], Easing.easeOut);
return (
<div key={i} style={{
position: 'absolute', left: x, top: l.y,
opacity: op, display:'flex', alignItems:'center', gap: 14,
}}>
<div style={{width: 60, height: 1, background: TERRA}} />
<div>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA, letterSpacing:'0.25em', marginBottom: 3}}>
0{i + 1}
</div>
<div style={{fontFamily: serif, fontSize: 20, color: INK, lineHeight: 1.25, letterSpacing:'-0.005em'}}>
{l.text}
</div>
</div>
</div>
);
})}
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+978
View File
@@ -0,0 +1,978 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Slides → PPTX</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ══════════════════════════════════════════════════════════
// Scene 1 (0 – 3s) · 开题
// ══════════════════════════════════════════════════════════
function Scene1_Title() {
const { elapsed } = useSprite();
const tagOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const mainOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
const mainY = interpolate(elapsed, [0.4, 1.2], [40, 0], Easing.easeOut);
const terraOp = interpolate(elapsed, [1.1, 1.8], [0, 1]);
const lineW = interpolate(elapsed, [1.6, 2.2], [0, 640]);
const subOp = interpolate(elapsed, [1.9, 2.5], [0, 1]);
const fadeOut = interpolate(elapsed, [2.7, 3.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{position:'absolute', top: 72, left: 88,
fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
color: ASH, opacity: tagOp}}>
<span style={{color: TERRA}}>●</span> 幻灯片能力 · HTML + PPTX
</div>
<div style={{fontFamily: serif, fontSize: 130, fontWeight: 500,
color: INK, lineHeight: 1.0, letterSpacing:'-0.015em',
opacity: mainOp, transform: `translateY(${mainY}px)`,
textAlign: 'center'}}>
<span style={{fontStyle:'italic'}}>播放</span>用 HTML,<br/>
<span style={{fontStyle:'italic', color: TERRA, opacity: terraOp}}>编辑</span>用 PPTX
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: ASH, marginTop: 24, opacity: subOp, letterSpacing:'0.02em'}}>
一个源文件,两种交付形态
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 2 (3 – 9s) · HTML Deck 翻页
// ══════════════════════════════════════════════════════════
function Scene2_DeckFlip() {
const { elapsed } = useSprite();
const frameOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const frameScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
// Three pages, each ~1.5s. Stagger timings inside deck.
// Page 1: 0.6 – 2.2 | Page 2: 2.2 – 3.8 | Page 3: 3.8 – 5.6
const pageIndex = elapsed < 2.2 ? 0 : elapsed < 3.8 ? 1 : 2;
const pageNum = pageIndex + 1;
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{position:'absolute', top: 48, left: 88,
fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: ASH}}>
<span style={{color: TERRA}}>●</span> SCENE 02 · HTML DECK
</div>
<div style={{position:'absolute', top: 48, right: 88,
fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH}}>
浏览器里直接演讲
</div>
<div style={{opacity: frameOp, transform: `scale(${frameScale})`,
transformOrigin:'center center'}}>
<BrowserFrame url="file:///Users/huashu/decks/annual-2026/deck.html">
<DeckSlide pageIndex={pageIndex} localElapsed={elapsed} />
{/* Footer inside deck */}
<div style={{position:'absolute', bottom: 18, left: 28, right: 28,
display:'flex', justifyContent:'space-between', alignItems:'center',
zIndex: 5}}>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.15em'}}>
{String(pageNum).padStart(2,'0')} / 12
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em'}}>
HUASHU · DESIGN
</div>
</div>
{/* TERRA progress bar */}
<div style={{position:'absolute', bottom: 0, left: 0, right: 0,
height: 3, background: '#eee', zIndex: 5}}>
<div style={{height:'100%', width: `${(pageNum/12)*100}%`,
background: TERRA}} />
</div>
</BrowserFrame>
</div>
<div style={{marginTop: 28, fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.25em'}}>
<span style={{color: pageIndex === 0 ? TERRA : LINE}}>●</span>
<span style={{margin:'0 10px', color: pageIndex === 1 ? TERRA : LINE}}>●</span>
<span style={{color: pageIndex === 2 ? TERRA : LINE}}>●</span>
</div>
</div>
);
}
// Browser chrome container (chrome style, 1600×900 deck 16:9)
function BrowserFrame({ url, children }) {
const W = 1400, H = 788; // 16:9 ratio
return (
<div style={{
display:'inline-block',
background:'#e8e4dc',
borderRadius: 12,
boxShadow:'0 30px 70px rgba(0,0,0,0.18), 0 10px 24px rgba(0,0,0,0.12)',
padding: 0,
overflow:'hidden',
border:`1px solid ${LINE}`,
}}>
{/* Title bar */}
<div style={{height: 42, display:'flex', alignItems:'center',
background:'#e8e4dc', padding:'0 16px', gap: 8,
borderBottom:`1px solid ${LINE}`}}>
<div style={{width:12, height:12, borderRadius:'50%', background:'#ff5f57'}} />
<div style={{width:12, height:12, borderRadius:'50%', background:'#febc2e'}} />
<div style={{width:12, height:12, borderRadius:'50%', background:'#28c840'}} />
<div style={{flex: 1, height: 26, background:'#faf6ef', border:`1px solid ${LINE}`,
borderRadius: 6, marginLeft: 16, padding:'0 14px',
display:'flex', alignItems:'center', gap: 8,
fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.02em',
overflow:'hidden', whiteSpace:'nowrap'}}>
<svg width="10" height="12" viewBox="0 0 10 12" style={{flexShrink: 0}}>
<path d="M2 5 V3.5 a3 3 0 016 0 V5" stroke={OLIVE} strokeWidth="1.2" fill="none"/>
<rect x="1" y="5" width="8" height="6" fill={OLIVE} opacity="0.85"/>
</svg>
<span style={{color: INK, opacity: 0.7}}>{url}</span>
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em'}}>DECK MODE</div>
</div>
{/* Deck area */}
<div style={{width: W, height: H, background:'#fff', position:'relative',
overflow:'hidden'}}>
{children}
</div>
</div>
);
}
// Three deck pages
function DeckSlide({ pageIndex, localElapsed }) {
// Slide-in entrance each time pageIndex changes
const pageStart = pageIndex === 0 ? 0.6 : pageIndex === 1 ? 2.2 : 3.8;
const sinceStart = localElapsed - pageStart;
const slideX = interpolate(sinceStart, [0, 0.5], [140, 0], Easing.easeOut);
const fadeIn = interpolate(sinceStart, [0, 0.4], [0, 1]);
return (
<div key={pageIndex} style={{position:'absolute', inset:0,
opacity: fadeIn, transform: `translateX(${slideX}px)`}}>
{pageIndex === 0 && <CoverPage />}
{pageIndex === 1 && <DataPage />}
{pageIndex === 2 && <QuotePage />}
</div>
);
}
function CoverPage() {
return (
<div style={{padding: '80px 80px 60px', height:'100%', background:'#fff',
display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
color: TERRA, marginBottom: 14}}>
VOL.01 · ANNUAL REPORT
</div>
<div style={{flex: 1, display:'flex', flexDirection:'column',
justifyContent:'center'}}>
<div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
color: INK, lineHeight: 1.02, letterSpacing:'-0.02em'}}>
2026<br/>
<span style={{fontStyle:'italic'}}>设计年度</span>报告
</div>
<div style={{height: 1, background: INK, width: 380, marginTop: 36,
marginBottom: 28}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: ASH, letterSpacing:'0.02em'}}>
The shape of digital craft, from typography to motion.
</div>
</div>
</div>
);
}
function DataPage() {
const numbers = [
{ big: '428', label: '项目交付', unit: 'projects' },
{ big: '92%', label: '客户续约', unit: 'retention' },
{ big: '3.1x', label: '交付提速', unit: 'vs 2025' },
];
const bars = [
{ h: 0.45, label: 'Q1' },
{ h: 0.62, label: 'Q2' },
{ h: 0.78, label: 'Q3' },
{ h: 1.00, label: 'Q4', hi: true },
];
return (
<div style={{padding: '60px 80px 56px', height:'100%', background:'#fff',
display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA, marginBottom: 10}}>SECTION 02 · NUMBERS</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.015em', marginBottom: 36}}>
今年的三个关键数字
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 48,
marginBottom: 40}}>
{numbers.map((n, i) => (
<div key={i}>
<div style={{fontFamily: serif, fontSize: 112, fontWeight: 400,
color: i === 2 ? TERRA : INK, lineHeight: 1, letterSpacing:'-0.02em'}}>
{n.big}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: INK, marginTop: 10}}>
{n.label}
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.2em', marginTop: 4}}>
{n.unit}
</div>
</div>
))}
</div>
<div style={{flex: 1, display:'flex', alignItems:'flex-end', gap: 20,
paddingLeft: 4, borderTop:`1px solid ${LINE}`, paddingTop: 24}}>
{bars.map((b, i) => (
<div key={i} style={{flex: 1, display:'flex', flexDirection:'column',
alignItems:'center'}}>
<div style={{width:'78%', height: `${b.h * 180}px`,
background: b.hi ? TERRA : INK, marginBottom: 10}} />
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.2em'}}>{b.label}</div>
</div>
))}
</div>
</div>
);
}
function QuotePage() {
return (
<div style={{padding: '80px', height:'100%', background:'#faf6ef',
display:'flex', flexDirection:'column', justifyContent:'center',
alignItems:'center', position:'relative'}}>
<div style={{position:'absolute', top: 64, left: 80,
fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: TERRA}}>
EPIGRAPH · III
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 104,
fontWeight: 400, color: INK, lineHeight: 1.15, letterSpacing:'-0.015em',
textAlign:'center', maxWidth: 1100}}>
"Less,<br/>but <span style={{color: TERRA}}>better</span>."
</div>
<div style={{height: 1, background: INK, width: 140, marginTop: 44,
marginBottom: 20}} />
<div style={{fontFamily: serif, fontSize: 22, color: ASH,
letterSpacing:'0.08em'}}>
— Dieter Rams
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 3 (9 – 15s) · 导出流水线
// ══════════════════════════════════════════════════════════
function Scene3_Pipeline() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
const nodes = [
{ title: 'HTML Deck', sub: 'source of truth', icon: 'code', delay: 0.4 },
{ title: 'html2pptx.js', sub: 'read computedStyle', icon: 'scan', delay: 1.1, hi: true },
{ title: 'pptxgenjs', sub: 'assemble objects', icon: 'compose', delay: 1.8 },
{ title: 'deck.pptx', sub: 'editable output', icon: 'doc', delay: 2.5 },
];
const cmdOp = interpolate(elapsed, [3.8, 4.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '72px 96px 56px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', opacity: titleOp, marginBottom: 12}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA, marginBottom: 10}}>
<span>●</span> SCENE 03 · EXPORT PIPELINE
</div>
<div style={{fontFamily: mono, fontSize: 64, fontWeight: 500,
color: INK, letterSpacing:'0.04em'}}>
导出流水线
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
color: ASH, textAlign:'right', maxWidth: 380, lineHeight: 1.5}}>
把 DOM 翻译成<br/>
PowerPoint 对象图
</div>
</div>
<div style={{height: 1, background: INK, width: '100%', opacity: titleOp,
marginTop: 28, marginBottom: 48}} />
{/* Pipeline nodes */}
<div style={{display:'flex', alignItems:'stretch', gap: 0, flex: 1,
position:'relative'}}>
{nodes.map((n, i) => {
const op = interpolate(elapsed, [n.delay, n.delay + 0.5], [0, 1]);
const ty = interpolate(elapsed, [n.delay, n.delay + 0.5], [28, 0], Easing.easeOut);
return (
<React.Fragment key={i}>
<div style={{flex: 1, opacity: op, transform: `translateY(${ty}px)`,
background: n.hi ? TERRA : '#fff',
border: `1px solid ${n.hi ? TERRA : LINE}`,
padding:'28px 24px', display:'flex', flexDirection:'column',
color: n.hi ? '#fff' : INK}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.25em',
opacity: n.hi ? 0.85 : 0.5, marginBottom: 18}}>
STEP {String(i+1).padStart(2, '0')}
</div>
<NodeIcon kind={n.icon} hi={n.hi} />
<div style={{fontFamily: mono, fontSize: 20, fontWeight: 500,
marginTop: 20, letterSpacing:'0.01em'}}>
{n.title}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
opacity: n.hi ? 0.85 : 0.6, marginTop: 6}}>
{n.sub}
</div>
</div>
{i < nodes.length - 1 && (
<ArrowBetween elapsed={elapsed} startTime={n.delay + 0.4} />
)}
</React.Fragment>
);
})}
</div>
{/* Data flow caption */}
<div style={{marginTop: 36, display:'flex', alignItems:'center', gap: 24,
opacity: interpolate(elapsed, [3.2, 3.8], [0, 1])}}>
<div style={{fontFamily: mono, fontSize: 13, color: ASH,
letterSpacing:'0.05em', flex: 1}}>
<span style={{color: OLIVE}}>DOM node</span> <span style={{color: TERRA}}>→</span>{' '}
<span style={{color: INK}}>{'{ type, text, font, color, x, y }'}</span>
<span style={{color: ASH, margin:'0 14px'}}>·</span>
<span style={{color: TERRA}}>→</span> <span style={{color: INK}}>slide.addText(...) / slide.addShape(...)</span>
</div>
</div>
{/* Command subtitle */}
<div style={{marginTop: 22, opacity: cmdOp,
background:'#1a1a1a', padding:'16px 24px',
borderLeft: `3px solid ${TERRA}`,
display:'flex', alignItems:'center', gap: 16}}>
<div style={{fontFamily: mono, fontSize: 12, color: TERRA,
letterSpacing:'0.2em'}}>$</div>
<div style={{fontFamily: mono, fontSize: 15, color: '#f5f0e6',
letterSpacing:'0.02em'}}>
node export_deck_pptx.mjs deck.html <span style={{color: '#8ca577'}}>--mode editable</span>
</div>
</div>
</div>
);
}
function NodeIcon({ kind, hi }) {
const fg = hi ? '#fff' : INK;
const bg = hi ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.04)';
if (kind === 'code') {
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="34" height="34" viewBox="0 0 34 34" fill="none">
<path d="M12 10 L5 17 L12 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 10 L29 17 L22 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M19 7 L15 27" stroke={fg} strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
);
}
if (kind === 'scan') {
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
<rect x="6" y="6" width="26" height="26" stroke={fg} strokeWidth="2"/>
<line x1="6" y1="15" x2="32" y2="15" stroke={fg} strokeWidth="1.5"/>
<line x1="6" y1="23" x2="32" y2="23" stroke={fg} strokeWidth="1.5"/>
<line x1="15" y1="6" x2="15" y2="32" stroke={fg} strokeWidth="1.5"/>
<line x1="23" y1="6" x2="23" y2="32" stroke={fg} strokeWidth="1.5"/>
<circle cx="19" cy="19" r="3" fill={fg}/>
</svg>
</div>
);
}
if (kind === 'compose') {
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
<rect x="4" y="4" width="16" height="12" stroke={fg} strokeWidth="2"/>
<rect x="22" y="4" width="12" height="12" stroke={fg} strokeWidth="2"/>
<rect x="4" y="20" width="12" height="14" stroke={fg} strokeWidth="2"/>
<rect x="18" y="20" width="16" height="14" stroke={fg} strokeWidth="2"/>
</svg>
</div>
);
}
// doc
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="34" height="38" viewBox="0 0 34 38" fill="none">
<path d="M6 4 H22 L28 10 V34 H6 Z" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
<path d="M22 4 V10 H28" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
<line x1="11" y1="17" x2="23" y2="17" stroke={fg} strokeWidth="1.5"/>
<line x1="11" y1="22" x2="23" y2="22" stroke={fg} strokeWidth="1.5"/>
<line x1="11" y1="27" x2="19" y2="27" stroke={fg} strokeWidth="1.5"/>
</svg>
</div>
);
}
function ArrowBetween({ elapsed, startTime }) {
const reveal = interpolate(elapsed, [startTime, startTime + 0.3], [0, 1]);
return (
<div style={{width: 48, display:'flex', alignItems:'center',
justifyContent:'center', position:'relative'}}>
<svg width="48" height="24" viewBox="0 0 48 24" style={{opacity: reveal}}>
<line x1="0" y1="12" x2={34 * reveal + 8} y2="12" stroke={TERRA} strokeWidth="1.5"/>
{reveal > 0.6 && (
<path d="M38 6 L44 12 L38 18" stroke={TERRA} strokeWidth="1.5" fill="none"
strokeLinecap="round" strokeLinejoin="round"/>
)}
</svg>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 4 (15 – 20s) · 产物:可编辑文本框
// ══════════════════════════════════════════════════════════
function Scene4_PPTEdit() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1]);
const pptScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
// Selection bounding box appears at 0.8s, handles animate in staggered
const selectOp = interpolate(elapsed, [0.9, 1.3], [0, 1]);
// Format panel slides in from right at 1.5s
const panelX = interpolate(elapsed, [1.6, 2.4], [80, 0], Easing.easeOut);
const panelOp = interpolate(elapsed, [1.6, 2.4], [0, 1]);
// Caption fades in 2.4s
const captionOp = interpolate(elapsed, [2.4, 3.0], [0, 1]);
// Checkboxes tick in sequentially
const chk1 = elapsed > 3.2 ? 1 : 0;
const chk2 = elapsed > 3.7 ? 1 : 0;
const chk3 = elapsed > 4.2 ? 1 : 0;
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM,
opacity: fadeIn * fadeOut,
display:'flex', flexDirection:'column', alignItems:'center',
padding:'60px 60px 40px'}}>
<div style={{width:'100%', display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 20}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA, marginBottom: 8}}>
<span>●</span> SCENE 04 · THE ARTIFACT
</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
产物:可编辑文本框
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right', maxWidth: 340, lineHeight: 1.5}}>
在 PowerPoint 里<br/>
像素级复现,字还是字
</div>
</div>
<div style={{position:'relative', transform: `scale(${pptScale})`,
transformOrigin:'center center'}}>
<PPTMockup selectOp={selectOp} />
{/* Format panel */}
<div style={{position:'absolute', top: 94, right: -296,
width: 272, background:'#f5f2ed', border:`1px solid ${LINE}`,
boxShadow:'0 12px 30px rgba(0,0,0,0.08)',
transform: `translateX(${panelX}px)`, opacity: panelOp,
padding: 0}}>
<FormatPanel />
</div>
</div>
<div style={{marginTop: 28, display:'flex', alignItems:'center', gap: 48,
opacity: captionOp}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: TERRA, letterSpacing:'0.01em'}}>
原生 PowerPoint 文本框 · 不是图片
</div>
<div style={{display:'flex', gap: 28, fontFamily: mono, fontSize: 13}}>
<CheckRow label="文字可编辑" on={chk1} />
<CheckRow label="字体保留" on={chk2} />
<CheckRow label="位置/颜色精确" on={chk3} />
</div>
</div>
</div>
);
}
function CheckRow({ label, on }) {
return (
<div style={{display:'flex', alignItems:'center', gap: 8}}>
<div style={{width: 18, height: 18, border:`1.5px solid ${on ? TERRA : LINE}`,
background: on ? TERRA : 'transparent',
display:'flex', alignItems:'center', justifyContent:'center',
transition:'none'}}>
{on ? (
<svg width="12" height="12" viewBox="0 0 12 12">
<path d="M2 6 L5 9 L10 3" stroke="#fff" strokeWidth="2" fill="none"
strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : null}
</div>
<span style={{color: on ? INK : ASH}}>{label}</span>
</div>
);
}
function PPTMockup({ selectOp }) {
const W = 1100, H = 620;
return (
<div style={{width: W, height: H, background:'#f4f1ec',
border:`1px solid ${LINE}`, boxShadow:'0 22px 50px rgba(0,0,0,0.14)',
display:'flex', flexDirection:'column'}}>
{/* PPT ribbon (title bar + tabs) */}
<div style={{height: 32, background:'#dcd7cd', display:'flex',
alignItems:'center', padding:'0 14px', gap: 8,
borderBottom:`1px solid ${LINE}`}}>
<div style={{width:10, height:10, borderRadius:'50%', background:'#ff5f57'}} />
<div style={{width:10, height:10, borderRadius:'50%', background:'#febc2e'}} />
<div style={{width:10, height:10, borderRadius:'50%', background:'#28c840'}} />
<div style={{flex: 1, textAlign:'center', fontFamily: sans, fontSize: 11,
color: ASH, letterSpacing:'0.02em'}}>
deck.pptx — PowerPoint
</div>
</div>
<div style={{height: 34, background:'#ebe7de', display:'flex',
alignItems:'center', padding:'0 18px', gap: 22,
fontFamily: sans, fontSize: 11, color: INK,
borderBottom:`1px solid ${LINE}`}}>
<span style={{color: TERRA, fontWeight: 600,
borderBottom: `2px solid ${TERRA}`, paddingBottom: 6,
marginBottom: -7}}>Home</span>
<span style={{opacity: 0.55}}>Insert</span>
<span style={{opacity: 0.55}}>Design</span>
<span style={{opacity: 0.55}}>Transitions</span>
<span style={{opacity: 0.55}}>Animations</span>
<span style={{opacity: 0.55}}>Slide Show</span>
<span style={{opacity: 0.55}}>Review</span>
<span style={{opacity: 0.55}}>View</span>
</div>
{/* Body: slide panel (left) + slide canvas (main) */}
<div style={{flex: 1, display:'flex'}}>
{/* Thumbnails */}
<div style={{width: 160, background:'#eae5db',
borderRight:`1px solid ${LINE}`, padding:'12px 12px',
display:'flex', flexDirection:'column', gap: 8}}>
{[0,1,2,3].map(i => (
<div key={i} style={{
background:'#fff',
border: i === 2 ? `2px solid ${TERRA}` : `1px solid ${LINE}`,
aspectRatio:'16/9', position:'relative',
padding: 8, display:'flex', alignItems:'center',
justifyContent:'center'}}>
<div style={{position:'absolute', top: 4, left: 4,
fontFamily: mono, fontSize: 8, color: ASH}}>{i+1}</div>
{i === 2 && (
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
color: INK}}>"Less..."</div>
)}
{i !== 2 && (
<div style={{width:'70%', height: 3, background: LINE}} />
)}
</div>
))}
</div>
{/* Slide canvas */}
<div style={{flex: 1, background:'#e8e4dc', display:'flex',
alignItems:'center', justifyContent:'center', padding: 32,
position:'relative'}}>
<div style={{width: 720, height: 405, background:'#faf6ef',
boxShadow:'0 8px 24px rgba(0,0,0,0.1)',
border:`1px solid ${LINE}`, position:'relative'}}>
{/* The editable text box */}
<div style={{position:'absolute', top:'50%', left:'50%',
transform:'translate(-50%, -50%)', textAlign:'center',
padding:'18px 40px'}}>
<div style={{fontFamily: serif, fontStyle:'italic',
fontSize: 72, color: INK, lineHeight: 1.1,
letterSpacing:'-0.01em'}}>
"Less, but <span style={{color: TERRA}}>better</span>."
</div>
<div style={{fontFamily: serif, fontSize: 14, color: ASH,
marginTop: 14, letterSpacing:'0.1em'}}>
— Dieter Rams
</div>
</div>
{/* Selection bounding box + 8 handles */}
{selectOp > 0 && <SelectionBox opacity={selectOp} />}
{/* slide number */}
<div style={{position:'absolute', bottom: 10, right: 14,
fontFamily: sans, fontSize: 9, color: ASH}}>3</div>
</div>
</div>
</div>
</div>
);
}
function SelectionBox({ opacity }) {
// Box centered around the textbox (~ 520×160)
const BW = 560, BH = 170;
const color = '#4a9eff';
const handles = [
{ x: 0, y: 0 }, { x: 0.5, y: 0 }, { x: 1, y: 0 },
{ x: 0, y: 0.5 }, { x: 1, y: 0.5 },
{ x: 0, y: 1 }, { x: 0.5, y: 1 }, { x: 1, y: 1 },
];
return (
<div style={{position:'absolute', top:'50%', left:'50%',
width: BW, height: BH, transform:'translate(-50%, -50%)',
border: `1.5px solid ${color}`, opacity,
boxShadow:`0 0 0 1px rgba(255,255,255,0.6)`, pointerEvents:'none'}}>
{handles.map((h, i) => (
<div key={i} style={{position:'absolute',
left: `${h.x * 100}%`, top: `${h.y * 100}%`,
transform:'translate(-50%, -50%)',
width: 10, height: 10, background:'#fff',
border: `1.5px solid ${color}`, borderRadius: 2}} />
))}
{/* Rotate handle */}
<div style={{position:'absolute', left:'50%', top: -26,
transform:'translateX(-50%)',
width: 10, height: 10, background:'#fff',
border: `1.5px solid ${color}`, borderRadius: '50%'}} />
<div style={{position:'absolute', left:'50%', top: -17,
transform:'translateX(-50%)', width: 1, height: 9, background: color}} />
{/* Label: Text Box */}
<div style={{position:'absolute', top: -26, left: 0,
fontFamily: mono, fontSize: 10, color: color,
background:'rgba(255,255,255,0.9)', padding:'2px 6px',
letterSpacing:'0.1em'}}>
TEXT BOX · shape #1
</div>
</div>
);
}
function FormatPanel() {
return (
<div>
<div style={{padding:'12px 16px', borderBottom:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
background:'#ebe7de'}}>
<div style={{fontFamily: sans, fontSize: 12, color: INK,
fontWeight: 600}}>Format Text</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH}}>✕</div>
</div>
<div style={{padding:'16px'}}>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 8}}>FONT</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'8px 10px', marginBottom: 14,
display:'flex', justifyContent:'space-between', alignItems:'center',
fontFamily: sans, fontSize: 12, color: INK}}>
<span style={{fontFamily: serif, fontStyle:'italic'}}>Newsreader</span>
<span style={{color: ASH, fontSize: 10}}>▾</span>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 10,
marginBottom: 14}}>
<div>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 6}}>SIZE</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'6px 10px', fontFamily: sans, fontSize: 12}}>72 pt</div>
</div>
<div>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 6}}>WEIGHT</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'6px 10px', fontFamily: sans, fontSize: 12}}>400 · italic</div>
</div>
</div>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 6}}>COLOR</div>
<div style={{display:'flex', gap: 8, marginBottom: 14}}>
<div style={{width: 28, height: 28, background: INK, border:`2px solid ${INK}`}} />
<div style={{width: 28, height: 28, background: TERRA,
outline:`2px solid ${TERRA}`, outlineOffset: 1}} />
<div style={{width: 28, height: 28, background: OLIVE}} />
<div style={{width: 28, height: 28, background: DEEP_BLUE}} />
<div style={{width: 28, height: 28, background:'#fff', border:`1px solid ${LINE}`}} />
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.1em', lineHeight: 1.6,
paddingTop: 10, borderTop:`1px solid ${LINE}`}}>
x: 2.4in · y: 2.1in<br/>
w: 5.8in · h: 1.7in
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 5 (20 – 24s) · 收尾
// ══════════════════════════════════════════════════════════
function Scene5_Final() {
const { elapsed } = useSprite();
const tagOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const mainY = interpolate(elapsed, [0.2, 1.2], [50, 0], Easing.easeOut);
const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
const lineW = interpolate(elapsed, [1.1, 1.8], [0, 540]);
const subOp = interpolate(elapsed, [1.5, 2.2], [0, 1]);
const monoOp = interpolate(elapsed, [2.2, 2.8], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 32, opacity: tagOp}}>
ONE SOURCE · TWO STATES
</div>
<div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
color: INK, lineHeight: 0.98, letterSpacing:'-0.03em',
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
一<span style={{color: ASH, fontStyle:'italic'}}>源</span>
<span style={{color: TERRA, margin:'0 28px'}}>·</span>
双<span style={{color: ASH, fontStyle:'italic'}}>态</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 46}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 28,
color: ASH, marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
浏览器里演讲 · PowerPoint 里二次编辑
</div>
<div style={{fontFamily: mono, fontSize: 18, color: INK, marginTop: 34,
opacity: monoOp, letterSpacing:'0.1em',
padding:'12px 28px', background:'#fff', border:`1px solid ${LINE}`}}>
<span style={{color: OLIVE}}>deck.html</span>
<span style={{color: TERRA, margin:'0 14px'}}>⇌</span>
<span style={{color: DEEP_BLUE}}>deck.pptx</span>
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={9}><Scene2_DeckFlip /></Sprite>
<Sprite start={9} end={15}><Scene3_Pipeline /></Sprite>
<Sprite start={15} end={20}><Scene4_PPTEdit /></Sprite>
<Sprite start={20} end={24}><Scene5_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+556
View File
@@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Motion Design</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Title (0 – 3s) ────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const titleY = interpolate(elapsed, [0, 1.2], [60, 0], Easing.easeOut);
const titleOp = interpolate(elapsed, [0, 0.8], [0, 1]);
const subOp = interpolate(elapsed, [0.6, 1.4], [0, 1]);
const lineW = interpolate(elapsed, [0.9, 1.6], [0, 520]);
const apiOp = interpolate(elapsed, [1.4, 2.2], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 24, opacity: titleOp}}>
动画引擎 · Stage + Sprite
</div>
<div style={{fontFamily: serif, fontSize: 148, fontWeight: 500, color: INK,
lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
transform: `translateY(${titleY}px)`}}>
<span style={{fontStyle:'italic', color: TERRA}}>Motion</span> Design
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24, color: ASH,
marginTop: 28, opacity: subOp}}>
时间驱动 · 可编排 · 60fps 导出
</div>
<div style={{fontFamily: mono, fontSize: 14, color: ASH,
marginTop: 40, opacity: apiOp, letterSpacing:'0.1em'}}>
&lt;Stage&gt; · &lt;Sprite&gt; · useTime() · useSprite() · interpolate() · Easing
</div>
</div>
);
}
// ── Scene 2: Easing functions comparison (3 – 8s) ────────
function Scene2_Easing() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
// Lane sweep cycle every 2s
const cycle = (elapsed % 2.2) / 2.0;
const sweepT = Math.min(1, Math.max(0, cycle));
const curves = [
{ name: 'linear', label: 'linear', fn: Easing.linear, color: ASH },
{ name: 'easeOut', label: 'easeOut', fn: Easing.easeOut, color: OLIVE },
{ name: 'spring', label: 'spring', fn: Easing.spring, color: TERRA },
{ name: 'easeInOut', label: 'easeInOut', fn: Easing.easeInOut, color: DEEP_BLUE },
];
const trackLeft = 320;
const trackRight = 1480;
const trackLen = trackRight - trackLeft;
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '80px 100px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 50,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>场景 1 · EASING</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
四种缓动曲线同跑
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right'}}>
同样的 2 秒,<br/>
走出四种不同的「节奏感」
</div>
</div>
<div style={{flex: 1, position:'relative'}}>
{curves.map((c, i) => {
const y = 80 + i * 140;
const t = c.fn(sweepT);
const x = trackLeft + trackLen * t;
// Draw the curve as a mini sparkline right of track
const sparkW = 160, sparkH = 50;
const sparkPts = Array.from({length: 30}, (_, k) => {
const tx = k / 29;
const ty = 1 - c.fn(tx);
return `${tx * sparkW},${ty * sparkH}`;
}).join(' ');
return (
<div key={i}>
{/* Label (left) */}
<div style={{position:'absolute', left: 0, top: y - 22, width: 280,
fontFamily: mono, fontSize: 14, color: INK, letterSpacing:'0.05em'}}>
<span style={{color: c.color, marginRight: 12}}>●</span>
Easing.<span style={{color: c.color}}>{c.label}</span>
</div>
{/* Track */}
<div style={{position:'absolute', left: trackLeft, top: y,
width: trackLen, height: 2, background: LINE}} />
{/* Dot */}
<div style={{position:'absolute', left: x - 14, top: y - 14,
width: 28, height: 28, borderRadius: '50%',
background: c.color,
boxShadow: `0 4px 12px ${c.color}55`}} />
{/* Sparkline */}
<svg style={{position:'absolute', left: trackRight + 60, top: y - sparkH/2 - 5,
width: sparkW, height: sparkH}}>
<polyline points={sparkPts} stroke={c.color} strokeWidth="1.5" fill="none" />
<circle cx={sweepT * sparkW} cy={(1 - c.fn(sweepT)) * sparkH}
r="3.5" fill={c.color} />
</svg>
</div>
);
})}
</div>
{/* Timeline at bottom */}
<div style={{marginTop: 10, position:'relative', height: 30}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.15em'}}>
t = <span style={{color: INK}}>{sweepT.toFixed(2)}</span> &nbsp; · &nbsp; 周期 2.0s
</div>
</div>
</div>
);
}
// ── Scene 3: interpolate() function demo (8 – 14s) ───────
function Scene3_Interpolate() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
// Animated t value: 0→1 cycle over ~3s
const cycle = (elapsed % 3.2) / 3.0;
const t = Math.min(1, Math.max(0, cycle));
// Three mapped outputs from same t:
const opacity = interpolate(t, [0, 1], [0, 1]);
const scale = interpolate(t, [0, 1], [0.4, 1.2], Easing.spring);
const rotation = interpolate(t, [0, 1], [-30, 30], Easing.easeInOut);
const translateX = interpolate(t, [0, 0.5, 1], [-80, 40, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '80px 100px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 40,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>场景 2 · INTERPOLATE</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
一个 <span style={{fontStyle:'italic', color: TERRA}}>t</span>,四种变化
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right'}}>
用同一条时间轴,<br/>
映射出透明度、尺寸、旋转、位移
</div>
</div>
{/* t value progress bar */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding: '20px 32px', marginBottom: 30}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 14}}>
<div style={{fontFamily: mono, fontSize: 13, color: INK}}>
<span style={{color: ASH}}>const</span> t = <span style={{color: TERRA}}>{t.toFixed(3)}</span>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.15em'}}>
时间 → 0 到 1
</div>
</div>
<div style={{height: 4, background: LINE, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${t * 100}%`, background: TERRA}} />
</div>
</div>
{/* Four demos */}
<div style={{flex: 1, display:'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 24}}>
{[
{ name: 'opacity', code: 'interpolate(t, [0,1], [0,1])', val: opacity, render:
<div style={{width: 120, height: 120, background: TERRA, opacity}} /> },
{ name: 'scale + spring', code: 'interpolate(t, [0,1], [0.4,1.2], spring)', val: scale, render:
<div style={{width: 120, height: 120, background: OLIVE,
transform: `scale(${scale})`}} /> },
{ name: 'rotate', code: 'interpolate(t, [0,1], [-30,30], easeInOut)', val: rotation, render:
<div style={{width: 120, height: 120, background: DEEP_BLUE,
transform: `rotate(${rotation}deg)`}} /> },
{ name: 'translateX (3 stops)', code: 'interpolate(t, [0,.5,1], [-80,40,0])', val: translateX, render:
<div style={{width: 120, height: 120, background: INK,
transform: `translateX(${translateX}px)`}} /> },
].map((d, i) => (
<div key={i} style={{background:'#fff', border:`1px solid ${LINE}`,
padding: '18px 18px 14px', display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
letterSpacing:'0.2em', marginBottom: 6}}>0{i+1}</div>
<div style={{fontFamily: serif, fontSize: 18, fontWeight: 500, color: INK,
marginBottom: 6}}>{d.name}</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
marginBottom: 20, minHeight: 32, wordBreak:'break-all', lineHeight: 1.5}}>
{d.code}
</div>
<div style={{flex: 1, display:'flex', alignItems:'center',
justifyContent:'center', overflow:'hidden'}}>
{d.render}
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
marginTop: 12, textAlign:'right'}}>
= <span style={{color: TERRA}}>{d.val.toFixed(2)}</span>
</div>
</div>
))}
</div>
</div>
);
}
// ── Scene 4: Sprite sequencing on timeline (14 – 20s) ───
function Scene4_Sprite() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
// Timeline plays 6s
const localTime = Math.min(elapsed, 5.6);
const sprites = [
{ name: 'Title', start: 0.0, end: 2.5, color: TERRA, y: 0, label: '标题' },
{ name: 'Image', start: 0.8, end: 3.5, color: OLIVE, y: 1, label: '图像淡入' },
{ name: 'Text', start: 1.8, end: 4.5, color: DEEP_BLUE, y: 2, label: '正文' },
{ name: 'Outro', start: 4.0, end: 5.5, color: '#8b4a2b', y: 3, label: '结尾' },
];
const timelineLeft = 100;
const timelineRight = 1820;
const timelineW = timelineRight - timelineLeft;
const totalDur = 5.6;
const cursorX = timelineLeft + (localTime / totalDur) * timelineW;
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '80px 100px 60px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 30,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>场景 3 · SPRITE 编排</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
时间片段 · <span style={{fontStyle:'italic'}}>同台起舞</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right'}}>
每个 &lt;Sprite start=... end=...&gt;<br/>
在自己的时间窗口出场、退场
</div>
</div>
{/* Live visualization area */}
<div style={{background:'#fff', border:`1px solid ${LINE}`, flex: 1,
position:'relative', overflow:'hidden', marginBottom: 30}}>
{sprites.map((s, i) => {
const active = localTime >= s.start && localTime < s.end;
if (!active) return null;
const localT = (localTime - s.start) / (s.end - s.start);
const op = interpolate(localT, [0, 0.15, 0.85, 1], [0, 1, 1, 0]);
const ty = interpolate(localT, [0, 0.2], [30, 0], Easing.easeOut);
if (s.name === 'Title') {
return (
<div key={i} style={{position:'absolute', top: 60, left: 80, right: 80,
opacity: op, transform: `translateY(${ty}px)`}}>
<div style={{fontFamily: mono, fontSize: 10, color: s.color,
letterSpacing:'0.3em', marginBottom: 10}}>CHAPTER 01</div>
<div style={{fontFamily: serif, fontSize: 64, fontWeight: 500,
color: INK, lineHeight: 1.05, letterSpacing:'-0.01em'}}>
如何让动画 <span style={{fontStyle:'italic', color: s.color}}>好看</span>
</div>
</div>
);
}
if (s.name === 'Image') {
return (
<div key={i} style={{position:'absolute', top: 60, right: 80,
width: 380, height: 240, opacity: op,
transform: `translateY(${ty}px)`,
background: `linear-gradient(135deg, ${s.color}, ${s.color}88 50%, ${s.color}33)`,
overflow: 'hidden'}}>
<div style={{position:'absolute', inset: 0,
background: `radial-gradient(circle at 30% 30%, ${s.color}aa, transparent 50%)`}} />
<div style={{position:'absolute', bottom: 14, left: 16,
fontFamily: mono, fontSize: 9, color: '#fff',
letterSpacing:'0.2em', opacity: 0.8}}>
IMAGE · FADE-IN
</div>
</div>
);
}
if (s.name === 'Text') {
return (
<div key={i} style={{position:'absolute', bottom: 80, left: 80, right: 80,
opacity: op, transform: `translateY(${ty}px)`}}>
<div style={{fontFamily: mono, fontSize: 10, color: s.color,
letterSpacing:'0.3em', marginBottom: 10}}>BODY</div>
<div style={{fontFamily: serif, fontSize: 20, color: INK,
lineHeight: 1.55, maxWidth: 720}}>
好的 motion 不是每个元素都在抢戏——是<span style={{fontStyle:'italic'}}>一个
</span>进、<span style={{fontStyle:'italic'}}>一个</span>退、留足呼吸,
最后合奏收尾。
</div>
</div>
);
}
if (s.name === 'Outro') {
return (
<div key={i} style={{position:'absolute', inset: 0,
opacity: op, display:'flex', alignItems:'center',
justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: serif, fontSize: 88, fontWeight: 500,
color: s.color, fontStyle:'italic', letterSpacing:'-0.01em'}}>
— fin —
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.3em', marginTop: 14}}>
4 SPRITES · 5.5 SECONDS · 1 STAGE
</div>
</div>
);
}
})}
</div>
{/* Timeline viz (showing sprite spans) */}
<div style={{position:'relative', height: 110}}>
{/* Labels */}
{sprites.map((s, i) => (
<div key={i} style={{position:'absolute', left: 0, top: i * 22,
fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.05em'}}>
<span style={{color: s.color, marginRight: 8}}>●</span>{s.label}
</div>
))}
{/* Tracks */}
{sprites.map((s, i) => {
const x0 = timelineLeft + (s.start / totalDur) * timelineW;
const x1 = timelineLeft + (s.end / totalDur) * timelineW;
const active = localTime >= s.start && localTime < s.end;
return (
<div key={i} style={{position:'absolute',
left: x0, top: i * 22 - 2, width: x1 - x0, height: 16,
background: active ? s.color : `${s.color}55`,
borderLeft: `2px solid ${s.color}`}} />
);
})}
{/* Playhead cursor */}
<div style={{position:'absolute', left: cursorX - 1, top: -6,
width: 2, height: 110, background: INK, zIndex: 5}} />
<div style={{position:'absolute', left: cursorX - 20, top: -20,
fontFamily: mono, fontSize: 10, color: INK,
letterSpacing:'0.1em'}}>
{localTime.toFixed(2)}s
</div>
</div>
</div>
);
}
// ── Scene 5: Outro (20 – 22s) ─────────────────────────
function Scene5_Outro() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 620]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 20}}>
导出 · MP4 / GIF / 60FPS / BGM
</div>
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.015em'}}>
从 <span style={{fontStyle:'italic', color: TERRA}}>时间</span>,到成片
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
marginTop: 26, maxWidth: 800, textAlign:'center', lineHeight: 1.55}}>
render-video.js · convert-formats.sh · add-music.sh<br/>
一条命令跑完,产出社交媒体可直接用的素材
</div>
</div>
);
}
// ── Watermark ──────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={8}><Scene2_Easing /></Sprite>
<Sprite start={8} end={14}><Scene3_Interpolate /></Sprite>
<Sprite start={14} end={20}><Scene4_Sprite /></Sprite>
<Sprite start={20} end={22}><Scene5_Outro /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Tweaks 实时变体</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sansCN = "'Noto Sans SC', -apple-system, sans-serif";
const playfair = "'Playfair Display', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// Palettes — the actual Tweak presets
const PALETTES = {
warm: { bg: CREAM, accent: TERRA, text: INK, sub: ASH, line: LINE, name: '暖米 + 赤陶', enName: 'WARM · TERRA' },
olive: { bg: '#f2efdf', accent: OLIVE, text: '#2a2a1e', sub: '#7a7a5e', line: '#d4d1b8', name: '墨绿 + 鹅黄', enName: 'OLIVE · CITRON' },
deep: { bg: '#f4efe6', accent: DEEP_BLUE, text: INK, sub: '#5a6478', line: '#c9c3b3', name: '深蓝 + 沙金', enName: 'DEEP · SAND' },
};
const FONTS = {
serif: { ui: serif, display: serif, name: 'Newsreader(衬线)' },
sans: { ui: sansCN, display: sansCN, name: '思源黑体' },
play: { ui: playfair, display: playfair, name: 'Playfair Display' },
};
// ── Scene 1: Title (0 – 3s) ────────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
const mainY = interpolate(elapsed, [0.3, 1.3], [40, 0], Easing.easeOut);
const mainOp = interpolate(elapsed, [0.3, 1.1], [0, 1]);
const lineW = interpolate(elapsed, [1.1, 1.9], [0, 460]);
const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 14, letterSpacing:'0.35em',
color: TERRA, marginBottom: 30, opacity: labelOp}}>
实时变体 · TWEAKS
</div>
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
lineHeight: 1.05, letterSpacing: '-0.01em',
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
一个 HTML · <span style={{fontStyle:'italic', color: TERRA}}>多种</span>设计
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 22,
color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
不需要重新生成代码 · 只切参数
</div>
</div>
);
}
// ── Scene 2: Main stage — control panel + live card (3 – 12s) ──
function Scene2_MainStage() {
const { elapsed } = useSprite();
// Decide current Tweaks state based on elapsed time inside the scene.
// Scene2 elapsed: 0 – 9s
// 0 – 4s : warm + serif + 40
// 4 – 7s : olive + serif + 40 (palette change @ 4s)
// 7 – 9s : olive + sans + 40 (font change @ 7s)
let palette = 'warm';
let font = 'serif';
const density = 40;
if (elapsed >= 4) palette = 'olive';
if (elapsed >= 7) font = 'sans';
// Ripple trigger times
const rippleTimes = [4, 7];
const ripples = rippleTimes.map(t => {
const e = elapsed - t;
if (e < 0 || e > 1.2) return null;
return { t, progress: e / 1.2 };
}).filter(Boolean);
// Fade-in intro
const introOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [8.6, 9.0], [1, 0]);
const pal = PALETTES[palette];
const fnt = FONTS[font];
return (
<div style={{position:'absolute', inset:0, background:CREAM,
display:'flex', opacity: introOp * fadeOut}}>
{/* Left: Control Panel (30%) */}
<ControlPanel palette={palette} font={font} density={density} elapsed={elapsed} />
{/* Right: Live Card (70%) */}
<div style={{flex: 1, position:'relative', padding: '60px 80px',
display:'flex', alignItems:'center', justifyContent:'center',
transition: 'background 600ms ease-in-out',
background: pal.bg}}>
<LiveCard palette={palette} font={font} density={density} />
{/* Ripples */}
{ripples.map((r, i) => (
<Ripple key={r.t} progress={r.progress}
x={r.t === 4 ? 180 : 180}
y={r.t === 4 ? 340 : 490} />
))}
</div>
</div>
);
}
function ControlPanel({ palette, font, density, elapsed }) {
return (
<div style={{width: '30%', background: '#f2ece0',
borderRight: `1px solid ${LINE}`, padding: '60px 44px 40px',
display:'flex', flexDirection:'column', gap: 38,
fontFamily: sans}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.35em',
color: TERRA, marginBottom: 6}}>
TWEAKS
</div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
设计调参面板
</div>
</div>
{/* Group 1: palette */}
<ControlGroup label="01 · 配色方案" en="PALETTE">
<Radio checked={palette==='warm'} label="暖米 + 赤陶" swatches={[CREAM, TERRA]} />
<Radio checked={palette==='olive'} label="墨绿 + 鹅黄" swatches={['#f2efdf', OLIVE]} />
<Radio checked={palette==='deep'} label="深蓝 + 沙金" swatches={['#f4efe6', DEEP_BLUE]} />
</ControlGroup>
{/* Group 2: font */}
<ControlGroup label="02 · 字型" en="TYPEFACE">
<Radio checked={font==='serif'} label="Newsreader(衬线)" fontFamily={serif} />
<Radio checked={font==='sans'} label="思源黑体" fontFamily={sansCN} />
<Radio checked={font==='play'} label="Playfair Display" fontFamily={playfair} />
</ControlGroup>
{/* Group 3: density */}
<ControlGroup label="03 · 信息密度" en="DENSITY">
<div style={{position:'relative', height: 4, background:'#e0dbcc',
marginTop: 16, marginBottom: 10}}>
<div style={{position:'absolute', left: 0, top: 0, height:'100%',
width: `${density}%`, background: TERRA}} />
<div style={{position:'absolute', left: `${density}%`, top: -6,
transform:'translateX(-50%)', width: 16, height: 16,
borderRadius:'50%', background: TERRA,
boxShadow:'0 2px 6px rgba(0,0,0,0.15)'}} />
</div>
<div style={{display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em', color: ASH}}>
<span>克制</span><span style={{color: TERRA}}>标准</span><span>密集</span>
</div>
</ControlGroup>
<div style={{flex: 1}} />
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.12em',
color: ASH, lineHeight: 1.6, borderTop: `1px solid ${LINE}`,
paddingTop: 16}}>
localStorage 持久化<br/>
<span style={{color: TERRA}}>→</span> 刷新不丢
</div>
</div>
);
}
function ControlGroup({ label, en, children }) {
return (
<div>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 14}}>
<div style={{fontFamily: serif, fontSize: 15, fontWeight: 500, color: INK}}>
{label}
</div>
<div style={{fontFamily: mono, fontSize: 9, letterSpacing:'0.25em',
color: ASH}}>{en}</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 8}}>{children}</div>
</div>
);
}
function Radio({ checked, label, swatches, fontFamily }) {
return (
<div style={{display:'flex', alignItems:'center', gap: 12,
padding:'9px 12px', background: checked ? '#fff' : 'transparent',
border: `1px solid ${checked ? TERRA : 'transparent'}`,
transition:'all 240ms ease-out'}}>
<div style={{width: 14, height: 14, borderRadius:'50%',
border: `1.5px solid ${checked ? TERRA : '#b0a898'}`,
display:'flex', alignItems:'center', justifyContent:'center',
flexShrink: 0}}>
{checked && <div style={{width: 7, height: 7, borderRadius:'50%',
background: TERRA}} />}
</div>
<div style={{flex: 1, fontFamily: fontFamily || sans, fontSize: 13,
color: checked ? INK : '#4a4a4a'}}>{label}</div>
{swatches && (
<div style={{display:'flex', gap: 3}}>
{swatches.map((c, i) => (
<div key={i} style={{width: 12, height: 12, background: c,
border:'1px solid rgba(0,0,0,0.06)'}} />
))}
</div>
)}
</div>
);
}
function Ripple({ progress, x, y }) {
const size = progress * 420;
const op = 1 - progress;
return (
<div style={{position:'absolute', left: x, top: y,
width: size, height: size, borderRadius:'50%',
border: `2px solid ${TERRA}`, opacity: op,
transform: 'translate(-50%, -50%)',
pointerEvents:'none'}} />
);
}
function LiveCard({ palette, font, density }) {
const pal = PALETTES[palette];
const fnt = FONTS[font];
return (
<div style={{width: '100%', maxWidth: 880, background: '#fff',
border: `1px solid ${pal.line}`,
transition:'border-color 600ms ease-in-out',
position:'relative'}}>
{/* Header bar */}
<div style={{padding:'18px 32px', borderBottom:`1px solid ${pal.line}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
transition:'border-color 600ms ease-in-out'}}>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: pal.accent, transition:'color 600ms ease-in-out'}}>
LUMINA · v3.2
</div>
<div style={{fontFamily: mono, fontSize: 10, color: pal.sub,
letterSpacing:'0.15em', transition:'color 600ms ease-in-out'}}>
PALETTE · {pal.enName}
</div>
</div>
{/* Hero content */}
<div style={{padding:'56px 60px 48px', display:'grid',
gridTemplateColumns:'1.4fr 1fr', gap: 48}}>
<div>
<div style={{fontFamily: mono, fontSize: 11,
color: pal.accent, letterSpacing:'0.25em', marginBottom: 16,
transition:'color 600ms ease-in-out'}}>
READING · MEMORY
</div>
<div style={{fontFamily: fnt.display, fontSize: 68,
fontWeight: font === 'sans' ? 700 : 500, color: pal.text,
lineHeight: 1.05, letterSpacing:'-0.02em',
transition:'color 600ms ease-in-out',
marginBottom: 14}}>
Lumina
</div>
<div style={{fontFamily: fnt.ui,
fontStyle: font === 'play' ? 'italic' : 'normal',
fontSize: 22, color: pal.sub, lineHeight: 1.4,
letterSpacing: font === 'sans' ? 0 : '0.01em',
transition:'color 600ms ease-in-out',
marginBottom: 28}}>
阅读记忆 · 让每一次阅读被看见
</div>
<div style={{fontFamily: fnt.ui, fontSize: 14, color: pal.text,
lineHeight: 1.7, opacity: 0.78, marginBottom: 32,
transition:'color 600ms ease-in-out'}}>
把你读过的每一行、标注过的每一段,<br/>
汇成一条属于你的阅读河流。
</div>
<div style={{display:'flex', gap: 12, alignItems:'center'}}>
<div style={{padding:'12px 26px', background: pal.accent,
color:'#fff', fontFamily: fnt.ui, fontSize: 13,
letterSpacing: font === 'sans' ? '0.05em' : '0.12em',
transition:'background 600ms ease-in-out'}}>
{font === 'sans' ? '开始使用' : 'Start Reading'}
</div>
<div style={{fontFamily: mono, fontSize: 11, color: pal.sub,
letterSpacing:'0.2em',
transition:'color 600ms ease-in-out'}}>
FREE · BETA
</div>
</div>
</div>
{/* Right image block */}
<div style={{background: pal.bg,
border: `1px solid ${pal.line}`,
transition:'all 600ms ease-in-out',
aspectRatio: '3 / 4', position:'relative', overflow:'hidden'}}>
{/* Abstract book spine illustration */}
<svg width="100%" height="100%" viewBox="0 0 300 400"
preserveAspectRatio="xMidYMid slice">
{[0,1,2,3,4,5].map(i => (
<rect key={i} x={40 + i * 35} y={60 + (i % 2) * 20}
width={26} height={280 - (i % 3) * 30}
fill="none" stroke={pal.accent}
strokeWidth={i === 2 ? 2 : 1}
opacity={0.55 + (i === 2 ? 0.4 : 0)}
style={{transition:'stroke 600ms ease-in-out'}} />
))}
<circle cx={150} cy={200} r={58} fill="none"
stroke={pal.accent} strokeWidth={1.5} opacity={0.5}
style={{transition:'stroke 600ms ease-in-out'}} />
<line x1={40} y1={350} x2={260} y2={350}
stroke={pal.text} strokeWidth={0.8} opacity={0.5}
style={{transition:'stroke 600ms ease-in-out'}} />
</svg>
<div style={{position:'absolute', bottom: 16, left: 18,
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em',
color: pal.sub,
transition:'color 600ms ease-in-out'}}>
FIG. 01 — SHELF
</div>
</div>
</div>
{/* Footer meta */}
<div style={{padding:'14px 32px', borderTop:`1px solid ${pal.line}`,
display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
color: pal.sub,
transition:'all 600ms ease-in-out'}}>
<span>DENSITY · {density}</span>
<span>FONT · {font.toUpperCase()}</span>
<span>TWEAK ID · #{palette}-{font}-{density}</span>
</div>
</div>
);
}
// ── Scene 3: Code view (12 – 17s) ─────────────────────────
function Scene3_CodeView() {
const { elapsed } = useSprite();
const introOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
// Code typing effect
const fullCode = `// Tweaks via localStorage + CSS vars
const tweaks = {
palette: 'warm', // ← user 选
font: 'serif',
density: 40,
};
document.documentElement.style
.setProperty(
'--accent',
PALETTES[tweaks.palette].accent
);
localStorage.setItem(
'tweaks', JSON.stringify(tweaks)
);`;
const typeProgress = Math.max(0, Math.min(1, (elapsed - 0.6) / 2.4));
const visibleChars = Math.floor(fullCode.length * typeProgress);
const visibleCode = fullCode.slice(0, visibleChars);
const cursorBlink = Math.floor(elapsed * 2.5) % 2 === 0 && typeProgress < 1;
return (
<div style={{position:'absolute', inset:0, background:CREAM,
display:'flex', flexDirection:'column', opacity: introOp * fadeOut,
padding:'60px 80px'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 36}}>
<div>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.35em',
color: TERRA, marginBottom: 6}}>
UNDER THE HOOD
</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
原理 · <span style={{fontStyle:'italic', color: TERRA}}>一行配置</span>,无限变体
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', lineHeight: 1.5}}>
纯前端 · 无后端依赖<br/>
<span style={{fontSize: 14}}>刷新保留状态</span>
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1.4fr', gap: 40,
flex: 1}}>
{/* Left: simplified tweak visualization */}
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding: 36, display:'flex', flexDirection:'column', gap: 28}}>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA}}>TWEAK · STATE</div>
<MiniRow label="palette" value="warm" swatch={TERRA} />
<MiniRow label="font" value="serif" />
<MiniRow label="density" value="40" />
<div style={{height: 1, background: LINE, margin:'8px 0'}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 16,
color: ASH, lineHeight: 1.55}}>
三个参数的组合空间:<br/>
<span style={{color: TERRA, fontFamily: mono, fontStyle:'normal',
fontSize: 14}}>3 × 3 × ∞ = 无限</span>
</div>
<div style={{flex: 1}} />
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em', lineHeight: 1.7}}>
→ 改代码:不必要<br/>
→ 重新生成:不必要<br/>
→ 只改变量:30ms 生效
</div>
</div>
{/* Right: code block */}
<div style={{background:'#0e1016', padding:'28px 32px',
fontFamily: mono, fontSize: 15, color:'#d4c9b5',
lineHeight: 1.7, position:'relative', overflow:'hidden'}}>
<div style={{display:'flex', gap: 8, marginBottom: 20}}>
<div style={{width: 10, height: 10, borderRadius:'50%',
background:'#ff5f57'}} />
<div style={{width: 10, height: 10, borderRadius:'50%',
background:'#febc2e'}} />
<div style={{width: 10, height: 10, borderRadius:'50%',
background:'#28c840'}} />
<div style={{marginLeft: 14, fontSize: 10, color:'#888',
letterSpacing:'0.15em'}}>tweaks.js</div>
</div>
<pre style={{whiteSpace:'pre-wrap', margin: 0,
fontFamily: mono, fontSize: 15, lineHeight: 1.65}}>
<CodeColorize text={visibleCode} />
{cursorBlink && <span style={{color:'#ff6a3d'}}>▌</span>}
</pre>
</div>
</div>
</div>
);
}
function MiniRow({ label, value, swatch }) {
return (
<div style={{display:'flex', alignItems:'center', gap: 14}}>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.2em', width: 80}}>{label}</div>
<div style={{flex: 1, fontFamily: mono, fontSize: 14, color: INK}}>
{value}
</div>
{swatch && (
<div style={{width: 14, height: 14, background: swatch}} />
)}
</div>
);
}
// Very light syntax coloring
function CodeColorize({ text }) {
const lines = text.split('\n');
return (
<>
{lines.map((line, i) => (
<span key={i}>
{colorizeLine(line)}
{'\n'}
</span>
))}
</>
);
}
function colorizeLine(line) {
const parts = [];
let rest = line;
// comment
const cIdx = rest.indexOf('//');
if (cIdx >= 0) {
const before = rest.slice(0, cIdx);
const comment = rest.slice(cIdx);
return (
<>
{tokenize(before)}
<span style={{color:'#6a7d6a'}}>{comment}</span>
</>
);
}
return tokenize(line);
}
function tokenize(s) {
// keywords + strings
const kw = ['const', 'let', 'var', 'function', 'return'];
const words = s.split(/(\s+|[{}();,=.:'])/);
return words.map((w, i) => {
if (kw.includes(w)) return <span key={i} style={{color:'#c79cff'}}>{w}</span>;
if (/^'[^']*'$/.test(w)) return <span key={i} style={{color:'#ffb86c'}}>{w}</span>;
if (/^[0-9]+$/.test(w)) return <span key={i} style={{color:'#ff6a3d'}}>{w}</span>;
if (['palette', 'font', 'density', 'tweaks', 'PALETTES', 'accent'].includes(w)) {
return <span key={i} style={{color:'#8be9fd'}}>{w}</span>;
}
if (['document', 'localStorage'].includes(w)) {
return <span key={i} style={{color:'#ff79c6'}}>{w}</span>;
}
return <span key={i}>{w}</span>;
});
}
// ── Scene 4: Finale (17 – 20s) ────────────────────────────
function Scene4_Final() {
const { elapsed } = useSprite();
const labelOp = interpolate(elapsed, [0.1, 0.7], [0, 1]);
const mainY = interpolate(elapsed, [0.2, 1.2], [30, 0], Easing.easeOut);
const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
const dimsOp = interpolate(elapsed, [1.3, 2.1], [0, 1]);
const dimensions = ['配色', '字型', '密度', '布局', '动画速度'];
return (
<div style={{position:'absolute', inset:0, background:CREAM,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 26, opacity: labelOp}}>
TWEAKS · EVERYTHING IS A VARIABLE
</div>
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
color: INK, lineHeight: 1.05, letterSpacing:'-0.01em',
opacity: mainOp, transform: `translateY(${mainY}px)`,
textAlign:'center'}}>
一个源文件 · <span style={{fontStyle:'italic', color: TERRA}}>无限</span>变体
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 38}} />
<div style={{marginTop: 36, display:'flex', gap: 10,
opacity: dimsOp, alignItems:'center'}}>
{dimensions.map((d, i) => (
<React.Fragment key={i}>
{i > 0 && (
<span style={{fontFamily: mono, fontSize: 14,
color: LINE, margin:'0 2px'}}>·</span>
)}
<span style={{fontFamily: mono, fontSize: 14,
letterSpacing:'0.2em',
color: i === 0 ? TERRA : ASH,
padding:'6px 14px',
border: `1px solid ${i === 0 ? TERRA : LINE}`}}>
{d}
</span>
</React.Fragment>
))}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
color: ASH, marginTop: 36, opacity: dimsOp,
maxWidth: 720, textAlign:'center', lineHeight: 1.5}}>
设计不是一次性的结果 ——<br/>
而是一组可以随时拨动的旋钮
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={20} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={12}><Scene2_MainStage /></Sprite>
<Sprite start={12} end={17}><Scene3_CodeView /></Sprite>
<Sprite start={17} end={20}><Scene4_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+767
View File
@@ -0,0 +1,767 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Infographic Demo</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Title (0 – 3s) ───────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const topOp = interpolate(elapsed, [0.0, 0.6], [0, 1]);
const topLineW = interpolate(elapsed, [0.3, 1.0], [0, 220]);
const mainOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
const mainY = interpolate(elapsed, [0.5, 1.3], [32, 0], Easing.easeOut);
const italicOp = interpolate(elapsed, [1.0, 1.6], [0, 1]);
const subOp = interpolate(elapsed, [1.5, 2.1], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{display:'flex', alignItems:'center', gap: 18, opacity: topOp, marginBottom: 48}}>
<div style={{height: 1, background: TERRA, width: topLineW}}/>
<div style={{fontFamily: mono, fontSize: 12, color: TERRA,
letterSpacing:'0.35em'}}>
信息图 · 数据驱动 · 印刷级
</div>
<div style={{height: 1, background: TERRA, width: topLineW}}/>
</div>
<div style={{fontFamily: serif, fontSize: 160, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing: '-0.02em',
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
让数据<span style={{fontStyle:'italic', color: TERRA, opacity: italicOp}}>说话</span>
</div>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 24,
color: ASH, marginTop: 44, opacity: subOp, letterSpacing: '0.02em'}}>
精确排版 · 一眼看懂 · 可印刷
</div>
</div>
);
}
// ── Scene 2: Full infographic layout (3 – 10s) ────────────
// Uses a magazine spread style: headline, three columns (big numbers / bars / pie), trend line footer
function Scene2_Spread() {
const { elapsed } = useSprite();
const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const ruleW = interpolate(elapsed, [0.3, 1.0], [0, 1800]);
const colDelay = [0.6, 1.0, 1.4];
return (
<div style={{position:'absolute', inset:0, background: CREAM,
padding: '60px 80px 50px', display:'flex', flexDirection:'column'}}>
{/* Masthead */}
<div style={{opacity: headerOp, display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 14, fontFamily: mono, fontSize: 11,
letterSpacing: '0.3em', color: ASH}}>
<span>HUASHU · INFOGRAPHIC REPORT</span>
<span>VOL. 01 · 2026.04</span>
</div>
{/* Headline */}
<div style={{opacity: headerOp, fontFamily: serif, fontSize: 72,
fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing: '-0.01em',
marginBottom: 8}}>
2026 AI 写作工具
<span style={{fontStyle:'italic', color: TERRA, marginLeft: 20}}>年度观察</span>
</div>
<div style={{opacity: headerOp, fontFamily: serif, fontStyle:'italic',
fontSize: 20, color: ASH, marginBottom: 22}}>
156 位创作者匿名问卷 · 3 月 15 日 – 4 月 10 日
</div>
{/* Top rule */}
<div style={{width: ruleW, height: 1, background: INK, marginBottom: 28}}/>
{/* Three-column grid */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.15fr 1px 0.95fr',
gap: 36, flex: 1}}>
<ColumnLeft elapsed={elapsed - colDelay[0]} />
<div style={{background: LINE}}/>
<ColumnMid elapsed={elapsed - colDelay[1]} />
<div style={{background: LINE}}/>
<ColumnRight elapsed={elapsed - colDelay[2]} />
</div>
{/* Footer trend line */}
<FooterTrend elapsed={elapsed - 3.5} />
</div>
);
}
function ColumnLeft({ elapsed }) {
const e = Math.max(0, elapsed);
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
// 87%
const n1 = Math.round(interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut));
const bar1 = interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut);
// 3.2x
const n2 = interpolate(e, [1.3, 2.8], [1.0, 3.2], Easing.easeOut);
const bar2 = interpolate(e, [1.3, 2.8], [0, 3.2/5*100], Easing.easeOut);
// 156
const n3 = Math.round(interpolate(e, [2.3, 3.6], [0, 156], Easing.easeOut));
const bar3 = interpolate(e, [2.3, 3.6], [0, 100], Easing.easeOut);
return (
<div style={{display:'flex', flexDirection:'column', gap: 30}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color: TERRA, opacity: labelOp}}>
COLUMN / 01 · 核心指标
</div>
<MetricRow
value={`${n1}%`}
width={`${bar1}%`}
label="用户每周使用 AI 辅助写作"
note="¹ 每周 ≥ 3 次"
color={TERRA}
/>
<MetricRow
value={`${n2.toFixed(1)}×`}
width={`${bar2}%`}
label="平均产出效率提升"
note="² 自述周稿字数"
color={OLIVE}
/>
<MetricRow
value={String(n3)}
width={`${bar3}%`}
label="有效样本数"
note="³ 剔除 AI 默认答卷"
color={DEEP_BLUE}
/>
</div>
);
}
function MetricRow({ value, width, label, note, color }) {
return (
<div>
<div style={{display:'flex', alignItems:'baseline', gap: 10, marginBottom: 8}}>
<div style={{fontFamily: serif, fontSize: 72, fontWeight: 500,
color: INK, lineHeight: 0.95, letterSpacing:'-0.02em',
fontVariantNumeric:'tabular-nums'}}>
{value}
</div>
</div>
<div style={{height: 6, background: '#eee7d7', width:'100%',
marginBottom: 10, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width, background: color}}/>
</div>
<div style={{fontFamily: serif, fontSize: 15, color: INK, lineHeight: 1.4}}>
{label}
<span style={{fontFamily: mono, fontSize: 9, color: ASH,
verticalAlign:'super', marginLeft: 4}}>{note}</span>
</div>
</div>
);
}
function ColumnMid({ elapsed }) {
const e = Math.max(0, elapsed);
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
// 5 bars, staggered 0.15s
const bars = [
{ name:'长文创作', pct: 78, color: TERRA },
{ name:'短内容', pct: 64, color: OLIVE },
{ name:'标题/文案', pct: 52, color: DEEP_BLUE },
{ name:'润色校对', pct: 41, color: ASH },
{ name:'翻译', pct: 29, color: ASH },
];
const chartH = 320;
const maxPct = 100;
return (
<div style={{display:'flex', flexDirection:'column', gap: 18}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color: TERRA, opacity: labelOp}}>
COLUMN / 02 · 用途分布
</div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
color: INK, lineHeight: 1.2, opacity: labelOp,
letterSpacing: '-0.01em'}}>
你最常用 AI 做什么?
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH, opacity: labelOp}}>
多选题 · 百分比占全样本
</div>
{/* chart */}
<div style={{position:'relative', height: chartH, display:'flex',
alignItems:'flex-end', gap: 18, padding: '0 8px', marginTop: 4,
borderBottom:`1px solid ${INK}`}}>
{/* y-axis gridlines */}
{[25, 50, 75, 100].map(v => (
<div key={v} style={{position:'absolute', left: 0, right: 0,
bottom: (v/maxPct)*chartH, height: 1,
borderTop:`1px dashed ${LINE}`, pointerEvents:'none'}}>
<div style={{position:'absolute', left: -36, top: -8,
fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.05em'}}>
{v}%
</div>
</div>
))}
{bars.map((b, i) => {
const delay = 0.8 + i * 0.15;
const growT = Math.max(0, Math.min(1, (e - delay) / 0.55));
const h = growT * (b.pct / maxPct) * chartH;
const labelOpB = Math.max(0, Math.min(1, (e - delay - 0.35) / 0.3));
return (
<div key={i} style={{flex: 1, position:'relative',
display:'flex', flexDirection:'column', alignItems:'center',
justifyContent:'flex-end', height:'100%'}}>
<div style={{position:'absolute', top: -22,
fontFamily: mono, fontSize: 11, color: INK,
letterSpacing:'0.02em', fontVariantNumeric:'tabular-nums',
opacity: labelOpB}}>
{b.pct}%
</div>
<div style={{width: '100%', height: h, background: b.color,
transition:'none'}}/>
</div>
);
})}
</div>
{/* x-axis labels */}
<div style={{display:'flex', gap: 18, padding: '0 8px', marginTop: -8}}>
{bars.map((b, i) => (
<div key={i} style={{flex: 1, textAlign:'center',
fontFamily: serif, fontSize: 12, color: INK,
letterSpacing:'0.02em', opacity: labelOp}}>
{b.name}
</div>
))}
</div>
</div>
);
}
function ColumnRight({ elapsed }) {
const e = Math.max(0, elapsed);
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
// Three pie slices sweep in
const slices = [
{ label:'Claude', pct: 46, color: TERRA },
{ label:'GPT', pct: 31, color: DEEP_BLUE },
{ label:'GLM/国产', pct: 23, color: OLIVE },
];
const cx = 130, cy = 130, r = 104;
const C = 2 * Math.PI * r;
// cumulative pct as fractions
let acc = 0;
const slicesCalc = slices.map((s, i) => {
const delay = 0.6 + i * 0.45;
const sweepT = Math.max(0, Math.min(1, (e - delay) / 0.7));
const start = acc;
const end = acc + s.pct / 100;
acc = end;
return { ...s, start, end, sweepT, delay };
});
return (
<div style={{display:'flex', flexDirection:'column', gap: 14}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color: TERRA, opacity: labelOp}}>
COLUMN / 03 · 模型占有率
</div>
<div style={{fontFamily: serif, fontSize: 24, fontWeight: 500,
color: INK, lineHeight: 1.2, opacity: labelOp,
letterSpacing:'-0.01em'}}>
主力模型分布
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
color: ASH, opacity: labelOp, marginBottom: 6}}>
单选题 · 日常首选
</div>
<div style={{display:'flex', alignItems:'center', gap: 18}}>
<svg width="260" height="260" viewBox="0 0 260 260">
{/* Background ring */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke={LINE} strokeWidth={1}/>
{slicesCalc.map((s, i) => {
// Draw partial arc with stroke-dasharray
const sweepLen = (s.end - s.start) * s.sweepT;
const dash = sweepLen * C;
const gap = C - dash;
const rot = s.start * 360 - 90;
return (
<circle key={i} cx={cx} cy={cy} r={r}
fill="none" stroke={s.color} strokeWidth={28}
strokeDasharray={`${dash} ${gap}`}
strokeDashoffset={0}
transform={`rotate(${rot} ${cx} ${cy})`}
opacity={0.95}/>
);
})}
{/* Inner text */}
<text x={cx} y={cy - 4} textAnchor="middle"
fontFamily={serif} fontSize={34} fill={INK}
fontWeight={500} letterSpacing="-0.5">
n=156
</text>
<text x={cx} y={cy + 22} textAnchor="middle"
fontFamily={mono} fontSize={10} fill={ASH}
letterSpacing="0.2em">
TOTAL
</text>
</svg>
<div style={{display:'flex', flexDirection:'column', gap: 14, flex: 1}}>
{slicesCalc.map((s, i) => {
const txtOp = Math.max(0, Math.min(1, (e - s.delay - 0.3) / 0.4));
return (
<div key={i} style={{display:'flex', alignItems:'baseline',
gap: 10, opacity: txtOp}}>
<div style={{width: 10, height: 10, background: s.color,
marginTop: 4, flexShrink: 0}}/>
<div style={{flex: 1}}>
<div style={{fontFamily: serif, fontSize: 18,
fontWeight: 500, color: INK, letterSpacing:'0.01em'}}>
{s.label}
</div>
</div>
<div style={{fontFamily: serif, fontSize: 22,
fontWeight: 500, color: INK,
fontVariantNumeric:'tabular-nums'}}>
{s.pct}%
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function FooterTrend({ elapsed }) {
const e = Math.max(0, elapsed);
const op = interpolate(e, [0, 0.5], [0, 1]);
const data = [12, 18, 24, 31, 38, 48, 57, 64, 71, 78, 84, 87];
const months = ['05','06','07','08','09','10','11','12','01','02','03','04'];
const W = 1760, H = 86, PAD = 8;
const maxV = 100;
// progressive reveal of line
const revealT = Math.max(0, Math.min(1, (e - 0.3) / 1.4));
const nPoints = Math.max(1, Math.floor(revealT * data.length));
const pts = [];
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * W;
const y = H - (data[i] / maxV) * (H - PAD * 2) - PAD;
pts.push([x, y]);
}
const visiblePts = pts.slice(0, nPoints);
const d = visiblePts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
const area = visiblePts.length > 1
? d + ` L ${visiblePts[visiblePts.length-1][0].toFixed(1)} ${H} L 0 ${H} Z`
: '';
return (
<div style={{marginTop: 26, opacity: op}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 8}}>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.3em'}}>TREND · 过去 12 个月 AI 周使用率(%)</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH}}>
从 12% 到 87% · 增长 7.25×
</div>
</div>
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}
style={{display:'block', width:'100%', height: H}}>
{area && <path d={area} fill={TERRA} opacity={0.08}/>}
{d && <path d={d} fill="none" stroke={TERRA} strokeWidth={1.6}/>}
{visiblePts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={2.4} fill={TERRA}/>
))}
{/* Axis labels */}
{pts.map((p, i) => (
<text key={i} x={p[0]} y={H - 0}
textAnchor="middle" fontFamily={mono} fontSize={9} fill={ASH}
opacity={0.6}>
{months[i]}
</text>
))}
</svg>
</div>
);
}
// ── Scene 3: Typography close-up (10 – 17s) ───────────────
function Scene3_Typography() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
const fadeOut = interpolate(elapsed, [6.5, 7.0], [1, 0], Easing.easeIn);
const opacity = Math.min(fadeIn, fadeOut);
const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
const leftOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
const compareOp = interpolate(elapsed, [1.8, 2.6], [0, 1]);
const captionOp = interpolate(elapsed, [3.6, 4.4], [0, 1]);
// Pulsing scale on "87"
const pulse = 1 + Math.sin(elapsed * 1.6) * 0.008;
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity,
padding: '60px 80px', display:'flex', flexDirection:'column'}}>
{/* Top label */}
<div style={{display:'flex', alignItems:'baseline',
justifyContent:'space-between', marginBottom: 32, opacity: labelOp}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>DETAIL · ZOOM 1.5×</div>
<div style={{fontFamily: serif, fontSize: 46, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
排版细节:<span style={{fontStyle:'italic', color: TERRA}}>品味税</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', maxWidth: 400, lineHeight: 1.5}}>
"AI 能写中文,但分不清什么是好的中文排版"
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1.25fr 1fr',
gap: 56, flex: 1}}>
{/* Left: Number gradient showcase */}
<div style={{opacity: leftOp, display:'flex', flexDirection:'column',
gap: 28}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em'}}>01 · 字号梯度 · HIERARCHY</div>
<div style={{display:'flex', alignItems:'baseline', gap: 36,
borderBottom:`1px solid ${LINE}`, paddingBottom: 28}}>
<div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
color: INK, lineHeight: 0.88, letterSpacing:'-0.04em',
fontVariantNumeric:'tabular-nums', display:'inline-block',
transform:`scale(${pulse})`, transformOrigin:'left bottom'}}>
87<span style={{fontSize: 80, color: TERRA,
verticalAlign:'super', marginLeft: 4, fontStyle:'italic'}}>%</span>
</div>
<div style={{fontFamily: serif, fontSize: 110, fontWeight: 400,
color: OLIVE, lineHeight: 0.88, letterSpacing:'-0.02em',
fontVariantNumeric:'tabular-nums'}}>
3.2<span style={{fontSize: 44, fontStyle:'italic',
color: ASH, marginLeft: 2}}>×</span>
</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 400,
color: DEEP_BLUE, lineHeight: 0.88,
fontVariantNumeric:'tabular-nums'}}>
156
</div>
</div>
<div style={{fontFamily: serif, fontSize: 15, color: ASH,
lineHeight: 1.55, maxWidth: 580}}>
主数据 <span style={{color: INK, fontWeight: 500}}>220pt</span>、
次级 <span style={{color: INK, fontWeight: 500}}>110pt</span>、
辅助 <span style={{color: INK, fontWeight: 500}}>56pt</span>——
梯度 2× 不是工程师拍脑袋,是几百年印刷品的视觉惯性。
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em', marginTop: 10}}>02 · 换行 · TEXT-WRAP: PRETTY</div>
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500,
color: INK, lineHeight: 1.25, letterSpacing:'-0.01em',
maxWidth: 620, textWrap:'pretty'}}>
标题在该断的地方断开<br/>
避免孤字和单字成行
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em', marginTop: 4}}>03 · 上标辅注 · MONO FOOTNOTE</div>
<div style={{fontFamily: serif, fontSize: 20, color: INK,
lineHeight: 1.6, maxWidth: 620}}>
87%
<span style={{fontFamily: mono, fontSize: 11, color: TERRA,
verticalAlign:'super', marginLeft: 4}}>¹</span>
用户每周用 AI 辅助写作
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
marginTop: 10, letterSpacing:'0.05em'}}>
¹ 基于 156 位创作者调研,每周 ≥ 3 次
</div>
</div>
</div>
{/* Right: AI slop vs 精致 */}
<div style={{opacity: compareOp, display:'flex', flexDirection:'column',
gap: 20}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em'}}>04 · AI SLOP vs 精致版</div>
{/* Slop version */}
<div style={{position:'relative', border: `1.5px dashed #c06060`,
padding: '22px 22px', borderRadius: 16,
background: 'linear-gradient(135deg, #6a47d4 0%, #3a1a7a 100%)'}}>
<div style={{position:'absolute', top: -10, left: 14,
background: '#c06060', color:'#fff', fontFamily: mono,
fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
✕ 反例 · 不要这样做
</div>
<div style={{fontFamily: sans, fontSize: 28, fontWeight: 700,
color:'#fff', marginBottom: 6, letterSpacing:'-0.01em'}}>
🚀 AI 写作工具爆发增长!
</div>
<div style={{fontFamily: sans, fontSize: 13, color:'rgba(255,255,255,0.85)',
lineHeight: 1.5}}>
✨ 87% 用户都在用!💡 效率提升 3.2 倍!🎯 赶紧加入!
</div>
<div style={{marginTop: 14, display:'flex', gap: 8}}>
<div style={{background:'rgba(255,255,255,0.2)',
padding:'6px 12px', borderRadius: 999,
fontFamily: sans, fontSize: 11, color:'#fff'}}>
#AI写作
</div>
<div style={{background:'rgba(255,255,255,0.2)',
padding:'6px 12px', borderRadius: 999,
fontFamily: sans, fontSize: 11, color:'#fff'}}>
#爆款
</div>
</div>
</div>
{/* Good version */}
<div style={{position:'relative', background:'#fff',
border: `1px solid ${LINE}`, padding: '22px 22px'}}>
<div style={{position:'absolute', top: -10, left: 14,
background: TERRA, color:'#fff', fontFamily: mono,
fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
✓ 精致版 · DO THIS
</div>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
letterSpacing:'0.3em', marginBottom: 4}}>ESSAY · 2026.04</div>
<div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
color: INK, lineHeight: 1.2, letterSpacing:'-0.01em',
marginBottom: 8}}>
AI 写作<br/>
<span style={{fontStyle:'italic'}}>悄然</span>改变创作者
</div>
<div style={{height: 1, background: INK, width: 70, marginBottom: 10}}/>
<div style={{fontFamily: serif, fontSize: 13, color:'#444',
lineHeight: 1.6}}>
87% 的创作者已经把 AI 纳入日常工作流;
效率提升 3.2×,但人味不减反增——
工具不定义内容,品味才定义。
</div>
</div>
</div>
</div>
{/* Caption */}
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
color: ASH, textAlign:'center', marginTop: 22, opacity: captionOp,
letterSpacing:'0.02em'}}>
排版细节是 AI 分不清的 <span style={{color: TERRA, fontWeight: 500,
fontStyle:'normal'}}>品味税</span>
</div>
</div>
);
}
// ── Scene 4: Outro (17 – 22s) ─────────────────────────────
function Scene4_Outro() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
const mainY = interpolate(elapsed, [0, 1.2], [28, 0], Easing.easeOut);
const italicOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 680]);
const subOp = interpolate(elapsed, [1.6, 2.2], [0, 1]);
const monoOp = interpolate(elapsed, [2.4, 3.2], [0, 1]);
const monoLineW = interpolate(elapsed, [2.8, 3.8], [0, 520]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 32, opacity: fadeIn}}>
HUASHU-DESIGN · INFOGRAPHIC CAPABILITY
</div>
<div style={{fontFamily: serif, fontSize: 148, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.02em',
transform:`translateY(${mainY}px)`}}>
<span style={{fontStyle:'italic', opacity: italicOp}}>数据</span>
<span style={{opacity: fadeIn}}> 配得上 </span>
<span style={{color: TERRA, opacity: italicOp}}>好看</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 44}}/>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
印刷级 · 不因为缩放而失真
</div>
<div style={{marginTop: 60, opacity: monoOp,
display:'flex', alignItems:'center', flexDirection:'column', gap: 14}}>
<div style={{height: 1, background: LINE, width: monoLineW}}/>
<div style={{fontFamily: mono, fontSize: 14, color: INK,
letterSpacing:'0.18em'}}>
export → <span style={{color: TERRA}}>PDF 矢量</span> /
<span style={{color: OLIVE}}> PNG 300dpi</span> /
<span style={{color: DEEP_BLUE}}> SVG 原生</span>
</div>
<div style={{height: 1, background: LINE, width: monoLineW}}/>
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Composition ───────────────────────────────────────────
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={10}><Scene2_Spread /></Sprite>
<Sprite start={10} end={17}><Scene3_Typography /></Sprite>
<Sprite start={17} end={22}><Scene4_Outro /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+652
View File
@@ -0,0 +1,652 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Expert Review</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── 5 dimensions ──────────────────────────────────────────
const DIMENSIONS = [
{ no: '01', name: '哲学一致性', desc: '是否遵循既定的设计风格', score: 9 },
{ no: '02', name: '视觉层级', desc: '信息优先级是否一目了然', score: 8 },
{ no: '03', name: '细节执行', desc: '排版、间距、字重是否到位', score: 7 },
{ no: '04', name: '功能性', desc: '交互是否顺畅、可用', score: 6 },
{ no: '05', name: '创新性', desc: '是否超出了平均水准', score: 8 },
];
const COMMENTS = [
'赤陶橙贯穿,serif + 留白很 Kenya Hara',
'Hero 和 body 强度接近,主次需再拉开',
'行距、字号梯度还差一点点克制',
'CTA 可达但色值和主色冲突',
'版面节奏有想法,避开了模板感',
];
// ── Scene 1: Title (0 – 3s) ────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const topOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const titleY = interpolate(elapsed, [0.2, 1.3], [50, 0], Easing.easeOut);
const titleOp = interpolate(elapsed, [0.2, 1.1], [0, 1]);
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 28, opacity: topOp}}>
设计评审 · 5 维度评分
</div>
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
transform: `translateY(${titleY}px)`}}>
<span style={{fontStyle:'italic', color: TERRA}}>评</span>设计 · 不评设计师
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
做完之后 · 用 5 个刻度看清楚
</div>
</div>
);
}
// ── Scene 2: 5 dimensions intro (3 – 8s) ───────────────
function Scene2_Dimensions() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '100px 100px 80px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 60,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>步骤 1 / 3 · 评审维度</div>
<div style={{fontFamily: serif, fontSize: 60, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
五把<span style={{fontStyle:'italic', color: TERRA}}>尺子</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 19, color: ASH,
textAlign:'right', lineHeight: 1.5}}>
主观审美变不可辩论,<br/>
客观维度变可打分
</div>
</div>
<div style={{flex: 1, display:'grid', gridTemplateColumns:'repeat(5, 1fr)',
gap: 22, alignItems:'stretch'}}>
{DIMENSIONS.map((d, i) => {
const appearStart = 0.6 + i * 0.4;
const appearEnd = appearStart + 0.7;
const op = interpolate(elapsed, [appearStart, appearEnd], [0, 1], Easing.easeOut);
const ty = interpolate(elapsed, [appearStart, appearEnd], [30, 0], Easing.easeOut);
return (
<div key={i} style={{
opacity: op,
transform: `translateY(${ty}px)`,
background: '#fff',
border: `1px solid ${LINE}`,
padding: '32px 26px 30px',
display:'flex', flexDirection:'column',
position:'relative',
}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 72,
fontWeight: 400, color: TERRA, lineHeight: 1,
letterSpacing:'-0.02em', marginBottom: 20}}>
{d.no}
</div>
<div style={{height: 1, background: INK, width: 40, marginBottom: 18}} />
<div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
color: INK, lineHeight: 1.15, marginBottom: 12}}>
{d.name}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: ASH, lineHeight: 1.55, flex: 1}}>
{d.desc}
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em', marginTop: 22}}>
0 – 10 PT
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Scene 3: Radar + scoring (8 – 14s) ────────────────
function Scene3_Radar() {
const { elapsed } = useSprite();
const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
// Radar reveal progress — polygon expands from center
const reveal = interpolate(elapsed, [0.8, 2.4], [0, 1], Easing.easeOut);
// Total score count-up
const totalT = interpolate(elapsed, [1.6, 2.8], [0, 1], Easing.easeOut);
const totalVal = Math.round(totalT * 38);
// Radar geometry
const cx = 340, cy = 440, R = 260;
const N = 5;
const maxScore = 10;
const angle = i => -Math.PI/2 + i * 2 * Math.PI / N;
// Axis endpoints
const axisPts = DIMENSIONS.map((_, i) => ({
x: cx + Math.cos(angle(i)) * R,
y: cy + Math.sin(angle(i)) * R,
}));
// Score polygon points (animated)
const scorePts = DIMENSIONS.map((d, i) => {
const r = (d.score / maxScore) * R * reveal;
return {
x: cx + Math.cos(angle(i)) * r,
y: cy + Math.sin(angle(i)) * r,
};
});
const scorePath = scorePts.map(p => `${p.x},${p.y}`).join(' ');
// Concentric rings
const rings = [2, 4, 6, 8, 10];
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '70px 90px 50px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 24,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>步骤 2 / 3 · 打分</div>
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
五边形 · <span style={{fontStyle:'italic'}}>照见</span>每一维
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right', lineHeight: 1.5}}>
不是给个评价,<br/>
是把问题「可视化」出来
</div>
</div>
<div style={{flex: 1, display:'grid', gridTemplateColumns: '720px 1fr', gap: 60}}>
{/* Radar */}
<div style={{position:'relative', background:'#fff', border:`1px solid ${LINE}`}}>
<svg viewBox="0 0 720 880" width="100%" height="100%" style={{display:'block'}}>
{/* Concentric rings */}
{rings.map((r, i) => {
const ringR = (r / maxScore) * R;
const pts = DIMENSIONS.map((_, k) => {
const x = cx + Math.cos(angle(k)) * ringR;
const y = cy + Math.sin(angle(k)) * ringR;
return `${x},${y}`;
}).join(' ');
return (
<g key={i}>
<polygon points={pts} fill="none" stroke={LINE} strokeWidth="1" />
<text x={cx + 6} y={cy - ringR + 4}
fontFamily={mono} fontSize="10" fill={ASH}
letterSpacing="0.1em">{r}</text>
</g>
);
})}
{/* Axes */}
{axisPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
stroke={LINE} strokeWidth="1" />
))}
{/* Score polygon */}
<polygon points={scorePath}
fill={TERRA} fillOpacity="0.18"
stroke={TERRA} strokeWidth="2" />
{/* Score dots */}
{scorePts.map((p, i) => reveal > 0.6 && (
<circle key={i} cx={p.x} cy={p.y} r="5"
fill={TERRA} opacity={Math.min(1, (reveal - 0.6) / 0.4)} />
))}
{/* Axis labels + score */}
{DIMENSIONS.map((d, i) => {
const labelR = R + 48;
const lx = cx + Math.cos(angle(i)) * labelR;
const ly = cy + Math.sin(angle(i)) * labelR;
const anchor = Math.abs(Math.cos(angle(i))) < 0.2 ? 'middle'
: Math.cos(angle(i)) > 0 ? 'start' : 'end';
const showScore = elapsed > 2.4 + i * 0.15;
return (
<g key={i}>
<text x={lx} y={ly}
fontFamily={mono} fontSize="13" fill={INK}
fontWeight="500" textAnchor={anchor}
letterSpacing="0.08em">
{d.name}
</text>
{showScore && (
<text x={lx} y={ly + 20}
fontFamily={serif} fontSize="22" fill={TERRA}
fontStyle="italic" fontWeight="500" textAnchor={anchor}>
{d.score}
<tspan fontSize="13" fill={ASH} fontStyle="normal"> / 10</tspan>
</text>
)}
</g>
);
})}
{/* Center total score */}
<text x={cx} y={750}
fontFamily={mono} fontSize="11" fill={ASH}
letterSpacing="0.3em" textAnchor="middle">
总分
</text>
<text x={cx} y={820}
fontFamily={serif} fontSize="72" fill={INK}
fontWeight="500" textAnchor="middle"
letterSpacing="-0.02em">
<tspan fontStyle="italic" fill={TERRA}>{totalVal}</tspan>
<tspan fontSize="34" fill={ASH} letterSpacing="0"> / 50</tspan>
</text>
</svg>
</div>
{/* Right: breakdown list */}
<div style={{display:'flex', flexDirection:'column', gap: 18, paddingTop: 4}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.25em', marginBottom: 4}}>
BREAKDOWN · 逐项
</div>
{DIMENSIONS.map((d, i) => {
const rowAppear = 2.2 + i * 0.25;
const op = interpolate(elapsed, [rowAppear, rowAppear + 0.6], [0, 1]);
const barT = interpolate(elapsed, [rowAppear + 0.2, rowAppear + 0.9],
[0, d.score / 10], Easing.easeOut);
const tx = interpolate(elapsed, [rowAppear, rowAppear + 0.5], [20, 0], Easing.easeOut);
return (
<div key={i} style={{opacity: op, transform:`translateX(${tx}px)`,
background:'#fff', border:`1px solid ${LINE}`, padding:'14px 20px'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 8}}>
<div style={{fontFamily: serif, fontSize: 22, fontWeight: 500, color: INK}}>
<span style={{fontFamily: mono, fontSize: 11, color: TERRA,
marginRight: 12, letterSpacing:'0.15em'}}>{d.no}</span>
{d.name}
</div>
<div style={{fontFamily: serif, fontSize: 22, fontStyle:'italic',
fontWeight: 500, color: TERRA}}>
{d.score}<span style={{fontSize: 13, color: ASH, fontStyle:'normal'}}> / 10</span>
</div>
</div>
{/* Progress bar */}
<div style={{height: 4, background: LINE, position:'relative', marginBottom: 8}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${barT * 100}%`, background: TERRA}} />
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 14,
color: ASH, lineHeight: 1.5}}>
{COMMENTS[i]}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Scene 4: Keep / Fix / Quick Wins (14 – 20s) ──────
function Scene4_Actions() {
const { elapsed } = useSprite();
const headerOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
const keeps = [
'赤陶橙 accent 贯穿全文',
'serif display 给了文学气质',
'留白足够 · 信息不挤',
];
const fixes = [
{ tag: '致命', sev: TERRA, text: 'Hero 图和 body 抢焦点 · 降低 hero 字号' },
{ tag: '重要', sev: OLIVE, text: '侧边 CTA 色和品牌主色冲突' },
{ tag: '优化', sev: ASH, text: 'Footer 字号可以再小 2px' },
];
const wins = [
'Hero 字号 96 → 72',
'CTA 改成 terra 主色',
'Footer 字号 14 → 12',
];
const col1T = interpolate(elapsed, [0.4, 1.2], [0, 1], Easing.easeOut);
const col2T = interpolate(elapsed, [0.8, 1.6], [0, 1], Easing.easeOut);
const col3T = interpolate(elapsed, [1.2, 2.0], [0, 1], Easing.easeOut);
const footerOp = interpolate(elapsed, [3.8, 4.6], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '70px 90px 60px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 40,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>步骤 3 / 3 · 行动清单</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
Keep · Fix · <span style={{fontStyle:'italic', color: TERRA}}>Quick Wins</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right', lineHeight: 1.5}}>
打完分 · 不是扔下报告,<br/>
是给一张可执行的「修复清单」
</div>
</div>
<div style={{flex: 1, display:'grid', gridTemplateColumns:'1fr 1.15fr 1fr',
gap: 28}}>
{/* KEEP */}
<div style={{opacity: col1T, transform:`translateY(${(1-col1T)*20}px)`,
background:'#fff', border:`1px solid ${LINE}`,
borderTop: `4px solid ${OLIVE}`, padding: '30px 30px 28px',
display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 24}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: OLIVE,
letterSpacing:'0.3em', marginBottom: 8}}>KEEP</div>
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
保持这些
</div>
</div>
<div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
fontWeight: 400, color: OLIVE, lineHeight: 1}}>
3
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
{keeps.map((k, i) => (
<div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start'}}>
<div style={{fontFamily: mono, fontSize: 16, color: OLIVE,
fontWeight: 600, marginTop: 2}}>✓</div>
<div style={{fontFamily: serif, fontSize: 19, color: INK,
lineHeight: 1.5, flex: 1}}>{k}</div>
</div>
))}
</div>
</div>
{/* FIX */}
<div style={{opacity: col2T, transform:`translateY(${(1-col2T)*20}px)`,
background:'#fff', border:`1px solid ${LINE}`,
borderTop: `4px solid ${TERRA}`, padding: '30px 30px 28px',
display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 24}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 8}}>FIX</div>
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
需修复 · 按严重度
</div>
</div>
<div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
fontWeight: 400, color: TERRA, lineHeight: 1}}>
3
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 16, flex: 1}}>
{fixes.map((f, i) => (
<div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start',
paddingBottom: 14, borderBottom: i < fixes.length - 1 ? `1px solid ${LINE}` : 'none'}}>
<div style={{
background: f.sev, color: '#fff',
fontFamily: mono, fontSize: 10, letterSpacing:'0.15em',
padding: '4px 10px', marginTop: 4, minWidth: 58,
textAlign: 'center', fontWeight: 600,
}}>
{f.tag}
</div>
<div style={{fontFamily: serif, fontSize: 17, color: INK,
lineHeight: 1.5, flex: 1}}>{f.text}</div>
</div>
))}
</div>
</div>
{/* QUICK WINS */}
<div style={{opacity: col3T, transform:`translateY(${(1-col3T)*20}px)`,
background:'#fff', border:`1px solid ${LINE}`,
borderTop: `4px solid ${DEEP_BLUE}`, padding: '30px 30px 28px',
display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 24}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: DEEP_BLUE,
letterSpacing:'0.3em', marginBottom: 8}}>QUICK WINS</div>
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
5 分钟能做的
</div>
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em', textAlign:'right', lineHeight: 1.6}}>
TOP<br/>3
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
{wins.map((w, i) => (
<div key={i} style={{display:'flex', gap: 16, alignItems:'flex-start'}}>
<div style={{fontFamily: serif, fontSize: 32, fontStyle:'italic',
color: DEEP_BLUE, fontWeight: 400, lineHeight: 1, minWidth: 32,
marginTop: -4}}>
{i+1}
</div>
<div style={{fontFamily: serif, fontSize: 19, color: INK,
lineHeight: 1.5, flex: 1}}>{w}</div>
</div>
))}
</div>
</div>
</div>
{/* Footer slogan */}
<div style={{marginTop: 36, textAlign:'center', opacity: footerOp}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
color: TERRA, letterSpacing:'0.02em'}}>
不是给个评价 · 是给个修复清单
</div>
</div>
</div>
);
}
// ── Scene 5: Outro (20 – 22s) ─────────────────────────
function Scene5_Outro() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
const titleY = interpolate(elapsed, [0, 1.0], [40, 0], Easing.easeOut);
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 540]);
const subOp = interpolate(elapsed, [0.9, 1.6], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 22}}>
5 维度 · 客观 · 可操作
</div>
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.015em',
transform: `translateY(${titleY}px)`}}>
先打分 · 再<span style={{fontStyle:'italic', color: TERRA}}>修</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
Huashu-Design · Expert Review
</div>
</div>
);
}
// ── Watermark ──────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={8}><Scene2_Dimensions /></Sprite>
<Sprite start={8} end={14}><Scene3_Radar /></Sprite>
<Sprite start={14} end={20}><Scene4_Actions /></Sprite>
<Sprite start={20} end={22}><Scene5_Outro /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+787
View File
@@ -0,0 +1,787 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · 品牌资产协议 5 步硬流程</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Trigger (0 – 3s) ─────────────────────────────
function Scene1_Trigger() {
const { elapsed } = useSprite();
const labelOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const titleY = interpolate(elapsed, [0, 1], [30, 0], Easing.easeOut);
const titleOp = interpolate(elapsed, [0.2, 1], [0, 1]);
const brandsOp = interpolate(elapsed, [1, 1.6], [0, 1]);
const switchOp = interpolate(elapsed, [1.9, 2.4], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3], [1, 0]);
const brands = ['Kimi', 'Linear', 'Lovart', 'Stripe'];
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 28, opacity: labelOp}}>
品 牌 资 产 协 议 · BRAND PROTOCOL
</div>
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
lineHeight: 1, letterSpacing:'-0.01em',
opacity: titleOp, transform: `translateY(${titleY}px)`}}>
涉及具体品牌<span style={{color: TERRA, fontStyle:'italic'}}>?</span>
</div>
<div style={{display:'flex', gap: 48, marginTop: 64, opacity: brandsOp}}>
{brands.map((b, i) => (
<span key={i} style={{fontFamily: serif, fontStyle:'italic',
fontSize: 44, color: ASH, letterSpacing:'0.02em'}}>
{b}
</span>
))}
</div>
<div style={{marginTop: 72, opacity: switchOp, textAlign:'center'}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 34,
color: TERRA, letterSpacing:'0.02em'}}>
先停下——走 5 步硬流程
</div>
<div style={{height:1, background: TERRA, width: 180, margin:'18px auto 0'}} />
</div>
</div>
);
}
// ── Scene 2: Step 1 & 2 (3 – 7s) ──────────────────────────
function Scene2_AskAndSearch() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const leftOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
const leftY = interpolate(elapsed, [0.3, 0.9], [20, 0], Easing.easeOut);
const rightOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
const rightY = interpolate(elapsed, [0.8, 1.4], [20, 0], Easing.easeOut);
const fadeOut = interpolate(elapsed, [3.5, 4], [1, 0]);
// cursor sweeping through search paths 1.6 → 3.2
const paths = [
'<brand>.com/brand',
'<brand>.com/press',
'brand.<brand>.com',
'<brand>.github.io/brand',
];
// current active index based on time
const activeIdx = Math.min(3, Math.max(0, Math.floor((elapsed - 1.6) / 0.4)));
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'72px 120px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', opacity: headOp, marginBottom: 48}}>
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
Step 1 & 2 · 问 · 搜
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em'}}>
01 / 05 → 02 / 05
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 48, flex:1}}>
{/* Left: Step 1 Ask */}
<div style={{opacity: leftOp, transform:`translateY(${leftY}px)`,
display:'flex', flexDirection:'column', gap: 24}}>
<div style={{display:'flex', alignItems:'baseline', gap: 18}}>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em'}}>STEP · 01</div>
<div style={{fontFamily: serif, fontSize: 40, color: INK,
fontWeight: 500}}>问</div>
</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'32px 34px', position:'relative'}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 64,
color: TERRA, lineHeight:1, position:'absolute',
top: 8, left: 14}}>「</div>
<div style={{fontFamily: serif, fontSize: 24, color: INK,
lineHeight: 1.55, paddingLeft: 36}}>
这个品牌有 <span style={{fontStyle:'italic', color: TERRA}}>brand guidelines</span> 吗?
</div>
<div style={{height:1, background: LINE, margin:'22px 0'}} />
<div style={{fontFamily: serif, fontSize: 20, color: ASH,
lineHeight: 1.6, paddingLeft: 36}}>
有的话直接给我;没有我去搜。
</div>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.15em', marginTop: 6}}>
▸ 用户提供 &gt; AI 猜测
</div>
</div>
{/* Right: Step 2 Search */}
<div style={{opacity: rightOp, transform:`translateY(${rightY}px)`,
display:'flex', flexDirection:'column', gap: 24}}>
<div style={{display:'flex', alignItems:'baseline', gap: 18}}>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em'}}>STEP · 02</div>
<div style={{fontFamily: serif, fontSize: 40, color: INK,
fontWeight: 500}}>搜 官 方 品 牌 页</div>
</div>
<div style={{background:'#1a1a1a', color:'#e8e3d6',
padding:'24px 28px', fontFamily: mono, fontSize: 16,
lineHeight: 1.9, flex: 1}}>
<div style={{color:'#6b6b6b', fontSize: 11,
letterSpacing:'0.2em', marginBottom: 14}}>
$ HTTP GET · typical paths
</div>
{paths.map((p, i) => {
const isActive = i === activeIdx && elapsed > 1.6 && elapsed < 3.4;
const visited = i < activeIdx;
return (
<div key={i} style={{
color: isActive ? TERRA : (visited ? '#8a8878' : '#e8e3d6'),
background: isActive ? 'rgba(192,74,26,0.12)' : 'transparent',
padding:'2px 8px', marginLeft:-8,
display:'flex', alignItems:'baseline', gap: 14}}>
<span style={{color: isActive ? TERRA : '#555', fontSize: 13}}>
{isActive ? '▸' : (visited ? '✓' : ' ')}
</span>
<span style={{letterSpacing:'0.02em'}}>{p}</span>
</div>
);
})}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, marginTop: 6}}>
按顺序试,中断也要记 404 状态
</div>
</div>
</div>
</div>
);
}
// ── Scene 3: Step 3 · Three fallbacks (7 – 12s) ───────────
function Scene3_Fallbacks() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const cardsOp = interpolate(elapsed, [0.3, 1], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
// Card state: 0 idle, 1 active/lit, 2 failed/gray, 3 success
// Card 1 active 1.2→2.0, then fails at 2.0
// Card 2 active 2.2→3.0, then fails at 3.0
// Card 3 active 3.2→4.2, succeeds at 4.2
const card1State = elapsed < 1.2 ? 0 : elapsed < 2.0 ? 1 : 2;
const card2State = elapsed < 2.2 ? 0 : elapsed < 3.0 ? 1 : (elapsed < 3.2 ? 2 : 2);
const card3State = elapsed < 3.2 ? 0 : elapsed < 4.2 ? 1 : 3;
const captionOp = interpolate(elapsed, [4.2, 4.6], [0, 1]);
const cards = [
{ n: '01', title: 'SVG 文件', cmd: 'curl -o logo.svg <url>', status: '最理想', state: card1State },
{ n: '02', title: '官网 HTML', cmd: 'curl -A "Mozilla/5.0" \\\n -L <url>', status: '80% 场景必用', state: card2State },
{ n: '03', title: '产品截图取色', cmd: 'macOS Preview · 吸管', status: 'App 必走', state: card3State },
];
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'70px 100px 60px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headOp, marginBottom: 40}}>
<div style={{display:'flex', alignItems:'baseline', gap: 18, marginBottom: 6}}>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em'}}>STEP · 03</div>
<div style={{fontFamily: serif, fontSize: 18, color: ASH,
fontStyle:'italic'}}>下 · download</div>
</div>
<div style={{fontFamily: serif, fontSize: 60, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
三条兜底路径
</div>
<div style={{height:1, background: LINE, width:'100%', marginTop: 20}} />
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr',
gap: 28, flex:1, opacity: cardsOp}}>
{cards.map((c, i) => {
const isIdle = c.state === 0;
const isActive = c.state === 1;
const isFailed = c.state === 2;
const isSuccess = c.state === 3;
const borderColor = isSuccess ? TERRA :
isActive ? INK :
isFailed ? '#c4bda7' : LINE;
const borderWidth = (isActive || isSuccess) ? 2 : 1;
const op = isFailed ? 0.42 : 1;
const bg = isSuccess ? '#fffaf3' : '#fff';
return (
<div key={i} style={{
background: bg,
border:`${borderWidth}px solid ${borderColor}`,
opacity: op,
padding:'28px 28px 24px',
display:'flex', flexDirection:'column',
position:'relative',
transition: 'none',
}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 20}}>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 300,
color: isSuccess ? TERRA : INK, lineHeight:1}}>
{c.n}
</div>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
color: isSuccess ? TERRA : isFailed ? ASH : INK}}>
{isIdle && 'READY'}
{isActive && '▸ TRYING…'}
{isFailed && '× FAILED'}
{isSuccess && '✓ GOT IT'}
</div>
</div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
color: INK, marginBottom: 14, letterSpacing:'-0.005em'}}>
{c.title}
</div>
<div style={{background:'#1a1a1a', color:'#e8e3d6',
fontFamily: mono, fontSize: 12, padding:'14px 16px',
whiteSpace:'pre-wrap', lineHeight: 1.6,
marginBottom: 20, borderLeft: `2px solid ${isSuccess ? TERRA : '#333'}`}}>
{c.cmd}
</div>
<div style={{marginTop:'auto', borderTop:`1px solid ${LINE}`,
paddingTop: 14, display:'flex', justifyContent:'space-between',
alignItems:'baseline'}}>
<div style={{fontFamily: serif, fontStyle:'italic',
fontSize: 15, color: ASH}}>
场景
</div>
<div style={{fontFamily: serif, fontSize: 16,
color: isSuccess ? TERRA : INK, fontWeight: 500}}>
{c.status}
</div>
</div>
{isSuccess && (
<div style={{position:'absolute', top:-10, right:-10,
width: 28, height: 28, borderRadius:'50%', background: TERRA,
color:'#fff', display:'flex', alignItems:'center',
justifyContent:'center', fontFamily: serif, fontSize: 14,
fontWeight: 600}}>
✓
</div>
)}
</div>
);
})}
</div>
<div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
前一条失败,<span style={{color: TERRA}}>立刻</span>走下一条——不要停
</div>
</div>
);
}
// ── Scene 4: Step 4 · Grep colors (12 – 17s) ──────────────
function Scene4_GrepColors() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const cmdOp = interpolate(elapsed, [0.4, 0.9], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
const results = [
{ count: 47, hex: '#1783FF', label: 'Kimi primary' },
{ count: 32, hex: '#FAFAFA', label: 'background' },
{ count: 18, hex: '#1a1a1a', label: 'ink' },
{ count: 12, hex: '#FF6B35', label: 'accent' },
{ count: 8, hex: '#6A6B4E', label: 'muted' },
];
// Stagger each result row, starting at t=1.5
const rowStart = 1.5;
const rowStep = 0.32;
const captionOp = interpolate(elapsed, [3.6, 4.1], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:'#1a1a1a', opacity: fadeOut,
padding:'64px 100px', display:'flex', flexDirection:'column',
color:'#e8e3d6'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 36, opacity: headOp}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 4}}>STEP · 04</div>
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
color:'#faf6ef', letterSpacing:'-0.01em'}}>
Grep 色 值
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color:'#8a8878', textAlign:'right'}}>
频次排序 = 品牌色权重<br/>
<span style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em'}}>
/ DON'T GUESS · COUNT
</span>
</div>
</div>
{/* Command */}
<div style={{opacity: cmdOp, background:'#0e0e0e',
border:'1px solid #333', padding:'20px 26px',
fontFamily: mono, fontSize: 15, lineHeight: 1.7,
marginBottom: 28}}>
<div style={{color: TERRA, fontSize: 10, letterSpacing:'0.2em',
marginBottom: 10}}>$ COMMAND</div>
<div style={{color:'#e8e3d6'}}>
<span style={{color:'#888'}}>$</span>{' '}
<span style={{color:'#7dd3fc'}}>grep</span>{' '}
<span style={{color:'#fbbf24'}}>-hoE</span>{' '}
<span style={{color:'#a5f3c5'}}>'#[0-9A-Fa-f]{'{6}'}'</span>{' '}
<span style={{color:'#e8e3d6'}}>assets/*.{'{svg,html}'}</span> \<br/>
{' '}<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>sort</span>{' '}
<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>uniq -c</span>{' '}
<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>sort -rn</span>{' '}
<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>head -20</span>
</div>
</div>
{/* Results + swatches */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60,
flex: 1}}>
{/* Left: result list */}
<div style={{fontFamily: mono, fontSize: 18, lineHeight: 2}}>
<div style={{color:'#555', fontSize: 10, letterSpacing:'0.2em',
marginBottom: 12}}>▸ RESULT · top 5</div>
{results.map((r, i) => {
const visible = elapsed > (rowStart + i * rowStep);
if (!visible) return <div key={i} style={{height: 36}}/>;
const fadeIn = interpolate(elapsed,
[rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
[0, 1]);
return (
<div key={i} style={{opacity: fadeIn, display:'flex',
alignItems:'center', gap: 20}}>
<span style={{color:'#fbbf24', minWidth: 40, textAlign:'right'}}>
{String(r.count).padStart(3, ' ').replace(/ /g, '\u00A0')}
</span>
<span style={{color:'#e8e3d6'}}>{r.hex}</span>
<span style={{color:'#666', fontSize: 14}}>←</span>
<span style={{color:'#8a8878', fontStyle:'italic',
fontFamily: serif, fontSize: 16}}>{r.label}</span>
</div>
);
})}
</div>
{/* Right: color swatches */}
<div style={{display:'flex', flexDirection:'column', gap: 14}}>
<div style={{color:'#555', fontFamily: mono, fontSize: 10,
letterSpacing:'0.2em', marginBottom: 2}}>▸ PALETTE</div>
{results.map((r, i) => {
const visible = elapsed > (rowStart + i * rowStep);
if (!visible) return <div key={i} style={{height: 54}}/>;
const fadeIn = interpolate(elapsed,
[rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
[0, 1]);
const w = interpolate(elapsed,
[rowStart + i*rowStep, rowStart + i*rowStep + 0.5],
[0, r.count * 9]);
return (
<div key={i} style={{opacity: fadeIn, display:'flex',
alignItems:'center', gap: 14, height: 50}}>
<div style={{width: 50, height: 50, background: r.hex,
border:'1px solid #333', flexShrink: 0}} />
<div style={{height: 24, background: r.hex,
width: `${w}px`, opacity: 0.7}} />
<div style={{fontFamily: mono, fontSize: 12,
color:'#8a8878'}}>×{r.count}</div>
</div>
);
})}
</div>
</div>
{/* Key caption */}
<div style={{opacity: captionOp, textAlign:'center', marginTop: 18,
paddingTop: 18, borderTop:'1px solid #333'}}>
<span style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
color: TERRA}}>
不要凭记忆猜品牌色
</span>
<span style={{fontFamily: serif, fontSize: 20, color:'#8a8878',
marginLeft: 14}}>
——频次说了算
</span>
</div>
</div>
);
}
// ── Scene 5: Step 5 · brand-spec.md (17 – 22s) ────────────
function Scene5_SpecFile() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const mdOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
const mdX = interpolate(elapsed, [0.3, 0.9], [-40, 0], Easing.easeOut);
const cssOp = interpolate(elapsed, [0.9, 1.5], [0, 1]);
const cssX = interpolate(elapsed, [0.9, 1.5], [40, 0], Easing.easeOut);
const arrowOp = interpolate(elapsed, [2.2, 2.7], [0, 1]);
const captionOp = interpolate(elapsed, [3.4, 3.9], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'64px 80px 48px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headOp, marginBottom: 28, display:'flex',
justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 4}}>STEP · 05 · FINAL</div>
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
固化为 <span style={{fontStyle:'italic'}}>brand-spec.md</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right'}}>
单一真相源 · single source of truth
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1.1fr 0.9fr',
gap: 40, flex:1, position:'relative'}}>
{/* Left: .md preview */}
<div style={{opacity: mdOp, transform:`translateX(${mdX}px)`,
background:'#fff', border:`1px solid ${LINE}`,
display:'flex', flexDirection:'column'}}>
<div style={{padding:'12px 20px', borderBottom:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
background:'#f7f2e8'}}>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.15em'}}>
▸ brand-spec.md
</div>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.15em'}}>
✓ COMMITTED
</div>
</div>
<div style={{padding:'22px 28px', fontFamily: mono, fontSize: 12,
lineHeight: 1.75, color: INK, flex:1, overflow:'hidden'}}>
<div style={{color: ASH}}>---</div>
<div><span style={{color: OLIVE}}>brand</span>: Kimi</div>
<div><span style={{color: OLIVE}}>fetched</span>: 2026-04-20</div>
<div style={{color: ASH}}>---</div>
<div style={{height: 10}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4}}>## 色板</div>
<div>- primary: <span style={{color: TERRA}}>#1783FF</span> (47)</div>
<div>- bg: <span style={{color: TERRA}}>#FAFAFA</span> (32)</div>
<div>- ink: <span style={{color: TERRA}}>#1a1a1a</span> (18)</div>
<div>- accent: <span style={{color: TERRA}}>#FF6B35</span> (12)</div>
<div style={{height: 12}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4}}>## 字型</div>
<div>- display: <span style={{color: OLIVE}}>Newsreader</span></div>
<div>- body: <span style={{color: OLIVE}}>Inter</span></div>
<div>- mono: <span style={{color: OLIVE}}>JetBrains Mono</span></div>
<div style={{height: 12}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4}}>## 签名细节</div>
<div>- 1px hairlines</div>
<div>- 圆角半径统一 4px</div>
<div style={{height: 12}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4, color: TERRA}}>## 禁区</div>
<div style={{color: ASH}}>- 不用紫渐变 / emoji / 4px 以上 shadow</div>
</div>
</div>
{/* Right: CSS vars + consumers */}
<div style={{opacity: cssOp, transform:`translateX(${cssX}px)`,
display:'flex', flexDirection:'column', gap: 20}}>
<div style={{background:'#0e0e0e', color:'#e8e3d6',
fontFamily: mono, fontSize: 14, padding:'22px 26px',
lineHeight: 1.7, flex: 1}}>
<div style={{color:'#666', fontSize: 10, letterSpacing:'0.2em',
marginBottom: 12}}>/* tokens.css */</div>
<div><span style={{color:'#7dd3fc'}}>:root</span> {'{'}</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-primary</span>: <span style={{color:'#fbbf24'}}>#1783FF</span>;</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-bg</span>: <span style={{color:'#fbbf24'}}>#FAFAFA</span>;</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-ink</span>: <span style={{color:'#fbbf24'}}>#1a1a1a</span>;</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-accent</span>: <span style={{color:'#fbbf24'}}>#FF6B35</span>;</div>
<div>{'}'}</div>
</div>
{/* Arrow cascade */}
<div style={{opacity: arrowOp, position:'relative',
background:'#fff', border:`1px solid ${LINE}`,
padding:'20px 24px'}}>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.2em', marginBottom: 12}}>▸ CONSUMERS</div>
<div style={{display:'flex', alignItems:'center', gap: 16,
fontFamily: mono, fontSize: 13, color: INK, flexWrap:'wrap'}}>
<span style={{padding:'4px 10px', border:`1px solid ${TERRA}`,
color: TERRA}}>brand-spec.md</span>
<span style={{color: TERRA}}>→</span>
<span style={{padding:'4px 10px', border:`1px solid ${INK}`}}>:root</span>
<span style={{color: TERRA}}>→</span>
<span style={{padding:'4px 10px', background: INK, color:'#fff'}}>all .html</span>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: ASH, marginTop: 14, lineHeight: 1.5}}>
一个文件改动,所有 HTML 自动跟随。
</div>
</div>
</div>
</div>
<div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
全协议的<span style={{color: TERRA}}>制胜点</span>——让所有 CSS 引用这份文件
</div>
</div>
);
}
// ── Scene 6: Final (22 – 24s) ─────────────────────────────
function Scene6_Final() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1], Easing.easeOut);
const titleY = interpolate(elapsed, [0, 0.8], [24, 0], Easing.easeOut);
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 580]);
const subOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 24}}>
01 → 02 → 03 → 04 → <span style={{color: INK}}>05 · DONE</span>
</div>
<div style={{fontFamily: serif, fontSize: 92, fontWeight: 500,
color: INK, lineHeight: 1.1, letterSpacing:'-0.01em',
textAlign:'center', transform: `translateY(${titleY}px)`}}>
<span style={{fontStyle:'italic', color: TERRA}}>15 分钟</span>的投资<br/>
省下 <span style={{fontStyle:'italic'}}>1–2 小时</span> 返工
</div>
<div style={{height:1, background: INK, width: lineW, marginTop: 40}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: ASH, marginTop: 28, opacity: subOp,
letterSpacing:'0.02em'}}>
这是稳定性最便宜的投资。
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Trigger /></Sprite>
<Sprite start={3} end={7}><Scene2_AskAndSearch /></Sprite>
<Sprite start={7} end={12}><Scene3_Fallbacks /></Sprite>
<Sprite start={12} end={17}><Scene4_GrepColors /></Sprite>
<Sprite start={17} end={22}><Scene5_SpecFile /></Sprite>
<Sprite start={22} end={24}><Scene6_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+856
View File
@@ -0,0 +1,856 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Junior Designer 模式</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Two working modes contrast (0 – 3s) ──────────
function Scene1_Contrast() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const leftOp = interpolate(elapsed, [0.3, 1], [0, 1]);
const rightOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
// Left: long bar filling slowly, then user frown at end
const badBar = Math.min(1, Math.max(0, (elapsed - 1.0) / 1.6));
const badReveal = elapsed > 2.5 ? 1 : 0;
// Right: small milestones ticking one by one
const ticks = [0.9, 1.3, 1.7, 2.1, 2.5];
const fadeOut = interpolate(elapsed, [2.85, 3], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'80px 120px', display:'flex', flexDirection:'column'}}>
{/* Title */}
<div style={{textAlign:'center', marginBottom: 60, opacity: titleOp}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 16}}>
JUNIOR DESIGNER MODE · 工作方式对比
</div>
<div style={{fontFamily: serif, fontSize: 68, fontWeight: 500,
color: INK, lineHeight: 1.1, letterSpacing:'-0.01em'}}>
理解错了 <span style={{fontStyle:'italic', color: TERRA}}>早改</span> 比 <span style={{fontStyle:'italic', color: TERRA}}>晚改</span> 便宜 100 倍
</div>
</div>
{/* Two columns */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60, flex: 1}}>
{/* Left: bad mode */}
<div style={{opacity: leftOp, background:'#f4eee3', padding:'40px 44px',
display:'flex', flexDirection:'column', position:'relative'}}>
<div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
<div style={{width: 32, height: 32, borderRadius:'50%',
background:'#ccc', color:'#fff', fontFamily: serif, fontSize: 20,
fontWeight: 600, display:'flex', alignItems:'center',
justifyContent:'center', lineHeight: 1}}>✗</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
color:'#555'}}>闷头做大招</div>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing: '0.2em', marginBottom: 14}}>
6 HOURS · 0 FEEDBACK
</div>
<div style={{height: 18, background:'#e2dbcd', position:'relative',
marginBottom: 36}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${badBar * 100}%`, background:'#999'}} />
</div>
<div style={{fontFamily: serif, fontSize: 16, color: ASH,
lineHeight: 1.7, flex: 1}}>
埋头 6 小时<br/>
一次性端出成品<br/>
默祷「方向没理解错」
</div>
{badReveal > 0 && (
<div style={{marginTop: 20, padding:'14px 18px', background:'#fff',
border: `1px solid #d0c8b8`, opacity: badReveal,
display:'flex', alignItems:'center', gap: 12}}>
<div style={{fontFamily: serif, fontSize: 22, color:'#888'}}>😐</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color:'#666', lineHeight: 1.4}}>
「这方向不对…<br/>得重来」
</div>
</div>
)}
</div>
{/* Right: good mode */}
<div style={{opacity: rightOp, background:'#fff', padding:'40px 44px',
border: `1.5px solid ${TERRA}`, display:'flex', flexDirection:'column',
position:'relative'}}>
<div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
<div style={{width: 32, height: 32, borderRadius:'50%',
background: TERRA, color:'#fff', fontFamily: serif, fontSize: 20,
fontWeight: 600, display:'flex', alignItems:'center',
justifyContent:'center', lineHeight: 1}}>✓</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
color: INK}}>Junior Designer 模式</div>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.2em', marginBottom: 14}}>
5 CHECKPOINTS · CONTINUOUS FEEDBACK
</div>
{/* Timeline with ticks */}
<div style={{position:'relative', height: 18, marginBottom: 36}}>
<div style={{position:'absolute', top: 8, left: 0, right: 0,
height: 2, background:'#e8e1d3'}} />
{ticks.map((tick, i) => {
const show = elapsed > tick ? 1 : 0;
const pct = (i + 1) / ticks.length * 100;
return (
<div key={i} style={{position:'absolute',
left: `calc(${pct}% - 10px)`, top: 0, width: 20, height: 20,
borderRadius:'50%', background: TERRA, color:'#fff',
fontSize: 11, display:'flex', alignItems:'center',
justifyContent:'center', opacity: show,
transform: `scale(${show})`, transition:'none',
fontFamily: sans, fontWeight: 600}}>✓</div>
);
})}
</div>
<div style={{fontFamily: serif, fontSize: 16, color: INK,
lineHeight: 1.7, flex: 1}}>
假设 · 骨架 · 粗图<br/>
每步都 show 一下<br/>
错得早,改得小
</div>
{elapsed > 2.5 && (
<div style={{marginTop: 20, padding:'14px 18px', background:'#faf6ef',
border: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12,
opacity: interpolate(elapsed, [2.5, 2.85], [0, 1])}}>
<div style={{fontFamily: serif, fontSize: 22}}>🙂</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: INK, lineHeight: 1.4}}>
「方向对,<br/>继续推进」
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Scene 2: HTML skeleton with assumptions + placeholders (3 – 8s) ──
function Scene2_Skeleton() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
// Typewriter content
const fullText = `<!--
ASSUMPTIONS:
- 主色用暖调橙(未确认,等品牌 spec)
- 3 屏切换,Today / Memory / Chat
- 字体:Newsreader + Noto Serif SC
PLACEHOLDERS:
- Hero 图暂用灰块 + 文字标签(有图再替换)
- AI 洞察文本用「等用户提供真实数据」
REASONING:
- 选橙调是因为客户说要「温暖感」
- Serif 因为是阅读类 App
-->`;
// Type at ~50 chars/sec
const charCount = Math.floor(interpolate(elapsed, [0.4, 4.0], [0, fullText.length]));
const shownText = fullText.slice(0, charCount);
const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0 && charCount < fullText.length;
// Hint callout appears after most text typed
const hintOp = interpolate(elapsed, [3.5, 4.2], [0, 1]);
const hintBob = Math.sin(elapsed * 3) * 4;
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
{/* Header */}
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 32}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 6}}>STEP 1 / 3</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
先写 <span style={{fontStyle:'italic', color: TERRA}}>骨架</span> · 不写渲染
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign: 'right', lineHeight: 1.5}}>
Assumptions + Placeholders + Reasoning<br/>
<span style={{fontSize: 14}}>— 把「猜」写在代码里</span>
</div>
</div>
{/* Editor */}
<div style={{flex: 1, background:'#1a1a1a', position:'relative',
display:'flex', flexDirection:'column', overflow:'hidden'}}>
{/* Editor top bar */}
<div style={{padding:'12px 20px', background:'#222',
borderBottom:'1px solid #2e2e2e', display:'flex',
alignItems:'center', gap: 16}}>
<div style={{display:'flex', gap: 8}}>
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#ff5f57'}} />
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#febc2e'}} />
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#28c840'}} />
</div>
<div style={{fontFamily: mono, fontSize: 11, color:'#888',
letterSpacing: '0.1em'}}>
index.html · junior-pass-v0
</div>
</div>
{/* Code body */}
<div style={{flex: 1, padding:'32px 40px', fontFamily: mono,
fontSize: 19, lineHeight: 1.7, color:'#8a8a8a',
whiteSpace:'pre-wrap', position:'relative'}}>
<span style={{color:'#6a8a56'}}>{shownText}</span>
{cursorBlink && <span style={{background:'#c04a1a', display:'inline-block',
width: 10, height: 22, verticalAlign:'text-bottom'}} />}
</div>
{/* Bottom status bar */}
<div style={{padding:'10px 20px', background:'#161616',
borderTop:'1px solid #2a2a2a', display:'flex',
justifyContent:'space-between', fontFamily: mono, fontSize: 10,
color:'#666', letterSpacing:'0.12em'}}>
<span>HTML · COMMENTS ONLY</span>
<span>LN {Math.min(15, Math.floor(charCount / 30) + 1)} / 15</span>
</div>
</div>
{/* Callout */}
<div style={{position:'absolute', right: 120, bottom: 100,
opacity: hintOp, transform: `translateY(${hintBob}px)`,
display:'flex', alignItems:'center', gap: 14}}>
<svg width="48" height="24" viewBox="0 0 48 24">
<path d="M 46 12 L 6 12 M 14 6 L 6 12 L 14 18"
fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
</svg>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: TERRA, letterSpacing:'0.02em'}}>
还没写一行渲染代码
</div>
</div>
</div>
);
}
// ── Scene 3: First show to user (8 – 13s) ─────────────────
function Scene3_FirstShow() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
// Wireframe fade in
const wireOp = interpolate(elapsed, [0.2, 0.9], [0, 1]);
// Chat bubbles stagger in
const b1Op = interpolate(elapsed, [1.0, 1.5], [0, 1]);
const b2Op = interpolate(elapsed, [2.0, 2.5], [0, 1]);
const b3Op = interpolate(elapsed, [2.9, 3.4], [0, 1]);
// Bottom subtitle
const subOp = interpolate(elapsed, [3.8, 4.3], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
{/* Header */}
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 6}}>STEP 2 / 3</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
第一次 <span style={{fontStyle:'italic', color: TERRA}}>show</span> 给用户
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', lineHeight: 1.5}}>
「先给你看方向 —— 对吗?」<br/>
<span style={{fontSize: 14}}>— 10 分钟对齐</span>
</div>
</div>
{/* Two panels */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.1fr', gap: 40,
flex: 1, alignItems:'stretch'}}>
{/* Left: wireframe */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding: 28, opacity: wireOp, display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em', marginBottom: 14}}>
WIREFRAME · NO STYLING
</div>
{/* Hero placeholder */}
<div style={{height: 180, background:'#ececec', border:'1.5px dashed #bbb',
display:'flex', alignItems:'center', justifyContent:'center',
fontFamily: mono, fontSize: 13, color:'#888',
letterSpacing:'0.15em', marginBottom: 20}}>
[ HERO IMAGE ]
</div>
{/* Title placeholder */}
<div style={{height: 26, background:'#e5e0d3', width:'75%',
marginBottom: 10}} />
<div style={{height: 16, background:'#ede8db', width:'55%',
marginBottom: 24}} />
{/* AI insight block */}
<div style={{background:'#f5f0e3', padding:'16px 18px', marginBottom: 20,
border: `1px dashed ${LINE}`}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.2em', marginBottom: 8}}>[ AI INSIGHT · 3 行 ]</div>
<div style={{height: 8, background:'#d9d2c5', width:'92%', marginBottom: 6}} />
<div style={{height: 8, background:'#d9d2c5', width:'88%', marginBottom: 6}} />
<div style={{height: 8, background:'#d9d2c5', width:'70%'}} />
</div>
<div style={{fontFamily: serif, fontSize: 14, color:'#888',
fontStyle:'italic', marginTop:'auto', textAlign:'center',
paddingTop: 14, borderTop: `1px solid ${LINE}`}}>
Marcus Aurelius · 第四卷
</div>
</div>
{/* Divider */}
<div style={{background: LINE, width: 1}} />
{/* Right: chat */}
<div style={{display:'flex', flexDirection:'column', gap: 18,
paddingTop: 10}}>
{/* Designer bubble */}
<div style={{opacity: b1Op, display:'flex', gap: 14,
alignItems:'flex-start'}}>
<div style={{width: 40, height: 40, background: TERRA, color:'#fff',
fontFamily: serif, fontSize: 16, fontWeight: 600,
display:'flex', alignItems:'center', justifyContent:'center',
flexShrink: 0, borderRadius: 2, letterSpacing:'0.05em'}}>JD</div>
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding:'16px 20px', maxWidth: '85%'}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.2em', marginBottom: 8}}>
JUNIOR DESIGNER
</div>
<div style={{fontFamily: serif, fontSize: 19, color: INK,
lineHeight: 1.5}}>
我对方向有几个假设,先给你看线框——想法对吗?
</div>
</div>
</div>
{/* User bubble 1 */}
<div style={{opacity: b2Op, display:'flex', gap: 14,
alignItems:'flex-start', justifyContent:'flex-end'}}>
<div style={{background: OLIVE, color:'#fff',
padding:'14px 20px', maxWidth: '80%'}}>
<div style={{fontFamily: mono, fontSize: 9,
color:'rgba(255,255,255,0.6)', letterSpacing:'0.2em',
marginBottom: 8}}>USER · FEEDBACK</div>
<div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
橙调 OK。AI 洞察那块我想要 <span style={{fontStyle:'italic',
textDecoration:'underline'}}>两行</span> 不要三行
</div>
</div>
</div>
{/* User bubble 2 */}
<div style={{opacity: b3Op, display:'flex', gap: 14,
alignItems:'flex-start', justifyContent:'flex-end'}}>
<div style={{background: OLIVE, color:'#fff',
padding:'14px 20px', maxWidth: '80%'}}>
<div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
Hero 图要 <span style={{fontStyle:'italic',
textDecoration:'underline'}}>摄影</span> 不要插画
</div>
</div>
</div>
{/* Subtitle */}
<div style={{marginTop:'auto', paddingTop: 24, opacity: subOp,
borderTop: `1px solid ${LINE}`}}>
<div style={{fontFamily: mono, fontSize: 11,
color: TERRA, letterSpacing:'0.3em', marginBottom: 6}}>
ALIGNMENT
</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
color: INK, lineHeight: 1.2}}>
对齐 = <span style={{color: TERRA}}>10 分钟</span>
<span style={{fontStyle:'italic', color: ASH, fontSize: 22}}> · 不是 2 小时</span>
</div>
</div>
</div>
</div>
</div>
);
}
// ── ArtBlock: oil painting hero (from c1-ios-prototype) ───
function ArtBlock({ mood = 'warm' }) {
const palettes = {
warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'],
quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'],
};
const p = palettes[mood];
return (
<div style={{
width:'100%', height:'100%', position:'relative', overflow:'hidden',
background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
}}>
<div style={{
position:'absolute', inset: 0,
background: `
radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
`,
filter:'blur(1px)',
}} />
<svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
<filter id="paint-noise-w2">
<feTurbulence baseFrequency="0.9" numOctaves="2" />
<feColorMatrix values="0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 1 0" />
</filter>
<rect width="100%" height="100%" filter="url(#paint-noise-w2)" />
</svg>
</div>
);
}
// ── Scene 4: Full pass fills in (13 – 18s) ────────────────
function Scene4_FullPass() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
// Staggered reveal of real content
const heroReveal = interpolate(elapsed, [0.6, 1.8], [0, 1], Easing.easeOut);
const titleReveal = interpolate(elapsed, [1.5, 2.3], [0, 1]);
const insightReveal = interpolate(elapsed, [2.2, 3.1], [0, 1]);
const meta = interpolate(elapsed, [3.0, 3.8], [0, 1]);
// Bottom subtitle
const subOp = interpolate(elapsed, [3.9, 4.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
{/* Header */}
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 6}}>STEP 3 / 3</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
Full pass · <span style={{fontStyle:'italic', color: TERRA}}>灰块变真图</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', lineHeight: 1.5}}>
Placeholders → Content<br/>
<span style={{fontSize: 14}}>— 方向对了再填色</span>
</div>
</div>
{/* Two panels: before wireframe (thumbnail) vs after (full card) */}
<div style={{display:'grid', gridTemplateColumns:'0.55fr 60px 1fr', gap: 0,
flex: 1, alignItems:'center'}}>
{/* Left: wireframe thumbnail, still, for contrast */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding: 20, opacity: 0.55, transform:'scale(0.92)',
transformOrigin:'center right'}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.2em', marginBottom: 10}}>
BEFORE
</div>
<div style={{height: 100, background:'#ececec',
border:'1.5px dashed #bbb', display:'flex',
alignItems:'center', justifyContent:'center',
fontFamily: mono, fontSize: 10, color:'#888',
letterSpacing:'0.15em', marginBottom: 12}}>
[ HERO IMAGE ]
</div>
<div style={{height: 14, background:'#e5e0d3', width:'75%', marginBottom: 6}} />
<div style={{height: 10, background:'#ede8db', width:'55%', marginBottom: 14}} />
<div style={{background:'#f5f0e3', padding:'10px 12px',
border: `1px dashed ${LINE}`}}>
<div style={{height: 6, background:'#d9d2c5', width:'92%', marginBottom: 4}} />
<div style={{height: 6, background:'#d9d2c5', width:'88%', marginBottom: 4}} />
<div style={{height: 6, background:'#d9d2c5', width:'70%'}} />
</div>
</div>
{/* Arrow */}
<div style={{display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="40" height="24" viewBox="0 0 40 24">
<path d="M 2 12 L 38 12 M 30 4 L 38 12 L 30 20"
fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
{/* Right: full card */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
boxShadow:'0 20px 60px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.04)',
display:'flex', flexDirection:'column', overflow:'hidden'}}>
<div style={{padding:'14px 24px', borderBottom:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.25em'}}>AFTER · FULL PASS</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em'}}>STOIC READER · v0.3</div>
</div>
{/* Hero image: oil painting */}
<div style={{height: 220, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', inset: 0, opacity: heroReveal}}>
<ArtBlock mood="warm" />
</div>
{heroReveal < 0.95 && (
<div style={{position:'absolute', inset: 0, background:'#ececec',
opacity: 1 - heroReveal, display:'flex', alignItems:'center',
justifyContent:'center', fontFamily: mono, fontSize: 12,
color:'#888', letterSpacing:'0.15em',
border:'1.5px dashed #bbb'}}>
[ HERO IMAGE ]
</div>
)}
{/* Photo credit */}
<div style={{position:'absolute', bottom: 10, right: 12,
fontFamily: mono, fontSize: 9, color:'rgba(255,255,255,0.7)',
letterSpacing:'0.2em', opacity: heroReveal}}>
PHOTO · J. TURNER
</div>
</div>
{/* Title */}
<div style={{padding:'26px 32px 10px', opacity: titleReveal}}>
<div style={{fontFamily: serif, fontSize: 36, fontWeight: 500,
color: INK, lineHeight: 1.15, letterSpacing:'-0.01em',
marginBottom: 6}}>
The Obstacle<br/>
<span style={{fontStyle:'italic', color: TERRA}}>is the Way</span>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: ASH}}>
Marcus Aurelius · 第四卷 · 今日阅读
</div>
</div>
{/* AI insight — NOW 2 lines, not 3 */}
<div style={{margin:'10px 32px 20px', padding:'16px 20px',
background:'#faf6ef', borderLeft: `3px solid ${TERRA}`,
opacity: insightReveal}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
letterSpacing:'0.25em', marginBottom: 8}}>AI INSIGHT · 2 LINES</div>
<div style={{fontFamily: serif, fontSize: 15, color: INK,
lineHeight: 1.55}}>
你最近笔记里「困难」出现 7 次。<br/>
Aurelius 今天正好在说:障碍本身就是路。
</div>
</div>
{/* Meta row */}
<div style={{padding:'14px 32px', borderTop:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em', opacity: meta}}>
<span>READ · 4 MIN</span>
<span>MEMORY · 12 NOTES</span>
<span>CHAT · ASK AURELIUS</span>
</div>
</div>
</div>
{/* Subtitle */}
<div style={{textAlign:'center', marginTop: 30, opacity: subOp}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: INK}}>
迭代到 80%,再做最后 20% 的抛光
</div>
</div>
</div>
);
}
// ── Scene 5: Closing + 4 checkpoints (18 – 22s) ───────────
function Scene5_Closing() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
const titleY = interpolate(elapsed, [0, 1.0], [30, 0], Easing.easeOut);
const lineW = interpolate(elapsed, [0.8, 1.6], [0, 620]);
const items = [
{ text: '问 clarifying questions(一次一批)' },
{ text: '写 assumptions + placeholders' },
{ text: '尽早 show(哪怕只是灰块)' },
{ text: '迭代再抛光,不一次做完' },
];
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column', padding:'60px 120px'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 24}}>
JUNIOR DESIGNER · RECAP
</div>
<div style={{fontFamily: serif, fontSize: 100, fontWeight: 500,
color: INK, lineHeight: 1.05, letterSpacing:'-0.015em',
transform: `translateY(${titleY}px)`, textAlign:'center'}}>
早 <span style={{fontStyle:'italic', color: TERRA}}>show</span>
<span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
早 <span style={{fontStyle:'italic', color: TERRA}}>改</span>
<span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
早 <span style={{fontStyle:'italic', color: TERRA}}>对齐</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 34,
marginBottom: 46}} />
{/* 4 checkpoints */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:'18px 60px',
maxWidth: 1100, width:'100%'}}>
{items.map((it, i) => {
const appear = interpolate(elapsed, [1.6 + i * 0.25, 2.2 + i * 0.25], [0, 1]);
const checkAppear = interpolate(elapsed, [2.0 + i * 0.25, 2.5 + i * 0.25], [0, 1], Easing.spring);
return (
<div key={i} style={{display:'flex', alignItems:'center', gap: 20,
opacity: appear, borderBottom:`1px solid ${LINE}`, paddingBottom: 14}}>
<div style={{width: 36, height: 36, border:`2px solid ${TERRA}`,
display:'flex', alignItems:'center', justifyContent:'center',
flexShrink: 0, background: checkAppear > 0.5 ? TERRA : 'transparent',
color:'#fff', fontFamily: serif, fontWeight: 600, fontSize: 20,
lineHeight: 1, transform: `scale(${0.7 + checkAppear * 0.3})`}}>
{checkAppear > 0.5 ? '✓' : ''}
</div>
<div style={{fontFamily: serif, fontSize: 26, color: INK,
lineHeight: 1.35, letterSpacing:'0.005em'}}>
{it.text}
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Contrast /></Sprite>
<Sprite start={3} end={8}><Scene2_Skeleton /></Sprite>
<Sprite start={8} end={13}><Scene3_FirstShow /></Sprite>
<Sprite start={13} end={18}><Scene4_FullPass /></Sprite>
<Sprite start={18} end={22}><Scene5_Closing /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+747
View File
@@ -0,0 +1,747 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Fallback 设计顾问</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
// ── 20 design philosophies ────────────────────────────────
const PHILOSOPHIES = [
{ n: 'Pentagram', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Massimo Vignelli', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Dieter Rams', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Otl Aicher', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Field.io', school: '运动诗学', en: 'KINETIC' },
{ n: 'Active Theory', school: '运动诗学', en: 'KINETIC' },
{ n: 'Locomotive', school: '运动诗学', en: 'KINETIC' },
{ n: 'Joshua Davis', school: '运动诗学', en: 'KINETIC' },
{ n: 'Kenya Hara', school: '东方哲学', en: 'EASTERN' },
{ n: 'Naoto Fukasawa', school: '东方哲学', en: 'EASTERN' },
{ n: 'Kashiwa Sato', school: '东方哲学', en: 'EASTERN' },
{ n: 'John Maeda', school: '东方哲学', en: 'EASTERN' },
{ n: 'Sagmeister', school: '实验先锋', en: 'AVANT' },
{ n: 'David Carson', school: '实验先锋', en: 'AVANT' },
{ n: 'Paula Scher', school: '实验先锋', en: 'AVANT' },
{ n: 'Tomato', school: '实验先锋', en: 'AVANT' },
{ n: 'Dan Flavin', school: '极简主义', en: 'MINIMAL' },
{ n: 'Ryuichi Sakamoto', school: '极简主义', en: 'MINIMAL' },
{ n: 'Agnes Martin', school: '极简主义', en: 'MINIMAL' },
{ n: 'Donald Judd', school: '极简主义', en: 'MINIMAL' },
];
const SELECTED_INDICES = [0, 4, 8]; // Pentagram, Field.io, Kenya Hara
// ── Shared typography helpers ─────────────────────────────
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Vague brief (0 – 3.5s) ───────────────────────
function Scene1_VagueBrief() {
const { t, elapsed } = useSprite();
const charCount = Math.floor(interpolate(elapsed, [0.3, 1.8], [0, 9]));
const text = '做个好看的页面'.slice(0, charCount);
const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0;
const questionOpacity = interpolate(elapsed, [1.8, 2.4], [0, 1]);
const questionBob = Math.sin(elapsed * 4) * 6;
const fadeOut = interpolate(elapsed, [2.8, 3.5], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: sans, fontSize:14, letterSpacing:'0.3em',
color: ASH, marginBottom: 40}}>
用户需求
</div>
<div style={{display:'flex', alignItems:'flex-start', gap: 36}}>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
color: TERRA, lineHeight: 1, marginTop: -20}}>「</div>
<div style={{fontFamily: serif, fontSize: 96, fontWeight: 400,
color: INK, letterSpacing: '0.02em', position: 'relative'}}>
{text}
<span style={{opacity: cursorBlink ? 1 : 0, color: TERRA,
marginLeft: 4, fontWeight: 300}}>|</span>
</div>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
color: TERRA, lineHeight: 1, marginTop: -20}}>」</div>
</div>
<div style={{fontFamily: sans, fontSize: 20, color: ASH, marginTop: 60,
opacity: questionOpacity, transform: `translateY(${questionBob}px)`,
letterSpacing: '0.05em'}}>
<span style={{color: TERRA, fontSize: 28, marginRight: 12}}>?</span>
风格、受众、情感基调—— 都没说
</div>
</div>
);
}
// ── Scene 2: Advisor activates (3.5 – 6.5s) ───────────────
function Scene2_AdvisorIntro() {
const { elapsed } = useSprite();
const mainY = interpolate(elapsed, [0, 1.2], [40, 0], Easing.easeOut);
const mainOpacity = interpolate(elapsed, [0, 0.8], [0, 1]);
const lineWidth = interpolate(elapsed, [0.8, 1.8], [0, 320]);
const subOpacity = interpolate(elapsed, [1.2, 2], [0, 1]);
const fadeOut = interpolate(elapsed, [2.5, 3], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: sans, fontSize: 12, letterSpacing: '0.4em',
color: TERRA, marginBottom: 24, opacity: mainOpacity}}>
设计方向顾问 · Fallback
</div>
<div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing: '-0.01em',
opacity: mainOpacity, transform: `translateY(${mainY}px)`}}>
推荐 <span style={{fontStyle:'italic', color: TERRA}}>3</span> 个方向
</div>
<div style={{height: 1, background: INK, width: lineWidth, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 26,
color: ASH, marginTop: 28, opacity: subOpacity}}>
从 20 种设计哲学里,按 5 个不同流派差异化推荐
</div>
</div>
);
}
// ── Scene 3: 20 philosophies grid scan (6.5 – 10.5s) ──────
function Scene3_GridScan() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM,
padding: '80px 120px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', opacity: titleOp, marginBottom: 50}}>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
设计哲学库
</div>
<div style={{fontFamily: mono, fontSize: 14, color: ASH, letterSpacing:'0.1em'}}>
20 位设计师 · 5 个流派
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'repeat(5, 1fr)', gap: 20, flex: 1}}>
{PHILOSOPHIES.map((p, i) => {
const stagger = i * 0.06;
const appearT = Math.max(0, Math.min(1, (elapsed - 0.5 - stagger) / 0.4));
const op = appearT;
const ty = (1 - appearT) * 24;
// Scanner highlight: sweeps through 20 cards from t=2.2 to t=3.2
const scannerStart = 2.2 + i * 0.04;
const scannerEnd = scannerStart + 0.25;
const scanHighlight = elapsed > scannerStart && elapsed < scannerEnd ? 1 : 0;
// Selected cards get circled at t=3.3+
const isSelected = SELECTED_INDICES.includes(i);
const selectT = Math.max(0, Math.min(1, (elapsed - 3.3) / 0.5));
const selectOp = isSelected ? selectT : 0;
const selectDim = !isSelected && elapsed > 3.4 ? interpolate(elapsed, [3.4, 3.8], [1, 0.28]) : 1;
return (
<div key={i} style={{
opacity: op * selectDim,
transform: `translateY(${ty}px)`,
background: scanHighlight ? '#fff' : 'transparent',
border: `1px solid ${isSelected && selectT > 0.3 ? TERRA : LINE}`,
borderWidth: isSelected && selectT > 0.3 ? 2 : 1,
padding: '20px 18px',
position: 'relative',
transition: 'none',
}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing: '0.15em', marginBottom: 10}}>
{String(i+1).padStart(2,'0')} · {p.en}
</div>
<div style={{fontFamily: serif, fontSize: 22, fontWeight: 500,
color: INK, lineHeight: 1.15, marginBottom: 6}}>
{p.n}
</div>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 14,
color: ASH}}>
{p.school}
</div>
{isSelected && selectT > 0.4 && (
<div style={{position:'absolute', top: -10, right: -10,
width: 26, height: 26, borderRadius: '50%', background: TERRA,
color: '#fff', display:'flex', alignItems:'center',
justifyContent:'center', fontFamily: serif, fontSize: 14,
fontWeight: 600, opacity: selectOp}}>
{SELECTED_INDICES.indexOf(i) + 1}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── Scene 4: Three-panel parallel demo generation (10.5 – 19s) ──
function Scene4_ParallelDemos() {
const { elapsed } = useSprite();
const slideIn = interpolate(elapsed, [0, 1], [200, 0], Easing.easeOut);
const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
const panels = [
{ name: 'Pentagram', school: '信息建筑派', en: 'Information Architecture',
delay: 0, render: 'pentagram' },
{ name: 'Field.io', school: '运动诗学派', en: 'Kinetic Poetry',
delay: 0.3, render: 'field' },
{ name: 'Kenya Hara', school: '东方哲学派', en: 'Eastern Minimalism',
delay: 0.6, render: 'hara' },
];
return (
<div style={{position:'absolute', inset:0, background:CREAM,
padding: '60px 60px 40px', display:'flex', flexDirection:'column',
opacity}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 4}}>步骤 3 / 4</div>
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
并行生成视觉 Demo
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign: 'right'}}>
"看到比说到更有效"<br/>
<span style={{fontSize: 14}}>— 设计顾问模式 · Phase 5</span>
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
flex: 1, transform: `translateY(${slideIn}px)`}}>
{panels.map((p, i) => (
<DemoPanel key={i} panel={p} localElapsed={elapsed - p.delay} />
))}
</div>
</div>
);
}
function DemoPanel({ panel, localElapsed }) {
const progressT = Math.max(0, Math.min(1, localElapsed / 3.0));
const progressPct = progressT * 100;
const done = progressT >= 0.92;
// Content fades in during the last 0.7s of generation — overlaps with
// skeleton fade-out so there's no empty-canvas gap when "READY" appears.
const contentReveal = interpolate(localElapsed, [2.4, 3.2], [0, 1], Easing.easeOut);
const skeletonOp = interpolate(localElapsed, [2.4, 3.2], [1, 0], Easing.easeOut);
return (
<div style={{
background:'#fff',
border: `1px solid ${LINE}`,
display:'flex', flexDirection:'column',
position:'relative',
}}>
{/* Header */}
<div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
{panel.name}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH, marginTop: 2}}>
{panel.en}
</div>
</div>
<div style={{fontFamily: mono, fontSize: 10, color: done ? TERRA : ASH,
letterSpacing: '0.15em'}}>
{done ? '✓ READY' : 'GENERATING'}
</div>
</div>
{/* Canvas */}
<div style={{flex: 1, position: 'relative', overflow: 'hidden'}}>
{skeletonOp > 0.02 && (
<div style={{position:'absolute', inset:0, opacity: skeletonOp}}>
<GenerationSkeleton progress={progressT} />
</div>
)}
<div style={{position:'absolute', inset:0, opacity: contentReveal}}>
{panel.render === 'pentagram' && <PentagramDemo />}
{panel.render === 'field' && <FieldDemo elapsed={localElapsed - 3.2} />}
{panel.render === 'hara' && <HaraDemo />}
</div>
</div>
{/* Progress bar */}
<div style={{height: 2, background: '#eee', position: 'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${progressPct}%`, background: TERRA,
transition:'none'}} />
</div>
</div>
);
}
function GenerationSkeleton({ progress }) {
const bars = [60, 85, 40, 72, 90, 55, 68];
return (
<div style={{padding: 24, display:'flex', flexDirection:'column', gap: 14}}>
{bars.map((w, i) => (
<div key={i} style={{height: 10, width: `${w}%`,
background: `linear-gradient(90deg, ${LINE} 0%, ${LINE} ${100-progress*80}%, #fff ${100-progress*80}%)`,
opacity: 0.6 + progress * 0.4}} />
))}
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
marginTop: 20, letterSpacing:'0.1em'}}>
{progress < 0.3 && '▸ loading style tokens...'}
{progress >= 0.3 && progress < 0.6 && '▸ composing layout...'}
{progress >= 0.6 && progress < 0.9 && '▸ applying typography...'}
{progress >= 0.9 && '▸ finalizing...'}
</div>
</div>
);
}
// ── Pentagram demo: serif editorial, strict grid, monochrome ──
function PentagramDemo() {
return (
<div style={{padding: '28px 28px 24px', background:'#fafafa', height:'100%',
display:'flex', flexDirection:'column', fontFamily: serif, color:'#111'}}>
<div style={{display:'flex', justifyContent:'space-between',
borderBottom:'1px solid #111', paddingBottom: 10, marginBottom: 16,
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em'}}>
<span>VOL. 01 · MMXXVI</span>
<span>NO. 043</span>
</div>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color:'#888', marginBottom: 10}}>ESSAY</div>
<div style={{fontSize: 40, lineHeight: 1.05, fontWeight: 500,
letterSpacing: '-0.02em', marginBottom: 18}}>
A Pure<br/>
<span style={{fontStyle:'italic'}}>Information</span><br/>
Architecture
</div>
<div style={{height: 1, background:'#111', margin:'8px 0 14px'}} />
<div style={{fontSize: 13, lineHeight: 1.55, color:'#333', flex: 1}}>
Designed not to impress, but to inform. The grid carries meaning; typography does the work.
</div>
<div style={{borderTop:'1px solid #111', paddingTop: 10, marginTop: 14,
display:'flex', justifyContent:'space-between', fontFamily: mono,
fontSize: 9, letterSpacing:'0.2em', color:'#888'}}>
<span>NEW YORK</span>
<span>PENTAGRAM</span>
</div>
</div>
);
}
// ── Field.io demo: dark, kinetic geometric shapes ──
function FieldDemo({ elapsed }) {
const e = Math.max(0, elapsed || 0);
return (
<div style={{padding: 0, background:'#0e1016', height:'100%',
position:'relative', overflow:'hidden'}}>
<svg viewBox="0 0 400 500" width="100%" height="100%"
style={{position:'absolute', inset:0}} preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="fg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#ff6a3d" />
<stop offset="100%" stopColor="#c04a1a" />
</linearGradient>
<linearGradient id="fg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#4a9eff" />
<stop offset="100%" stopColor="#1a4fc0" />
</linearGradient>
</defs>
{/* Concentric circles breathing */}
{[0, 1, 2, 3].map(i => (
<circle key={i} cx="200" cy="280"
r={40 + i * 50 + Math.sin(e * 1.2 + i) * 10}
fill="none" stroke="url(#fg1)" strokeWidth={1.5}
opacity={0.4 - i * 0.08} />
))}
{/* Rotating triangle */}
<g transform={`translate(200 280) rotate(${e * 20})`}>
<polygon points="0,-70 60,35 -60,35" fill="url(#fg2)" opacity="0.7" />
</g>
{/* Orbiting dots */}
{[0, 1, 2, 3, 4, 5].map(i => {
const angle = (e * 0.8 + i * Math.PI / 3);
return <circle key={i} cx={200 + Math.cos(angle) * 150}
cy={280 + Math.sin(angle) * 150} r={4} fill="#ff6a3d" opacity={0.9}/>;
})}
</svg>
<div style={{position:'absolute', top: 24, left: 24, right: 24,
display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#fff', opacity: 0.7}}>
<span>FIELD.IO</span>
<span>LIVE · RECORDING</span>
</div>
<div style={{position:'absolute', bottom: 24, left: 24, right: 24,
fontFamily: serif, fontStyle:'italic', fontSize: 20, color:'#fff',
letterSpacing:'0.02em'}}>
kinetic identity<br/>
<span style={{fontFamily: mono, fontSize: 10, fontStyle:'normal',
letterSpacing:'0.2em', color:'#ff6a3d', opacity: 0.8}}>
/ motion is the brand
</span>
</div>
</div>
);
}
// ── Kenya Hara demo: vast white space, tiny dot, haiku ──
function HaraDemo() {
return (
<div style={{padding: 0, background:'#fdfbf6', height:'100%',
position:'relative'}}>
<div style={{position:'absolute', top: 28, left: 32,
fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#aaa'}}>
HARA · MMXXVI
</div>
<div style={{position:'absolute', top: '42%', left:'50%',
transform:'translate(-50%, -50%)', width: 14, height: 14,
borderRadius:'50%', background:'#1a1a1a'}} />
<div style={{position:'absolute', top:'58%', left:'50%',
transform:'translateX(-50%)', fontFamily: serif, fontStyle:'italic',
fontSize: 14, color:'#1a1a1a', letterSpacing:'0.1em'}}>
white.
</div>
<div style={{position:'absolute', bottom: 32, right: 32,
writingMode:'vertical-rl', fontFamily: "'Noto Serif SC', serif",
fontSize: 16, color:'#888', letterSpacing:'0.2em'}}>
原 研 哉
</div>
<div style={{position:'absolute', bottom: 28, left: 32,
fontFamily: serif, fontStyle:'italic', fontSize: 11, color:'#999',
maxWidth: 200, lineHeight: 1.6}}>
"Emptiness is not nothing—<br/>it is everything that could be."
</div>
</div>
);
}
// ── Scene 5: User selects Kenya Hara (19 – 22s) ───────────
function Scene5_Select() {
const { elapsed } = useSprite();
// Cursor travels from right edge toward middle panel
const cursorX = interpolate(elapsed, [0, 1.2], [1750, 960], Easing.easeInOut);
const cursorY = interpolate(elapsed, [0, 1.2], [240, 540], Easing.easeInOut);
const cursorOp = interpolate(elapsed, [0, 0.2], [0, 1]);
// Middle panel selection lock-in
const selectLock = Math.max(0, Math.min(1, (elapsed - 1.2) / 0.4));
// Left + right panels dim + shrink
const sideDim = interpolate(elapsed, [1.2, 1.8], [1, 0.2]);
const sideScale = interpolate(elapsed, [1.2, 1.8], [1, 0.92], Easing.easeOut);
// Middle scales up
const midScale = interpolate(elapsed, [1.2, 1.8], [1, 1.06], Easing.easeOut);
return (
<div style={{position:'absolute', inset:0, background:CREAM,
padding: '60px 60px 40px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 4}}>步骤 4 / 4</div>
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
用户选定方向
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH}}>
——或混合:"A 的配色 + C 的布局"
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
flex: 1}}>
<StaticPanel which="pentagram" opacity={sideDim} scale={sideScale} />
<StaticPanel which="hara" opacity={1} scale={midScale} selected={selectLock > 0.5}/>
<StaticPanel which="field" opacity={sideDim} scale={sideScale} />
</div>
{/* Cursor */}
<div style={{position:'absolute', left: cursorX, top: cursorY,
opacity: cursorOp, pointerEvents:'none', zIndex: 50,
filter:'drop-shadow(0 4px 8px rgba(0,0,0,0.2))'}}>
<svg width="36" height="44" viewBox="0 0 36 44">
<path d="M 2 2 L 2 38 L 11 30 L 16 42 L 22 40 L 17 28 L 28 28 Z"
fill="#fff" stroke="#1a1a1a" strokeWidth="2" strokeLinejoin="round"/>
</svg>
</div>
{/* "Selected" callout */}
{selectLock > 0.5 && (
<div style={{position:'absolute', left:'50%', top: 140,
transform:'translateX(-50%)', background: TERRA, color:'#fff',
padding:'10px 24px', fontFamily: mono, fontSize: 12,
letterSpacing:'0.25em', opacity: selectLock, zIndex: 40}}>
✓ SELECTED
</div>
)}
</div>
);
}
function StaticPanel({ which, opacity, scale, selected }) {
const titles = {
pentagram: { n: 'Pentagram', en: 'Information Architecture' },
hara: { n: 'Kenya Hara', en: 'Eastern Minimalism' },
field: { n: 'Field.io', en: 'Kinetic Poetry' },
};
const t = titles[which];
return (
<div style={{
background:'#fff',
border: selected ? `3px solid ${TERRA}` : `1px solid ${LINE}`,
opacity, transform: `scale(${scale})`, transformOrigin:'center center',
display:'flex', flexDirection:'column',
}}>
<div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
{t.n}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH, marginTop: 2}}>
{t.en}
</div>
</div>
<div style={{fontFamily: mono, fontSize: 10,
color: selected ? TERRA : '#999',
letterSpacing: '0.15em'}}>
{selected ? '✓ SELECTED' : 'READY'}
</div>
</div>
<div style={{flex: 1, position:'relative', overflow:'hidden'}}>
{which === 'pentagram' && <PentagramDemo />}
{which === 'field' && <FieldDemo elapsed={10} />}
{which === 'hara' && <HaraDemo />}
</div>
</div>
);
}
// ── Scene 6: Ready to execute (22 – 24s) ──────────────────
function Scene6_Final() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
const lineW = interpolate(elapsed, [0.6, 1.4], [0, 600]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 20}}>
NEXT · JUNIOR DESIGNER PASS
</div>
<div style={{fontFamily: serif, fontSize: 104, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
开始 <span style={{fontStyle:'italic', color: TERRA}}>Kenya Hara</span> 风格
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: ASH, marginTop: 28, maxWidth: 700, textAlign:'center', lineHeight: 1.5}}>
"方向确认 → 回到 Junior Designer 主干流程<br/>
这时已有明确的 design context,不再是凭空做"
</div>
</div>
);
}
// ── Watermark (always visible) ────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3.5}><Scene1_VagueBrief /></Sprite>
<Sprite start={3.5} end={6.5}><Scene2_AdvisorIntro /></Sprite>
<Sprite start={6.5} end={10.5}><Scene3_GridScan /></Sprite>
<Sprite start={10.5} end={19}><Scene4_ParallelDemos /></Sprite>
<Sprite start={19} end={22}><Scene5_Select /></Sprite>
<Sprite start={22} end={24}><Scene6_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+153 -18
View File
@@ -201,35 +201,164 @@
**反例**:底部画 `00:42 ──── PROJECT NAME`、画面右下角画"CH 03 / 06"章节计数、画面边缘画版本号"v0.3.1"——都是伪 chrome filler。 **反例**:底部画 `00:42 ──── PROJECT NAME`、画面右下角画"CH 03 / 06"章节计数、画面边缘画版本号"v0.3.1"——都是伪 chrome filler。
## 12. 录屏前置空白 —— 用 `window.__ready` 同步动画 t=0 ## 12. 录屏前置空白 + 录屏起点偏移 —— `__ready` × tick × lastTick 三联陷阱
**踩的坑**:60 秒动画导出 MP4,前 2-3 秒是空白页面。`ffmpeg --trim=0.3` 剪不掉。 **踩的坑(A · 前置空白)**:60 秒动画导出 MP4,前 2-3 秒是空白页面。`ffmpeg --trim=0.3` 剪不掉。
**根因**:Playwright `recordVideo` 从 `newContext()` 那一刻就开始写 WebM。但此时 Babel Standalone 还在编译 inline JSX、React 还没 mount、`document.fonts.ready.then(root.render)` 还没触发。`page.goto(url, { waitUntil: 'networkidle' })` 只等网络空闲,检测不到 JS 执行阶段——所以 WebM 前 1.5-3s 是空白页。 **踩的坑(B · 起点偏移,2026-04-20 真实事故)**:导出 24 秒视频,用户观感「视频 19 秒才开始播第一帧」。实际上动画从 t=5 开始录,录到 t=24 后 loop 回 t=0,再录 5 秒到 end——所以视频最后 5 秒才是动画真正的开头。
**根因**(两个坑共享一个根因):
Playwright `recordVideo` 从 `newContext()` 那一刻就开始写 WebM,此时 Babel/React/字体加载共耗时 L 秒(2-6s)。录屏脚本等 `window.__ready = true` 作为「动画从这里开始」的锚点——它和动画 `time = 0` 必须严格 pair。有两种常见错法:
| 错法 | 症状 |
|------|------|
| `__ready` 在 `useEffect` 或同步 setup 阶段设(在 tick 第一帧之前) | 录屏脚本以为动画开始了,实际 WebM 还在录空白页 → **前置空白** |
| tick 的 `lastTick = performance.now()` 在**脚本顶层**初始化 | 字体加载 L 秒被算进首帧 `dt`,`time` 瞬间跳到 L → 录屏全程滞后 L 秒 → **起点偏移** |
**✅ 正确的完整 starter tick 模板**(手写动画必须用这个骨架):
```js
// ━━━━━━ state ━━━━━━
let time = 0;
let playing = false; // ❗ 默认不播,等字体 ready 再启动
let lastTick = null; // ❗ sentinel——tick 首帧时 dt 强制为 0(别用 performance.now())
const fired = new Set();
// ━━━━━━ tick ━━━━━━
function tick(now) {
if (lastTick === null) {
lastTick = now;
window.__ready = true; // ✅ pair:「录屏起点」与「动画 t=0」同一帧
render(0); // 再渲一次确保 DOM 就绪(此时字体已 ready)
requestAnimationFrame(tick);
return;
}
const dt = (now - lastTick) / 1000; // 首帧之后 dt 才开始推进
lastTick = now;
if (playing) {
let t = time + dt;
if (t >= DURATION) {
t = window.__recording ? DURATION - 0.001 : 0; // 录制时不 loop,留 0.001s 保留末帧
if (!window.__recording) fired.clear();
}
time = t;
render(time);
}
requestAnimationFrame(tick);
}
// ━━━━━━ boot ━━━━━━
// 不要在顶层立即 rAF——等字体加载完才启动
document.fonts.ready.then(() => {
render(0); // 先把初始画面画出来(字体已就绪)
playing = true;
requestAnimationFrame(tick); // 首次 tick 会 pair __ready + t=0
});
// ━━━━━━ seek 接口(供 render-video 防御性矫正用)━━━━━━
window.__seek = (t) => { fired.clear(); time = t; lastTick = null; render(t); };
```
**为什么这个模板对**:
| 环节 | 为什么必须这样 |
|------|-------------|
| `lastTick = null` + 首帧 `return` | 避免「脚本加载到 tick 首次执行」的 L 秒被算进动画时间 |
| `playing = false` 默认 | 字体加载期间 `tick` 即使运行也不推进 time,避免渲染错位 |
| `__ready` 在 tick 首帧设 | 录屏脚本此刻开始计时,对应的画面是动画真正的 t=0 |
| `document.fonts.ready.then(...)` 里才启动 tick | 规避字体 fallback 宽度测量、避免首帧字体跳变 |
| `window.__seek` 存在 | 让 `render-video.js` 可以主动矫正——第二道防线 |
**录屏脚本端的对应防御**:
1. `addInitScript` 注入 `window.__recording = true`(先于 page goto)
2. `waitForFunction(() => window.__ready === true)`,记录此刻偏移作为 ffmpeg trim
3. **额外**:`__ready` 之后主动 `page.evaluate(() => window.__seek && window.__seek(0))`,把 HTML 可能的 time 偏差强制归零——这是第二道防线,对付不严格遵守 starter 模板的 HTML
**验证方法**:导出 MP4 后
```bash
ffmpeg -i video.mp4 -ss 0 -vframes 1 frame-0.png
ffmpeg -i video.mp4 -ss $DURATION-0.1 -vframes 1 frame-end.png
```
首帧必须是动画 t=0 的初始状态(不是中段,不是黑),末帧必须是动画终态(不是第二轮 loop 的某个时刻)。
**参考实现**:`assets/animations.jsx` 的 Stage 组件、`scripts/render-video.js` 都已按此协议实现。手写 HTML 必须套 starter tick 模板——每一行都是防过具体 bug。
## 13. 录制时禁止 loop —— `window.__recording` 信号
**踩的坑**:动画 Stage 默认 `loop=true`(浏览器里方便看效果)。`render-video.js` 录完 duration 秒还多等 300ms 缓冲才停止,这 300ms 让 Stage 进入下一循环。ffmpeg `-t DURATION` 截取时,最后 0.5-1s 落入下一循环——视频结尾突然回到第一帧(Scene 1),观众以为视频出 bug。
**根因**:录制脚本和 HTML 之间没有"我在录制"的握手协议。HTML 不知道自己被录,依然按浏览器交互场景循环。
**规则**: **规则**:
1. **动画代码**在 tick 第一帧发 `window.__ready = true`,和动画 t=0 同步: 1. **录制脚本**:在 `addInitScript` 里注入 `window.__recording = true`(先于 page goto):
```js ```js
function tick(now) { await recordCtx.addInitScript(() => { window.__recording = true; });
if (last === null) {
last = now;
window.__ready = true; // 必须在这里,不是 useEffect
}
// ... 动画推进
}
``` ```
为什么同步?如果 `__ready` 在 tick 之前设(如 `useEffect` 或 rAF 排队),触发时 WebM 光标还在空白页;如果在 tick 之后设(rAF 嵌套),则动画已经跑了几帧被 trim 掉。**pair 起来**才是对的。
2. **录屏脚本**`page.goto` 之后 `waitForFunction(() => window.__ready === true)`,记录此时相对 WebM 起点的秒数作为 ffmpeg trim 偏移。完全不靠猜。 2. **Stage 组件**:识别这个信号,强制 loop=false:
```js
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
// ...
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
// ↑ 留 0.001 防止 Sprite end=duration 被关掉
```
3. **字体等待**放在 tick 启动条件里:`document.fonts.ready.then(() => rAF(tick))`。这样 tick 第一帧就是字体已就绪的画面——`__ready` 信号 = WebM 捕到的第一个"用户会看到"的动画帧。 3. **结尾 Sprite 的 fadeOut**:录制场景下应设 `fadeOut={0}`,否则视频末尾会渐变到透明/暗色——用户期望停在清晰的最后一帧,不是淡出。手写 HTML 时建议结尾 Sprite 都用 `fadeOut={0}`。
**不这么做的代价**:固定 trim 靠猜。机器快慢、字体缓存状态、网速每次不同——某台机器上的 3s trim 换到另一台可能不够或截掉开头。动态测量从根本解决。 **参考实现**:`assets/animations.jsx` 的 Stage / `scripts/render-video.js` 都已内置握手。手写 Stage 必须实现 `__recording` 检测——否则录制必踩这个坑。
**参考实现**:`assets/animations.jsx` 的 Stage 组件已内置。`scripts/render-video.js` 已内置 auto-trim 逻辑。非 animations.jsx 的手写 HTML,自行在 tick/渲染循环的第一帧设信号。 **验证**:导出 MP4 后 `ffmpeg -ss 19.8 -i video.mp4 -frames:v 1 end.png`,检查倒数 0.2 秒是否还是预期最后一帧,没有突然切换到另一个 scene。
**验证方法**:导出后 `ffmpeg -i video.mp4 -ss 0 -vframes 1 frame-0.png`,检查第一帧是动画应有的初始状态(不是动画中段、不是黑屏)。 ## 14. 60fps 视频默认用帧复制 —— minterpolate 兼容性差
**踩的坑**:`convert-formats.sh` 用 `minterpolate=fps=60:mi_mode=mci...` 生成的 60fps MP4,在 macOS QuickTime / Safari 部分版本下无法打开(一片黑或直接拒打)。VLC / Chrome 能打开。
**根因**:minterpolate 输出的 H.264 elementary stream 包含某些播放器解析有问题的 SEI / SPS 字段。
**规则**:
- 默认 60fps 用简单 `fps=60` filter(帧复制),兼容性广(QuickTime/Safari/Chrome/VLC 都能开)
- 高质量插帧用 `--minterpolate` flag 显式启用——但**必须本地测过**目标播放器再交付
- 60fps 标签价值是**上传平台的算法识别**(Bilibili / YouTube 上 60fps 标记会优先推流),实际感知流畅度对 CSS 动画来说提升微弱
- 加 `-profile:v high -level 4.0` 提升 H.264 通用兼容性
**`convert-formats.sh` 已默认改成兼容模式**。如果你需要插帧高质量,加 `--minterpolate` flag:
```bash
bash convert-formats.sh input.mp4 --minterpolate
```
## 15. `file://` + 外部 `.jsx` 的 CORS 陷阱 —— 单文件交付必须内联引擎
**踩的坑**:动画 HTML 里用 `<script type="text/babel" src="animations.jsx"></script>` 外部加载引擎。本机双击打开(`file://` 协议)→ Babel Standalone 走 XHR 拉 `.jsx` → Chrome 报 `Cross origin requests are only supported for protocol schemes: http, https, chrome, chrome-extension...` → 整页黑屏,不报 `pageerror` 只报 console error,很容易当"动画没触发"误诊。
启 HTTP server 也未必救得了——本机有全局代理时 `localhost` 也会走代理,返回 502 / 连接失败。
**规则**:
- **单文件交付(双击打开即用的 HTML)** → `animations.jsx` 必须**内联**到 `<script type="text/babel">...</script>` 标签内,不要用 `src="animations.jsx"`
- **多文件项目(起 HTTP server 演示)** → 可以外部加载,但交付时明确写清 `python3 -m http.server 8000` 命令
- 判断标准:交付给用户的是"HTML 文件"还是"带 server 的项目目录"?前者用内联
- Stage 组件 / animations.jsx 经常 200+ 行——贴进 HTML `<script>` 块完全可接受,别怕体积
**最小验证**:双击你生成的 HTML,**不要**通过任何 server 打开。如果 Stage 正常显示动画首帧,才算通过。
## 16. 跨 scene 反色上下文 —— 画面内元素不要硬编码颜色
**踩的坑**:做多场景动画时,`ChapterLabel` / `SceneNumber` / `Watermark` 等**跨 scene 都出现**的元素,在组件里写死 `color: '#1A1A1A'`(深色文字)。前 4 个 scene 浅底 OK,到第 5 个黑底 scene 时"05"和水印直接消失——不报错、不触发任何检查、关键信息隐形。
**规则**:
- **跨多 scene 复用的画面内元素**(chapter 标签 / scene 编号 / 时间码 / 水印 / 版权条)**禁止硬编码颜色值**
- 改用三种方式之一:
1. **`currentColor` 继承**:元素只写 `color: currentColor`,父 scene 容器设 `color: 计算值`
2. **invert prop**:组件接受 `<ChapterLabel invert />` 手动切换深浅
3. **基于底色自动计算**:`color: contrast-color(var(--scene-bg))`(CSS 4 新 API,或 JS 判断)
- 交付前用 Playwright 抽**每个 scene 的代表帧**,人眼过一遍"跨 scene 元素"是否都可见
这条坑的隐蔽性在于——**没有 bug 报警**。只有人眼或 OCR 能发现。
## 快速自查清单(开工前 5 秒) ## 快速自查清单(开工前 5 秒)
@@ -242,4 +371,10 @@
- [ ] 第 0 帧是完整初始状态,不是空白? - [ ] 第 0 帧是完整初始状态,不是空白?
- [ ] 画面内没有「伪 chrome」装饰(进度条/时间码/底部署名条与 Stage scrubber 撞车)? - [ ] 画面内没有「伪 chrome」装饰(进度条/时间码/底部署名条与 Stage scrubber 撞车)?
- [ ] 动画 tick 第一帧同步设 `window.__ready = true`?(用 animations.jsx 自带;手写 HTML 自己加) - [ ] 动画 tick 第一帧同步设 `window.__ready = true`?(用 animations.jsx 自带;手写 HTML 自己加)
- [ ] 导出后抽第 0 帧验证是动画初始状态? - [ ] Stage 检测 `window.__recording` 强制 loop=false?(手写 HTML 必加)
- [ ] 结尾 Sprite 的 `fadeOut` 设为 0(视频末尾停清晰帧)?
- [ ] 60fps MP4 默认用帧复制模式(兼容性),高质量插帧才加 `--minterpolate`?
- [ ] 导出后抽第 0 帧 + 末帧验证是动画初始/最终状态?
- [ ] 涉及具体品牌(Stripe/Anthropic/Lovart/...):走完了「品牌资产协议」(SKILL.md §1.a 五步)?有没有写 `brand-spec.md`?
- [ ] 单文件交付的 HTML:`animations.jsx` 是内联的,不是 `src="..."`?(file:// 下 external .jsx 会 CORS 黑屏)
- [ ] 跨 scene 出现的元素(chapter 标签/水印/scene 编号)没有硬编码颜色?在每个 scene 底色下都可见?
+243
View File
@@ -0,0 +1,243 @@
# 可编辑 PPTX 导出:HTML 硬约束 + 尺寸决策 + 常见错误
本文档讲的是**用 `scripts/html2pptx.js` + `pptxgenjs` 把 HTML 逐元素翻译成真·可编辑 PowerPoint 文本框**的路径。和 `export_deck_pptx.mjs --mode image`(截图铺底、文字变图片、不可编辑)是两回事。
> **核心前提**:要走这条路,HTML 必须从第一行就按下面 4 条约束写。**不是写完再转**——事后补救会触发 2-3 小时返工(2026-04-20 期权私董会项目实测踩坑)。
---
## 画布尺寸:用 960×540pt(LAYOUT_WIDE)
PPTX 单位是 **inch**(物理尺寸),不是 px。决策原则:body 的 computedStyle 尺寸要**匹配 presentation layout 的 inch 尺寸**(±0.1",由 `html2pptx.js` 的 `validateDimensions` 强制检查)。
### 3 个候选尺寸对比
| HTML body | 物理尺寸 | 对应 PPT layout | 何时选 |
|---|---|---|---|
| **`960pt × 540pt`** | **13.333″ × 7.5″** | **pptxgenjs `LAYOUT_WIDE`** | ✅ **默认推荐**(现代 PowerPoint 16:9 标配) |
| `720pt × 405pt` | 10″ × 5.625″ | 自定义 | 仅当用户指定「老版 PowerPoint Widescreen」模板时 |
| `1920px × 1080px` | 20″ × 11.25″ | 自定义 | ❌ 非标尺寸,投影后字体显得异常小 |
**别把 HTML 尺寸当分辨率想。** PPTX 是矢量文档,body 尺寸决定的是**物理尺寸**不是清晰度。超大 body(20″×11.25″)不会让文字更清晰——只会让字号 pt 相对画布变小,投影/打印时反而更难看。
### body 写法三选一(等价)
```css
body { width: 960pt; height: 540pt; } /* 最清晰,推荐 */
body { width: 1280px; height: 720px; } /* 等价,px 习惯 */
body { width: 13.333in; height: 7.5in; } /* 等价,英寸直觉 */
```
配套的 pptxgenjs 代码:
```js
const pptx = new pptxgen();
pptx.layout = 'LAYOUT_WIDE'; // 13.333 × 7.5 inch, 无需自定义
```
---
## 4 条硬约束(违反会直接报错)
`html2pptx.js` 把 HTML 的 DOM 逐元素翻译成 PowerPoint 对象。PowerPoint 的格式约束投射到 HTML 上 = 下面 4 条规则。
### 规则 1:DIV 里不能直接写文字 — 必须用 `<p>` 或 `<h1>`-`<h6>` 包裹
```html
<!-- ❌ 错误:文字直接在 div 里 -->
<div class="title">Q3营收增长23%</div>
<!-- ✅ 正确:文字在 <p> 或 <h1>-<h6> 里 -->
<div class="title"><h1>Q3营收增长23%</h1></div>
<div class="body"><p>新用户是主要驱动力</p></div>
```
**为什么**:PowerPoint 文本必须存在 text frame 里,text frame 对应 HTML 的段落级元素(p/h*/li)。裸 `<div>` 在 PPTX 里没有对应的文本容器。
**也不能用 `<span>` 承载主文字**——span 是行内元素,没法独立对齐成文本框。span 只能**夹在 p/h\* 里**做局部样式(加粗、换色)。
### 规则 2:不支持 CSS 渐变 — 只能用纯色
```css
/* ❌ 错误 */
background: linear-gradient(to right, #FF6B6B, #4ECDC4);
/* ✅ 正确:纯色 */
background: #FF6B6B;
/* ✅ 如果必须多色条纹,用 flex 子元素各自纯色 */
.stripe-bar { display: flex; }
.stripe-bar div { flex: 1; }
.red { background: #FF6B6B; }
.teal { background: #4ECDC4; }
```
**为什么**:PowerPoint 的 shape fill 只支持 solid/gradient-fill 两种,但 pptxgenjs 的 `fill: { color: ... }` 只映射 solid。渐变走 PowerPoint 原生 gradient 需要另写结构,目前工具链不支持。
### 规则 3:背景/边框/阴影只能在 DIV 上,不能在文字标签上
```html
<!-- ❌ 错误:<p> 有背景色 -->
<p style="background: #FFD700; border-radius: 4px;">重点内容</p>
<!-- ✅ 正确:外层 div 承载背景/边框,<p> 只负责文字 -->
<div style="background: #FFD700; border-radius: 4px; padding: 8pt 12pt;">
<p>重点内容</p>
</div>
```
**为什么**:PowerPoint 里 shape(方块/圆角矩形)和 text frame 是两个对象。HTML 的 `<p>` 只翻译成 text frame,背景/边框/阴影属于 shape——必须在**包裹 text 的 div** 上写。
### 规则 4:DIV 不能用 `background-image` — 用 `<img>` 标签
```html
<!-- ❌ 错误 -->
<div style="background-image: url('chart.png')"></div>
<!-- ✅ 正确 -->
<img src="chart.png" style="position: absolute; left: 50%; top: 20%; width: 300pt; height: 200pt;" />
```
**为什么**:`html2pptx.js` 只从 `<img>` 元素提取图片路径,不解析 CSS 的 `background-image` URL。
---
## Path A HTML 模板骨架
每张 slide 一个独立 HTML 文件,彼此作用域隔离(避开单文件 deck 的 CSS 污染)。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 960pt; height: 540pt; /* ⚠️ 匹配 LAYOUT_WIDE */
font-family: system-ui, -apple-system, "PingFang SC", sans-serif;
background: #FEFEF9; /* 纯色,不能渐变 */
overflow: hidden;
}
/* DIV 负责布局/背景/边框 */
.card {
position: absolute;
background: #1A4A8A; /* 背景在 DIV 上 */
border-radius: 4pt;
padding: 12pt 16pt;
}
/* 文字标签只负责字体样式,不加背景/边框 */
.card h2 { font-size: 24pt; color: #FFFFFF; font-weight: 700; }
.card p { font-size: 14pt; color: rgba(255,255,255,0.85); }
</style>
</head>
<body>
<!-- 标题区:外层 div 定位,内层文字标签 -->
<div style="position: absolute; top: 40pt; left: 60pt; right: 60pt;">
<h1 style="font-size: 36pt; color: #1A1A1A; font-weight: 700;">标题用断言句,不是主题词</h1>
<p style="font-size: 16pt; color: #555555; margin-top: 10pt;">副标题补充说明</p>
</div>
<!-- 内容卡片:div 负责背景,h2/p 负责文字 -->
<div class="card" style="top: 130pt; left: 60pt; width: 240pt; height: 160pt;">
<h2>要点一</h2>
<p>简短说明文字</p>
</div>
<!-- 列表:使用 ul/li,不用手动 • 符号 -->
<div style="position: absolute; top: 320pt; left: 60pt; width: 540pt;">
<ul style="font-size: 16pt; color: #1A1A1A; padding-left: 24pt; list-style: disc;">
<li>第一条要点</li>
<li>第二条要点</li>
<li>第三条要点</li>
</ul>
</div>
<!-- 插图:用 <img> 标签,不用 background-image -->
<img src="illustration.png" style="position: absolute; right: 60pt; top: 110pt; width: 320pt; height: 240pt;" />
</body>
</html>
```
---
## 常见错误速查
| 错误信息 | 原因 | 修复方法 |
|---------|------|---------|
| `DIV element contains unwrapped text "XXX"` | div 里有裸文字 | 把文字包进 `<p>` 或 `<h1>`-`<h6>` |
| `CSS gradients are not supported` | 用了 linear/radial-gradient | 改为纯色,或用 flex 子元素分段 |
| `Text element <p> has background` | `<p>` 标签加了背景色 | 外套 `<div>` 承载背景,`<p>` 只写文字 |
| `Background images on DIV elements are not supported` | div 用了 background-image | 改为 `<img>` 标签 |
| `HTML content overflows body by Xpt vertically` | 内容超出 540pt | 减少内容或缩小字号,或 `overflow: hidden` 截断 |
| `HTML dimensions don't match presentation layout` | body 尺寸和 pres layout 对不上 | body 用 `960pt × 540pt` 配 `LAYOUT_WIDE`;或 defineLayout 自定义尺寸 |
| `Text box "XXX" ends too close to bottom edge` | 大字号 `<p>` 距离 body 底边 < 0.5 inch | 往上挪,留足下边距;PPT 底部本身就会被遮住一部分 |
---
## 基本工作流(3 步出 PPTX)
### Step 1:按约束写每页独立 HTML
```
我的Deck/
├── slides/
│ ├── 01-cover.html # 每个文件都是完整 960×540pt HTML
│ ├── 02-agenda.html
│ └── ...
└── illustration/ # 所有 <img> 引用的图片
├── chart1.png
└── ...
```
### Step 2:写 build.js 调用 `html2pptx.js`
```js
const pptxgen = require('pptxgenjs');
const html2pptx = require('../scripts/html2pptx.js'); // 本 skill 脚本
(async () => {
const pres = new pptxgen();
pres.layout = 'LAYOUT_WIDE'; // 13.333 × 7.5 inch,匹配 HTML 的 960×540pt
const slides = ['01-cover.html', '02-agenda.html', '03-content.html'];
for (const file of slides) {
await html2pptx(`./slides/${file}`, pres);
}
await pres.writeFile({ fileName: 'deck.pptx' });
})();
```
### Step 3:打开检查
- PowerPoint/Keynote 打开导出 PPTX
- 双击任意文字应能直接编辑(如果是图片说明第 1 条违反了)
- 验证 overflow:每页应该在 body 范围内,没有被截
---
## 这条路径 vs 其他选项(什么时候选什么)
| 需求 | 选什么 |
|------|------|
| 同事会改 PPTX 里的文字 / 发给非技术人员继续编辑 | **本文路径**(editable,需从头按 4 条约束写 HTML) |
| 只是演讲用 / 发存档,不再改 | `export_deck_pdf.mjs`(多文件)或 `export_deck_stage_pdf.mjs`(单文件 deck-stage),出矢量 PDF |
| 视觉自由度优先(动画、web component、CSS 渐变、复杂 SVG),接受不可编辑 | `export_deck_pptx.mjs --mode image`(图片铺底 PPTX) |
**绝不要在视觉自由写好的 HTML 上硬跑 html2pptx**——实测视觉驱动的 HTML pass 率 < 30%,剩下的逐页改造比重写还慢。
---
## 为什么 4 条约束不是 Bug 而是物理约束
这 4 条不是 `html2pptx.js` 作者偷懒——它们是 **PowerPoint 文件格式(OOXML)本身的约束**投射到 HTML 上的结果:
- PPTX 里文字必须在 text frame(`<a:txBody>`),对应段落级 HTML 元素
- PPTX 的 shape 和 text frame 是两个对象,无法在同一 element 上同时画背景和写文字
- PPTX 的 shape fill 对 gradient 支持有限(仅某些 preset gradients,不支持 CSS 任意角度渐变)
- PPTX 的 picture 对象必须引用真实图片文件,不是 CSS 属性
理解这点后,**不要期待工具变聪明** —— 是 HTML 写法要适配 PPTX 格式,不是反过来。
+206 -27
View File
@@ -1,8 +1,68 @@
# Slide Decks:HTML幻灯片制作规范 # Slide Decks:HTML幻灯片制作规范
做幻灯片是设计工作的高频场景。这份文档说明怎么做好HTML幻灯片。 做幻灯片是设计工作的高频场景。这份文档说明怎么做好HTML幻灯片——从架构选型、单页设计,到 PDF/PPTX 导出的完整路径。
**和huashu-slides skill的区别**:huashu-slides focus on「AI演示文稿全流程」(含PPT导出);本文档focus on「用HTML本身作为呈现媒介」的设计方法论。两者可以配合使用——用这里的方法做高质量的HTML deck,再用huashu-slides导出成PPTX。 **本 skill 的能力覆盖**:
- HTML 播放/PDF 导出 → 本文档 + `scripts/export_deck_pdf.mjs` / `scripts/export_deck_stage_pdf.mjs`
- 可编辑 PPTX 导出 → `references/editable-pptx.md` + `scripts/html2pptx.js` + `scripts/export_deck_pptx.mjs --mode editable`
- 图片铺底 PPTX(不可编辑但视觉保真)→ `scripts/export_deck_pptx.mjs --mode image`
---
## 🛑 开工前先确认交付格式(最硬的 checkpoint)
**这个决策比「单文件还是多文件」更先。** 2026-04-20 期权私董会项目实测:**不在动手前确认交付格式 = 2-3 小时返工。**
### 决策树
```
│ 问:最终要交付什么?
├── 只要浏览器全屏演讲 / 本地 HTML → 视觉最自由,随便做
├── 要 PDF(打印 / 发群 / 存档) → 视觉最自由,任何架构都能导出
└── 要可编辑 PPTX(同事会改文字) → 🛑 从第一行 HTML 开始就按 `references/editable-pptx.md` 的 4 条硬约束写
```
### 为什么「要 PPTX 就得从头走 Path A」
PPTX 可编辑的前提是 `html2pptx.js` 能把 DOM 逐元素翻译为 PowerPoint 对象。它需要 **4 条硬约束**:
1. body 固定 720pt × 405pt(不是 1920×1080px)
2. 所有文字包在 `<p>`/`<h1>`-`<h6>` 里(禁止 div 直接放文字,禁止用 `<span>` 承载主文字)
3. `<p>`/`<h*>` 自身不能有 background/border/shadow(放外层 div)
4. `<div>` 不能用 `background-image`(用 `<img>` 标签)
5. 不用 CSS gradient、不用 web component、不用复杂 SVG 装饰
**本 skill 默认的 HTML 视觉自由度高**——大量 span、嵌套 flex、复杂 SVG、web component(如 `<deck-stage>`)、CSS 渐变——**几乎没有一条能天然过 html2pptx 的约束**(实测视觉驱动的 HTML 直接上 html2pptx,pass 率 < 30%)。
### 两条真实路径的代价对比(2026-04-20 真实踩坑)
| 路径 | 做法 | 结果 | 代价 |
|------|------|------|------|
| ❌ **先自由写 HTML,事后补救 PPTX** | 单文件 deck-stage + 大量 SVG/span 装饰 | 要可编辑 PPTX 只剩两条路:<br>A. 手写 pptxgenjs 几百行 hardcode 坐标<br>B. 重写 17 页 HTML 成 Path A 格式 | 2-3 小时返工,且手写版**维护成本永续**(HTML 改一个字,PPTX 要再人肉同步) |
| ✅ **从第一步按 Path A 约束写** | 每页独立 HTML + 4 条硬约束 + 720×405pt | 一条命令导出 100% 可编辑 PPTX,同时也能浏览器全屏演讲(Path A HTML 就是浏览器可播放的标准 HTML) | 写 HTML 时多花 5 分钟想「文字怎么包进 `<p>`」,零返工 |
### 开工话术(抄走即用)
> 动手之前先确认交付格式:
> - **浏览器演讲 / PDF** → 我按设计自由度最大的方式做(可以用动画、web component、复杂 SVG、CSS 渐变)
> - **需要可编辑 PPTX**(同事会改文字) → 我必须从一开始就按 `references/editable-pptx.md` 的 4 条硬约束写 HTML。视觉能力会少一些(无渐变、无 web component、无复杂 SVG),但导出就是一条命令的事
>
> 你选哪条?
### 混合交付怎么办
用户说「我要 HTML 演讲 **和** 可编辑 PPTX」——**这不是混合**,是 PPTX 需求覆盖 HTML 需求。按 Path A 写出来的 HTML 本身就能浏览器全屏演讲(加个 `deck_index.html` 拼接器就行)。**没有额外代价。**
用户说「我要 PPTX **和** 动画 / web component」——**这是真矛盾**。告诉用户:要可编辑 PPTX 就得牺牲这些视觉能力。让他做取舍,不要偷偷做手写 pptxgenjs 方案(会变成永续维护债)。
### 事后才知道要 PPTX 怎么办(紧急补救)
极个别情况:HTML 已经写好了才发现要 PPTX。此时两个选项都不完美:
1. **图片铺底 PPTX**(`scripts/export_deck_pptx.mjs` image 模式)——视觉 100% 保真但文字不可编辑。适合「演讲用 PPT 播放、不改内容」场景
2. **手写 pptxgenjs 重建**(为每页手写 addText/addShape + 图形 PNG 嵌入)——文字可编辑,但位置、字体、对齐都要手调,维护成本高。**只有用户明确接受「HTML 源要改就得重新手调 PPTX」才走这条**
永远优先把选择告诉用户,让他决定。**永远不要第一反应就开始手写 pptxgenjs**——那是最后的兜底手段。
--- ---
@@ -142,49 +202,113 @@ Playwright 截图也是直接 `goto(file://.../slides/05-personas.html)`,不
### 基本用法 ### 基本用法
1. 从 `assets/deck_stage.js` 读取内容,嵌入 HTML 的 `<script>`(或 `<script src="deck_stage.js">`) 1. 从 `assets/deck_stage.js` 读取内容,嵌入 HTML 的 `<script>`(或 `<script src="deck_stage.js">`)
2. 在 body 里用 `<deck-stage>` 包 slide: 2. 在 body 里用 `<deck-stage>` 包 slide
3. 🛑 **script 标签必须放在 `</deck-stage>` 之后**(见下方硬约束)
```html ```html
<deck-stage> <body>
<deck-stage>
<section> <section>
<h1>Slide 1</h1> <h1>Slide 1</h1>
</section> </section>
<section> <section>
<h1>Slide 2</h1> <h1>Slide 2</h1>
</section> </section>
</deck-stage> </deck-stage>
<!-- ✅ 正确:script 在 deck-stage 之后 -->
<script src="deck_stage.js"></script>
</body>
``` ```
### 🛑 Script 位置硬约束(2026-04-20 真实踩坑)
**不能把 `<script src="deck_stage.js">` 放在 `<head>` 里。** 即使它在 `<head>` 里能定义 `customElements`,parser 在解析到 `<deck-stage>` 开始标签时就会触发 `connectedCallback`——此时子 `<section>` 还没被 parse,`_collectSlides()` 拿到空数组,counter 显示 `1 / 0`,所有页同时叠加渲染。
**三条合规写法**(任选其一):
```html
<!-- ✅ 最推荐:script 在 </deck-stage> 之后 -->
</deck-stage>
<script src="deck_stage.js"></script>
<!-- ✅ 也可:script 在 head 但加 defer -->
<head><script src="deck_stage.js" defer></script></head>
<!-- ✅ 也可:module 脚本天然 defer -->
<head><script src="deck_stage.js" type="module"></script></head>
```
`deck_stage.js` 本身已内置 `DOMContentLoaded` 延迟收集防御,即使 script 放 head 也不会彻底炸掉——但 `defer` 或放 body 底部仍然是更干净的做法,避免依赖防御分支。
### ⚠️ 单文件架构的 CSS 陷阱(务必阅读) ### ⚠️ 单文件架构的 CSS 陷阱(务必阅读)
单文件架构最常见的坑——**`display` 属性被单页样式偷走**。 单文件架构最常见的坑——**`display` 属性被单页样式偷走**。
如果你给某张 slide 的样式写了 `display: flex/grid`: 常见错误姿势 1(直接写 display: flex 到 section):
```css ```css
.emotion-slide { display: grid; } /* 特异性: 10 */ /* ❌ 外部 CSS 特异性 2,覆盖了 shadow DOM 的 ::slotted(section){display:none}(也是 2)*/
``` deck-stage > section {
display: flex; /* 所有页会同时叠加渲染! */
它会压过 deck 级的「隐藏非 active 页」规则: flex-direction: column;
padding: 80px;
```css ...
deck-stage > section { display: none; } /* 特异性: 2 */
```
结果所有 slide 同时渲染叠加。
**修复模式**(写 deck 时必做):
```css
/* ✅ 用 :not(.active) + !important 锁死「非激活即隐藏」 */
deck-stage > section:not(.active) {
display: none !important;
} }
``` ```
特异性+权重双保险,单页 layout class 的 `display: grid/flex` 就不会影响可见性。 常见错误姿势 2(section 有特异性更高的 class):
另一个替代方案:**把单页的 flex/grid 写到内部 wrapper div 上**,section 本身只管 `display: block/none`。 ```css
.emotion-slide { display: grid; } /* 特异性: 10,更糟 */
```
两种都会让 **所有 slide 同时叠加渲染**——counter 可能显示 `1 / 10` 假装正常,但视觉上第一页盖着第二页盖着第三页。
### ✅ Starter CSS(开工直接 copy,不踩坑)
**section 自身**只管「可见/不可见」;**layout(flex/grid 等)写到 `.active` 上**:
```css
/* section 只定义非 display 的通用样式 */
deck-stage > section {
background: var(--paper);
padding: 80px 120px;
overflow: hidden;
position: relative;
/* ⚠️ 不要在这里写 display! */
}
/* 锁死「非激活即隐藏」——特异性+权重双保险 */
deck-stage > section:not(.active) {
display: none !important;
}
/* 激活页才写需要的 display + layout */
deck-stage > section.active {
display: flex;
flex-direction: column;
justify-content: center;
}
/* 打印模式:所有页都要显示,覆盖 :not(.active) */
@media print {
deck-stage > section { display: flex !important; }
deck-stage > section:not(.active) { display: flex !important; }
}
```
替代方案:**把单页的 flex/grid 写到内部 wrapper `<div>` 上**,section 本身永远只是 `display: block/none` 的切换器。这是最干净的做法:
```html
<deck-stage>
<section>
<div class="slide-content flex-layout">...</div>
</section>
</deck-stage>
```
### 自定义尺寸 ### 自定义尺寸
@@ -316,7 +440,7 @@ Deck 需要 **intentional variety**:
HTML 优先是第一公民。但用户经常需要 PPTX/PDF 交付。提供两个通用脚本,**任何多文件 deck 都能用**,位于 `scripts/` 下: HTML 优先是第一公民。但用户经常需要 PPTX/PDF 交付。提供两个通用脚本,**任何多文件 deck 都能用**,位于 `scripts/` 下:
### `export_deck_pdf.mjs` — 导出矢量 PDF(推荐) ### `export_deck_pdf.mjs` — 导出矢量 PDF(多文件架构)
```bash ```bash
node scripts/export_deck_pdf.mjs --slides <slides-dir> --out deck.pdf node scripts/export_deck_pdf.mjs --slides <slides-dir> --out deck.pdf
@@ -332,6 +456,61 @@ node scripts/export_deck_pdf.mjs --slides <slides-dir> --out deck.pdf
**限制**:PDF 不能再编辑文字——要改回到 HTML 改。 **限制**:PDF 不能再编辑文字——要改回到 HTML 改。
### `export_deck_stage_pdf.mjs` — 单文件 deck-stage 架构专用 ⚠️
**什么时候用**:deck 是单 HTML 文件 + `<deck-stage>` web component 包裹 N 个 `<section>`(即路径 B 架构)。此时 `export_deck_pdf.mjs` 那套「每个 HTML 一次 `page.pdf()`」走不通,需要走这个专用脚本。
```bash
node scripts/export_deck_stage_pdf.mjs --html deck.html --out deck.pdf
```
**为什么不能复用 export_deck_pdf.mjs**(2026-04-20 真实踩坑记录):
1. **Shadow DOM 赢过 `!important`**:deck-stage 的 shadow CSS 里有 `::slotted(section) { display: none }`(只 active 的那张 `display: block`)。即使在 light DOM 用 `@media print { deck-stage > section { display: block !important } }` 也压不住——`page.pdf()` 触发 print 媒体后 Chromium 最终渲染只有 active 那一张,结果**整个 PDF 只有 1 页**(当前 active slide 的重复)。
2. **循环 goto 每页还是只出 1 页**:直觉解法「对每个 `#slide-N` navigate 一次再 `page.pdf({pageRanges:'1'})`」也失败——因为 print CSS 在 shadow DOM 之外也有 `deck-stage > section { display: block }` 规则被 override 后,最终渲染永远是 section 列表的第一个(不是你 navigate 到的那一页)。结果 17 次循环得到 17 张 P01 封面。
3. **absolute 子元素跑到下一页**:即使成功让所有 section 渲染出来,section 本身若 `position: static`,其 absolute 定位的 `cover-footer`/`slide-footer` 会相对 initial containing block 定位——当 section 被 print 强制为 1080px 高度,absolute footer 可能被推到下一页(表现为 PDF 比 section 数量多 1 页,多出来的那页只含 footer 孤儿)。
**修复策略**(脚本已实现):
```js
// 打开 HTML 后,用 page.evaluate 把 section 从 deck-stage slot 中提出来,
// 直接挂到 body 下一个普通 div 里,并内联 style 确保 position:relative + 固定尺寸
await page.evaluate(() => {
const stage = document.querySelector('deck-stage');
const sections = Array.from(stage.querySelectorAll(':scope > section'));
document.head.appendChild(Object.assign(document.createElement('style'), {
textContent: `
@page { size: 1920px 1080px; margin: 0; }
html, body { margin: 0 !important; padding: 0 !important; }
deck-stage { display: none !important; }
`,
}));
const container = document.createElement('div');
sections.forEach(s => {
s.style.cssText = 'width:1920px!important;height:1080px!important;display:block!important;position:relative!important;overflow:hidden!important;page-break-after:always!important;break-after:page!important;background:#F7F4EF;margin:0!important;padding:0!important;';
container.appendChild(s);
});
// 最后一页禁分页,避免尾部空白页
sections[sections.length - 1].style.pageBreakAfter = 'auto';
sections[sections.length - 1].style.breakAfter = 'auto';
document.body.appendChild(container);
});
await page.pdf({ width: '1920px', height: '1080px', printBackground: true, preferCSSPageSize: true });
```
**为什么这能 work**:
- 把 section 从 shadow DOM slot 拔到 light DOM 的普通 div——彻底绕过 `::slotted(section) { display: none }` 规则
- 内联 `position: relative` 让 absolute 子元素相对 section 定位,不会溢出
- `page-break-after: always` 让浏览器 print 时每 section 独立一页
- `:last-child` 不分页避免尾部空白页
**用 `mdls -name kMDItemNumberOfPages` 验证时注意**:macOS 的 Spotlight metadata 有缓存,PDF 重写后要跑 `mdimport file.pdf` 强制刷新,否则显示旧的页数。用 `pdfinfo` 或 `pdftoppm` 数文件数才是真数。
---
### `export_deck_pptx.mjs` — 导出 PPTX(两种模式) ### `export_deck_pptx.mjs` — 导出 PPTX(两种模式)
```bash ```bash
@@ -393,7 +572,7 @@ node scripts/export_deck_pptx.mjs --slides <dir> --out deck.pptx --mode editable
## 导出为可编辑 PPTX 的深度路径(仅长期项目) ## 导出为可编辑 PPTX 的深度路径(仅长期项目)
如果你的 deck 会长期维护、反复修改、团队协作——建议**一开始就按 html2pptx 约束写 HTML**,让 `--mode editable` 稳定通过。详见 `huashu-slides` skill 的 `references/prompt-templates.md` 第 2 节(4 条硬性约束)。 如果你的 deck 会长期维护、反复修改、团队协作——建议**一开始就按 html2pptx 约束写 HTML**,让 `--mode editable` 稳定通过。详见 `references/editable-pptx.md`(4 条硬约束 + HTML 模板 + 常见错误速查)。
--- ---
+8 -2
View File
@@ -21,7 +21,7 @@ open -a "Google Chrome" "/path/to/your/design.html"
HTML文件里最常见的问题是JS报错导致白屏。用Playwright跑一遍: HTML文件里最常见的问题是JS报错导致白屏。用Playwright跑一遍:
```bash ```bash
python "$SKILL/scripts/verify.py" path/to/design.html python ~/.claude/skills/claude-design/scripts/verify.py path/to/design.html
``` ```
这个脚本会: 这个脚本会:
@@ -120,7 +120,13 @@ open screenshot.png
### 上传图床分享链接 ### 上传图床分享链接
如果需要给远程协作者看(比如 Slack/飞书/微信),让用户用自己的图床工具或 MCP 上传。任何返回永久链接的图床都行(ImgBB / Cloudflare R2 / 七牛 / 自建等),粘贴到任何地方。 如果需要给远程协作者看(比如 Slack/飞书/微信),让用户用自己的图床工具或 MCP 上传:
```bash
python ~/Documents/写作/tools/upload_image.py screenshot.png
```
返回ImgBB的永久链接,可以粘贴到任何地方。
## 验证出错时 ## 验证出错时
+14 -2
View File
@@ -86,13 +86,22 @@ bash add-music.sh product-promo.mp4 --mood=ad --out=promo-final.mp4
从已有 MP4 生成 60fps 版本和 GIF。 从已有 MP4 生成 60fps 版本和 GIF。
```bash ```bash
bash /path/to/claude-design/scripts/convert-formats.sh <input.mp4> [gif_width] bash /path/to/claude-design/scripts/convert-formats.sh <input.mp4> [gif_width] [--minterpolate]
``` ```
输出(与输入同目录): 输出(与输入同目录):
- `<name>-60fps.mp4` — minterpolate 插帧到 60fps - `<name>-60fps.mp4` — 默认用 `fps=60` 帧复制(兼容性广);加 `--minterpolate` 启用高质量插帧
- `<name>.gif` — palette 优化的 GIF(默认 960 宽,可改) - `<name>.gif` — palette 优化的 GIF(默认 960 宽,可改)
**60fps 模式选择**:
| 模式 | 命令 | 兼容性 | 使用场景 |
|---|---|---|---|
| 帧复制(默认)| `convert-formats.sh in.mp4` | QuickTime/Safari/Chrome/VLC 全通 | 通用交付、上传平台、社交媒体 |
| minterpolate 插帧 | `convert-formats.sh in.mp4 --minterpolate` | macOS QuickTime/Safari 可能拒打 | B站等需要真插帧的展示场景,**交付前必须本地测**目标播放器 |
为什么默认改成帧复制?minterpolate 输出的 H.264 elementary stream 有 known compat bug——之前默认 minterpolate 时多次踩到「macOS QuickTime 打不开」的问题。详见 `animation-pitfalls.md` §14。
`gif_width` 参数: `gif_width` 参数:
- 960(默认)—— 社交平台通用 - 960(默认)—— 社交平台通用
- 1280 —— 更清晰但文件更大 - 1280 —— 更清晰但文件更大
@@ -164,6 +173,9 @@ GIF 只能 256 色。一次 pass 的 GIF 会把全动画色彩压到 256 色通
- [ ] 动画最后一帧是稳定的收尾状态(不是半截) - [ ] 动画最后一帧是稳定的收尾状态(不是半截)
- [ ] 字体/图片/emoji 全部正常渲染(参考 `animation-pitfalls.md`) - [ ] 字体/图片/emoji 全部正常渲染(参考 `animation-pitfalls.md`)
- [ ] Duration 参数与 HTML 里的实际动画时长匹配 - [ ] Duration 参数与 HTML 里的实际动画时长匹配
- [ ] HTML 中 Stage 检测 `window.__recording` 强制 loop=false(手写 Stage 必查;用 `assets/animations.jsx` 自带)
- [ ] 结尾 Sprite 的 `fadeOut={0}`(视频末帧不淡出)
- [ ] 含「Created by Huashu-Design」水印(仅动画场景必加;第三方品牌作品加「非官方出品 · 」前缀。详见 SKILL.md §「Skill 推广水印」)
## 交付时附带的说明 ## 交付时附带的说明
+45 -13
View File
@@ -1,18 +1,27 @@
#!/bin/bash #!/bin/bash
# Convert MP4 animations to 60fps MP4 (via minterpolate) and optimized GIF. # Convert MP4 animations to 60fps MP4 and optimized GIF.
# #
# Usage: # Usage:
# ./convert-formats.sh input.mp4 [gif_width] # ./convert-formats.sh input.mp4 [gif_width] [--minterpolate]
# #
# Produces next to the input: # Produces next to the input:
# <name>-60fps.mp4 (1920x1080, 60fps, motion-interpolated) # <name>-60fps.mp4 (1920x1080, 60fps, frame-duplicated by default)
# <name>.gif (scaled width, 15fps, palette-optimized) # <name>.gif (scaled width, 15fps, palette-optimized)
# #
# minterpolate flags: # Flags:
# mi_mode=mci motion compensation interpolation # --minterpolate Enable motion-compensated interpolation (high quality
# mc_mode=aobmc adaptive overlapped block motion comp # but elementary stream has known QuickTime/Safari
# me_mode=bidir bidirectional motion estimation # compat issues — only use if your player handles it).
# vsbmc=1 variable-size block motion comp #
# Default 60fps mode: simple `fps=60` filter (frame duplication). Wide
# compatibility, plays in QuickTime / Safari / Chrome / VLC. The 60fps
# label is for upload-platform optics; perceived smoothness is identical
# to the source 25fps for most CSS-driven motion.
#
# When to enable --minterpolate: heavy translate/scale motion where you
# want true 60fps interpolation. WARN: macOS QuickTime sometimes refuses
# to open minterpolate output. Test before delivering.
#
# GIF uses two-pass palette: # GIF uses two-pass palette:
# pass 1: palettegen with stats_mode=diff (per-video optimal palette) # pass 1: palettegen with stats_mode=diff (per-video optimal palette)
# pass 2: paletteuse with bayer dither + rectangle diff # pass 2: paletteuse with bayer dither + rectangle diff
@@ -20,8 +29,21 @@
set -e set -e
INPUT="${1:?Usage: $0 input.mp4 [gif_width]}" INPUT=""
GIF_WIDTH="${2:-960}" GIF_WIDTH="960"
USE_MINTERPOLATE=0
for arg in "$@"; do
case "$arg" in
--minterpolate) USE_MINTERPOLATE=1 ;;
--*) echo "Unknown flag: $arg" >&2; exit 1 ;;
*)
if [ -z "$INPUT" ]; then INPUT="$arg"
else GIF_WIDTH="$arg"
fi
;;
esac
done
[ -z "$INPUT" ] && { echo "Usage: $0 input.mp4 [gif_width] [--minterpolate]" >&2; exit 1; }
DIR=$(dirname "$INPUT") DIR=$(dirname "$INPUT")
BASE=$(basename "$INPUT" .mp4) BASE=$(basename "$INPUT" .mp4)
@@ -29,10 +51,20 @@ OUT60="$DIR/$BASE-60fps.mp4"
OUTGIF="$DIR/$BASE.gif" OUTGIF="$DIR/$BASE.gif"
PAL="$DIR/.palette-$BASE.png" PAL="$DIR/.palette-$BASE.png"
echo "▸ 60fps interpolate: $OUT60" if [ "$USE_MINTERPOLATE" = "1" ]; then
echo "▸ 60fps interpolate (minterpolate, high quality): $OUT60"
VFILTER="minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1"
else
echo "▸ 60fps frame-duplicate (compat mode): $OUT60"
VFILTER="fps=60"
fi
# -profile:v high -level 4.0 → broad H.264 compatibility (QuickTime, Safari, mobile)
# -movflags +faststart → moov atom upfront, streamable / instant-play
ffmpeg -y -loglevel error -i "$INPUT" \ ffmpeg -y -loglevel error -i "$INPUT" \
-vf "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" \ -vf "$VFILTER" \
-c:v libx264 -pix_fmt yuv420p -crf 18 -preset medium -movflags +faststart \ -c:v libx264 -pix_fmt yuv420p -profile:v high -level 4.0 \
-crf 18 -preset medium -movflags +faststart \
"$OUT60" "$OUT60"
MP4_SIZE=$(du -h "$OUT60" | cut -f1) MP4_SIZE=$(du -h "$OUT60" | cut -f1)
echo " ✓ $MP4_SIZE" echo " ✓ $MP4_SIZE"
+102 -32
View File
@@ -1,26 +1,39 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* export_deck_pptx.mjs — 把多文件 slide deck 导出为 PPTX(图片铺底) * export_deck_pptx.mjs — 把多文件 slide deck 导出为 PPTX
*
* 两种模式:
* --mode image 图片铺底,视觉 100% 保真,⚠️ 文字不可编辑
* --mode editable 文本框原生,文字可编辑,要求 HTML 符合 4 条硬约束(见 references/editable-pptx.md)
* *
* 用法: * 用法:
* node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--width 1920] [--height 1080] * # 图片模式(默认)
* node export_deck_pptx.mjs --slides <dir> --out <file.pptx>
* # 可编辑模式
* node export_deck_pptx.mjs --slides <dir> --out <file.pptx> --mode editable
* *
* 特点: * --mode image 特点:
* - 每张 slide 截图成 PNG,满铺一张 PPTX 页面 * - 每张 slide 截图成 PNG,满铺一张 PPTX 页面
* - 视觉 100% 保真(因为就是图片) * - 视觉 100% 保真(因为就是图片)
* - ⚠️ 文字不可编辑(文字变成了图片) * - 文字不可编辑
* - HTML 随便写,不挑格式
* *
* 如果用户需要「可编辑文字」的 PPTX: * --mode editable 特点:
* ❌ 不要在 claude-design 的 HTML 上硬上 html2pptx——claude-design 的 HTML 视觉自由度高, * - 调用 scripts/html2pptx.js 把 HTML DOM 逐元素翻译成 PowerPoint 对象
* 很少能满足 html2pptx 的严格约束(p 标签语法、无 ::before、无 span margin 等)。 * - 文字是真文本框,PPT 里直接双击能编辑
* 实测 32 页里能 pass 的不到 30%,剩下的要逐页改造 + 逐页修字体溢出——工时失控。 * - ⚠️ HTML 必须符合 4 条硬约束(见 references/editable-pptx.md):
* ✅ 正确做法:切换到 **huashu-slides** skill 的 Path A,按它的 HTML 格式**从头重构** * 1. 文字包在 <p>/<h1>-<h6> 里(div 不能直接放文字)
* 每一页。huashu-slides 的 HTML 从一开始就符合 html2pptx 约束,可 100% 导出可编辑 PPTX。 * 2. 不用 CSS 渐变
* 3. <p>/<h*> 不能有 background/border/shadow(放外层 div)
* 4. div 不能 background-image(用 <img>)
* - body 尺寸默认 960pt × 540pt(LAYOUT_WIDE,13.333″ × 7.5″)
* - 视觉驱动的 HTML 几乎无法 pass —— 必须从写 HTML 的第一行就按约束写
* *
* 依赖:playwright pptxgenjs * 依赖:
* npm install playwright pptxgenjs * --mode image: npm install playwright pptxgenjs
* --mode editable: npm install playwright pptxgenjs sharp
* *
* 会按文件名排序(01-xxx.html → 02-xxx.html → ...) * 按文件名排序(01-xxx.html → 02-xxx.html → ...)。
*/ */
import { chromium } from 'playwright'; import { chromium } from 'playwright';
@@ -28,36 +41,32 @@ import pptxgen from 'pptxgenjs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function parseArgs() { function parseArgs() {
const args = { width: 1920, height: 1080 }; const args = { width: 1920, height: 1080, mode: 'image' };
const a = process.argv.slice(2); const a = process.argv.slice(2);
for (let i = 0; i < a.length; i += 2) { for (let i = 0; i < a.length; i += 2) {
const k = a[i].replace(/^--/, ''); const k = a[i].replace(/^--/, '');
args[k] = a[i + 1]; args[k] = a[i + 1];
} }
if (!args.slides || !args.out) { if (!args.slides || !args.out) {
console.error('用法: node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--width 1920] [--height 1080]'); console.error('用法: node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--mode image|editable] [--width 1920] [--height 1080]');
process.exit(1); process.exit(1);
} }
args.width = parseInt(args.width); args.width = parseInt(args.width);
args.height = parseInt(args.height); args.height = parseInt(args.height);
if (!['image', 'editable'].includes(args.mode)) {
console.error(`未知 --mode: ${args.mode}。支持: image, editable`);
process.exit(1);
}
return args; return args;
} }
async function main() { async function exportImage({ slidesDir, outFile, files, width, height }) {
const { slides, out, width, height } = parseArgs(); console.log(`[image mode] Rendering ${files.length} slides as PNG...`);
const slidesDir = path.resolve(slides);
const outFile = path.resolve(out);
const files = (await fs.readdir(slidesDir))
.filter(f => f.endsWith('.html'))
.sort();
if (!files.length) {
console.error(`No .html files found in ${slidesDir}`);
process.exit(1);
}
console.log(`Found ${files.length} slides, rendering to PNG...`);
const browser = await chromium.launch(); const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width, height } }); const ctx = await browser.newContext({ viewport: { width, height } });
@@ -76,7 +85,6 @@ async function main() {
} }
await browser.close(); await browser.close();
// Build PPTX
const pres = new pptxgen(); const pres = new pptxgen();
pres.defineLayout({ name: 'DECK', width: width / 96, height: height / 96 }); pres.defineLayout({ name: 'DECK', width: width / 96, height: height / 96 });
pres.layout = 'DECK'; pres.layout = 'DECK';
@@ -86,12 +94,74 @@ async function main() {
} }
await pres.writeFile({ fileName: outFile }); await pres.writeFile({ fileName: outFile });
// cleanup
for (const p of pngs) await fs.unlink(p).catch(() => {}); for (const p of pngs) await fs.unlink(p).catch(() => {});
await fs.rmdir(tmpDir).catch(() => {}); await fs.rmdir(tmpDir).catch(() => {});
console.log(`\n✓ Wrote ${outFile} (${files.length} slides, image mode)`); console.log(`\n✓ Wrote ${outFile} (${files.length} slides, image mode, 文字不可编辑)`);
console.log(` 要可编辑?改走 huashu-slides 的 Path A 从头重构 HTML。`); }
async function exportEditable({ slidesDir, outFile, files }) {
console.log(`[editable mode] Converting ${files.length} slides via html2pptx...`);
// 动态 require html2pptx.js(CommonJS 模块)
const { createRequire } = await import('module');
const require = createRequire(import.meta.url);
let html2pptx;
try {
html2pptx = require(path.join(__dirname, 'html2pptx.js'));
} catch (e) {
console.error(`✗ 加载 html2pptx.js 失败:${e.message}`);
console.error(` 该模块依赖 sharp —— 请跑 npm install sharp 后重试。`);
process.exit(1);
}
const pres = new pptxgen();
pres.layout = 'LAYOUT_WIDE'; // 13.333 × 7.5 inch,对应 HTML body 960 × 540 pt
const errors = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
const fullPath = path.join(slidesDir, f);
try {
await html2pptx(fullPath, pres);
console.log(` [${i + 1}/${files.length}] ${f} ✓`);
} catch (e) {
console.error(` [${i + 1}/${files.length}] ${f} ✗ ${e.message}`);
errors.push({ file: f, error: e.message });
}
}
if (errors.length) {
console.error(`\n⚠️ ${errors.length} 张 slide 转换失败。常见原因:HTML 不符合 4 条硬约束。`);
console.error(` 详见 references/editable-pptx.md 的「常见错误速查」。`);
if (errors.length === files.length) {
console.error(`✗ 全部失败,不生成 PPTX。`);
process.exit(1);
}
}
await pres.writeFile({ fileName: outFile });
console.log(`\n✓ Wrote ${outFile} (${files.length - errors.length}/${files.length} slides, editable mode, 文字可在 PPT 中直接编辑)`);
}
async function main() {
const { slides, out, width, height, mode } = parseArgs();
const slidesDir = path.resolve(slides);
const outFile = path.resolve(out);
const files = (await fs.readdir(slidesDir))
.filter(f => f.endsWith('.html'))
.sort();
if (!files.length) {
console.error(`No .html files found in ${slidesDir}`);
process.exit(1);
}
if (mode === 'image') {
await exportImage({ slidesDir, outFile, files, width, height });
} else {
await exportEditable({ slidesDir, outFile, files });
}
} }
main().catch(e => { console.error(e); process.exit(1); }); main().catch(e => { console.error(e); process.exit(1); });
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* export_deck_stage_pdf.mjs — 单文件 <deck-stage> 架构专用 PDF 导出
*
* 用法:
* node export_deck_stage_pdf.mjs --html <deck.html> --out <file.pdf> [--width 1920] [--height 1080]
*
* 什么时候用这个脚本?
* - 你的 deck 是**单 HTML 文件**,所有 slide 是 `<section>`,外层用 `<deck-stage>` 包裹
* - 此时 `export_deck_pdf.mjs`(多文件专用)用不上
*
* 为什么不能直接 `page.pdf()`(2026-04-20 踩坑记录):
* 1. deck-stage 的 shadow CSS `::slotted(section) { display: none }` 让只有 active slide 可见
* 2. print 媒体下外层 `!important` 压不住 shadow DOM 规则
* 3. 结果:PDF 永远只有 1 页(active 那张)
*
* 解决方案:
* 打开 HTML 后,用 page.evaluate 把所有 section 从 deck-stage slot 拔出来,
* 挂到 body 下一个普通 div,内联 style 强制 position:relative + 固定尺寸,
* 每个 section 加 page-break-after: always,最后一个改 auto 避免尾部空白页。
*
* 依赖:playwright
* npm install playwright
*
* 输出特点:
* - 文字保留矢量(可复制、可搜索)
* - 视觉 1:1 保真
* - 字体必须能被 Chromium 加载(本地字体或 Google Fonts)
*/
import { chromium } from 'playwright';
import fs from 'fs/promises';
import path from 'path';
function parseArgs() {
const args = { width: 1920, height: 1080 };
const a = process.argv.slice(2);
for (let i = 0; i < a.length; i += 2) {
const k = a[i].replace(/^--/, '');
args[k] = a[i + 1];
}
if (!args.html || !args.out) {
console.error('用法: node export_deck_stage_pdf.mjs --html <deck.html> --out <file.pdf> [--width 1920] [--height 1080]');
process.exit(1);
}
args.width = parseInt(args.width);
args.height = parseInt(args.height);
return args;
}
async function main() {
const { html, out, width, height } = parseArgs();
const htmlAbs = path.resolve(html);
const outFile = path.resolve(out);
await fs.access(htmlAbs).catch(() => {
console.error(`HTML file not found: ${htmlAbs}`);
process.exit(1);
});
console.log(`Rendering ${path.basename(htmlAbs)} → ${path.basename(outFile)}`);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width, height } });
const page = await ctx.newPage();
await page.goto('file://' + htmlAbs, { waitUntil: 'networkidle' });
await page.waitForTimeout(2500); // 等 Google Fonts + deck-stage init
// 核心修复:把 section 从 shadow DOM slot 拔出来摊平
const sectionCount = await page.evaluate(({ W, H }) => {
const stage = document.querySelector('deck-stage');
if (!stage) throw new Error('<deck-stage> not found — 这个脚本只适用于单文件 deck-stage 架构');
const sections = Array.from(stage.querySelectorAll(':scope > section'));
if (!sections.length) throw new Error('No <section> found inside <deck-stage>');
// 注入打印样式
const style = document.createElement('style');
style.textContent = `
@page { size: ${W}px ${H}px; margin: 0; }
html, body { margin: 0 !important; padding: 0 !important; background: #fff; }
deck-stage { display: none !important; }
`;
document.head.appendChild(style);
// 摊平到 body 下
const container = document.createElement('div');
container.id = 'print-container';
sections.forEach(s => {
// 内联 style 拿到最高优先级;确保 position:relative 让 absolute 子元素正确约束
s.style.cssText = `
width: ${W}px !important;
height: ${H}px !important;
display: block !important;
position: relative !important;
overflow: hidden !important;
page-break-after: always !important;
break-after: page !important;
margin: 0 !important;
padding: 0 !important;
`;
container.appendChild(s);
});
// 最后一页不分页,避免尾部空白页
const last = sections[sections.length - 1];
last.style.pageBreakAfter = 'auto';
last.style.breakAfter = 'auto';
document.body.appendChild(container);
return sections.length;
}, { W: width, H: height });
await page.waitForTimeout(800);
await page.pdf({
path: outFile,
width: `${width}px`,
height: `${height}px`,
printBackground: true,
preferCSSPageSize: true,
});
await browser.close();
const stat = await fs.stat(outFile);
const kb = (stat.size / 1024).toFixed(0);
console.log(`\n✓ Wrote ${outFile} (${kb} KB, ${sectionCount} pages, vector)`);
console.log(` 验证页数:mdimport "${outFile}" && pdfinfo "${outFile}" | grep Pages`);
}
main().catch(e => { console.error(e); process.exit(1); });
+44 -9
View File
@@ -103,7 +103,10 @@ console.log(` output: ${MP4_OUT}`);
viewport: { width: WIDTH, height: HEIGHT }, viewport: { width: WIDTH, height: HEIGHT },
}); });
const warmupPage = await warmupCtx.newPage(); const warmupPage = await warmupCtx.newPage();
await warmupPage.goto(url, { waitUntil: 'networkidle' }); // 'load' not 'networkidle' — unpkg/Google Fonts can keep connections alive
// past our 30s budget even after all critical resources are in. __ready
// flag + FONT_WAIT handle animation-readiness properly.
await warmupPage.goto(url, { waitUntil: 'load', timeout: 60000 });
await warmupPage.waitForTimeout(FONT_WAIT * 1000); await warmupPage.waitForTimeout(FONT_WAIT * 1000);
await warmupCtx.close(); await warmupCtx.close();
@@ -118,6 +121,12 @@ console.log(` output: ${MP4_OUT}`);
}, },
}); });
// Tell the page it's being recorded — animations.jsx Stage reads this
// and forces loop=false so the export ends on the final frame instead of
// capturing the start of the next cycle. Hand-written Stage components
// should also honor this signal (see animation-pitfalls.md §13).
await recordCtx.addInitScript(() => { window.__recording = true; });
// Inject CSS + JS heuristic to hide "chrome" elements. // Inject CSS + JS heuristic to hide "chrome" elements.
// Two layers: // Two layers:
// A. CSS selectors for common class-name conventions (cheap) // A. CSS selectors for common class-name conventions (cheap)
@@ -183,7 +192,7 @@ console.log(` output: ${MP4_OUT}`);
// mount + fonts.ready). That elapsed time = exact trim offset. // mount + fonts.ready). That elapsed time = exact trim offset.
const T0 = Date.now(); const T0 = Date.now();
const page = await recordCtx.newPage(); const page = await recordCtx.newPage();
await page.goto(url, { waitUntil: 'networkidle' }); await page.goto(url, { waitUntil: 'load', timeout: 60000 });
// Wait for animation ready signal. Stage component (animations.jsx) sets // Wait for animation ready signal. Stage component (animations.jsx) sets
// window.__ready = true on its first rAF after mount + fonts.ready. // window.__ready = true on its first rAF after mount + fonts.ready.
@@ -195,14 +204,38 @@ console.log(` output: ${MP4_OUT}`);
).then(() => true).catch(() => false); ).then(() => true).catch(() => false);
if (hasReady) { if (hasReady) {
// 第二道防线:主动把动画 time 归零——对付 HTML 不严格遵守 starter tick 模板
// 的情况(例如 lastTick 用 performance.now() 导致字体加载时间被算进首帧 dt)
// 详见 references/animation-pitfalls.md §12
const seekCorrected = await page.evaluate(() => {
if (typeof window.__seek === 'function') {
window.__seek(0);
return true;
}
return false;
});
if (seekCorrected) {
// 等两个 rAF 让 seek 生效并渲染出 t=0 的画面
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
}
animationStartSec = (Date.now() - T0) / 1000; animationStartSec = (Date.now() - T0) / 1000;
console.log(`▸ Ready at ${animationStartSec.toFixed(2)}s (from window.__ready)`); console.log(`▸ Ready at ${animationStartSec.toFixed(2)}s (from window.__ready${seekCorrected ? ' + __seek(0) correction' : ''})`);
} else { } else {
await page.waitForTimeout(FONT_WAIT * 1000); await page.waitForTimeout(FONT_WAIT * 1000);
animationStartSec = (Date.now() - T0) / 1000; animationStartSec = (Date.now() - T0) / 1000;
console.log(`▸ No window.__ready signal; using fallback wait (${animationStartSec.toFixed(2)}s)`); // Fallback offset is unreliable: animation may have started in raf loop
console.log(` tip: in animations.jsx-based HTML this is automatic;`); // already, so trim could land mid-cycle. Add 0.5s safety margin (see
console.log(` otherwise set window.__ready=true after your first paint.`); // animation-pitfalls.md §13). Loud warning so user knows to fix the HTML.
console.log('');
console.log(` ⚠️ WARNING: window.__ready signal not detected within ${READY_TIMEOUT}s`);
console.log(` Recording will use fallback trim of ${animationStartSec.toFixed(2)}s + 0.5s safety margin.`);
console.log(` This is UNRELIABLE — your video may start mid-animation or skip frames.`);
console.log('');
console.log(` FIX: in your HTML's animation tick (or rAF first frame), add:`);
console.log(` window.__ready = true;`);
console.log(` animations.jsx-based HTML does this automatically. If you wrote your`);
console.log(` own Stage, see references/animation-pitfalls.md §12 for the pattern.`);
console.log('');
} }
// Now let the animation play out its full duration // Now let the animation play out its full duration
@@ -222,11 +255,13 @@ console.log(` output: ${MP4_OUT}`);
// Resolve final trim offset: // Resolve final trim offset:
// - manual --trim=X → use X (explicit user override) // - manual --trim=X → use X (explicit user override)
// - otherwise → use animationStartSec (measured), with a tiny // - hasReady → animationStartSec + 0.05s (Babel-commit nudge)
// +0.05s nudge to clear the first Babel-commit frame // - fallback (no __ready) → animationStartSec + 0.5s safety margin (raf
// loop may have started running already; without
// this we'd capture mid-cycle frames)
const resolvedTrim = TRIM_OVERRIDE !== null const resolvedTrim = TRIM_OVERRIDE !== null
? parseFloat(TRIM_OVERRIDE) ? parseFloat(TRIM_OVERRIDE)
: animationStartSec + 0.05; : animationStartSec + (hasReady ? 0.05 : 0.5);
console.log(`▸ ffmpeg: trim=${resolvedTrim.toFixed(2)}s${TRIM_OVERRIDE !== null ? ' (manual)' : ' (auto)'}, encode H.264…`); console.log(`▸ ffmpeg: trim=${resolvedTrim.toFixed(2)}s${TRIM_OVERRIDE !== null ? ' (manual)' : ' (auto)'}, encode H.264…`);
const ffmpeg = spawnSync('ffmpeg', [ const ffmpeg = spawnSync('ffmpeg', [