From b127f8baa3ffa65b8948e4d9d20fc619c5094dac Mon Sep 17 00:00:00 2001 From: alchain Date: Mon, 20 Apr 2026 18:19:48 +0800 Subject: [PATCH] launch huashu-design v1.0: 9 demos + marketing-first README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- .gitignore | 49 +- README.md | 329 +++++--- SKILL.md | 181 ++++- assets/animations.jsx | 13 +- assets/deck_stage.js | 25 +- assets/personal-asset-index.example.json | 2 +- demos/c1-ios-prototype.html | 721 +++++++++++++++++ demos/c2-slides-pptx.html | 978 +++++++++++++++++++++++ demos/c3-motion-design.html | 556 +++++++++++++ demos/c4-tweaks.html | 762 ++++++++++++++++++ demos/c5-infographic.html | 767 ++++++++++++++++++ demos/c6-expert-review.html | 652 +++++++++++++++ demos/w1-brand-protocol.html | 787 ++++++++++++++++++ demos/w2-junior-designer.html | 856 ++++++++++++++++++++ demos/w3-fallback-advisor.html | 747 +++++++++++++++++ references/animation-pitfalls.md | 171 +++- references/editable-pptx.md | 243 ++++++ references/slide-decks.md | 245 +++++- references/verification.md | 10 +- references/video-export.md | 16 +- scripts/convert-formats.sh | 58 +- scripts/export_deck_pptx.mjs | 134 +++- scripts/export_deck_stage_pdf.mjs | 130 +++ scripts/render-video.js | 55 +- 24 files changed, 8206 insertions(+), 281 deletions(-) create mode 100644 demos/c1-ios-prototype.html create mode 100644 demos/c2-slides-pptx.html create mode 100644 demos/c3-motion-design.html create mode 100644 demos/c4-tweaks.html create mode 100644 demos/c5-infographic.html create mode 100644 demos/c6-expert-review.html create mode 100644 demos/w1-brand-protocol.html create mode 100644 demos/w2-junior-designer.html create mode 100644 demos/w3-fallback-advisor.html create mode 100644 references/editable-pptx.md create mode 100644 scripts/export_deck_stage_pdf.mjs diff --git a/.gitignore b/.gitignore index a964d1b..1db711b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,22 @@ -# OS +# macOS .DS_Store -Thumbs.db +**/.DS_Store -# Editor -.vscode/ -.idea/ -*.swp -*~ +# Video render temp (render-video.js 产物) +.video-tmp-*/ +**/.video-tmp-*/ -# Node -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 +# Personal asset index(个人真实数据,只保留 .example.json 模板) assets/personal-asset-index.json -# Logs -*.log +# Node / editor / OS +node_modules/ +*.swp +.idea/ +.vscode/ +Thumbs.db + +# Verification artifacts(截图、临时测试脚本) +demos/_frames_*.png +demos/_verify.js +demos/_verify.mjs diff --git a/README.md b/README.md index 4f5503e..6501df0 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,254 @@ -# 花叔Design · Huashu-Design +
-![花叔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,再迭代细节。最后还能请一位「严苛评审」打分。 +
-适用于任何支持 Skill 规范的 AI agent:Claude Code、Codex、Cursor、Trae、OpenClaw、Hermes Agent 等。 +**Claude Design 把设计师塞进了浏览器。Huashu Design 让图形工具这一层消失。** + +
+ +PPT、App 原型、时间轴动画、信息图、设计变体——在 Claude Code 里说一句话,agent 自己搭 design spec、自己抓品牌色、自己生成 HTML、自己跑 Playwright 验证。
+没有画布,没有图层,没有插件。只剩下一个能直接复制到 Keynote、贴进飞书、发进 issue 的交付物。 + +``` +npx skills add alchaincyf/huashu-design +``` + +[看效果](#demo-画廊) · [安装](#装上就能用) · [能做什么](#能做什么) · [核心机制](#核心机制) · [和 Claude Design 的关系](#和-claude-design-的关系) + +
+ +[![Star History Chart](https://api.star-history.com/svg?repos=alchaincyf/huashu-design&type=Date)](https://star-history.com/#alchaincyf/huashu-design&Date) + +
--- -## 它能做什么 - -一条 skill 同时覆盖四件事: - -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 清单。 +

+ Fallback 设计顾问 · 从 20 种设计哲学推荐 3 个差异化方向 +

--- -## 附带的工程工具链 - -- **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 -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」「评审这个设计」「做个好看的页面(我没想法)」 - -触发词完整清单见 `SKILL.md` frontmatter 的 description 字段。 +没有按钮、没有面板、没有 Figma 插件。跨 agent 通用 —— Claude Code / Cursor / Trae / Hermes / OpenClaw 任一都能装。 --- -## 使用前需要自己配置的东西 +## 能做什么 -大部分能力开箱即用,下面几项在需要时再弄: +| 能力 | 交付物 | 典型耗时 | +|------|--------|----------| +| 交互原型(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 | -| 能力 | 需要 | -|------|------| -| 视频导出 | `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 目录并填真实信息 | +**跨 agent 通用**:Claude Code、Cursor、Trae、Hermes、OpenClaw 任一都能装。 --- -## 目录结构 +## Demo 画廊 + +### 设计方向顾问 + +模糊需求时的 fallback:从 5 流派 × 20 种设计哲学里挑 3 个差异化方向,并行生成 3 个 Demo 让你选。 + +

+ +### iOS App 原型 + +iPhone 15 Pro 精确机身(灵动岛 / 状态栏 / Home Indicator)· 状态驱动多屏切换 · 真图从 Wikimedia/Met/Unsplash 取 · Playwright 自动点击测试。 + +

+ +### Motion Design 引擎 + +Stage + Sprite 时间片段模型 · `useTime` / `useSprite` / `interpolate` / `Easing` 四 API 覆盖所有动画需求 · 一条命令导出 MP4 / GIF / 60fps 插帧 / 带 BGM 的成片。 + +

+ +### HTML Slides → 可编辑 PPTX + +HTML deck 浏览器演讲 · `html2pptx.js` 读 DOM 的 computedStyle 逐元素翻译成 PowerPoint 对象 · 导出的是**真文本框**,不是图片铺底。 + +

+ +### Tweaks · 实时变体切换 + +配色 / 字型 / 信息密度等参数化 · 侧边面板切换 · 纯前端 + `localStorage` 持久化 · 刷新不丢。 + +

+ +### 信息图 / 数据可视化 + +杂志级排版 · CSS Grid 精准分栏 · `text-wrap: pretty` 排印细节 · 真数据驱动 · 可导 PDF 矢量 / PNG 300dpi / SVG。 + +

+ +### 5 维度专家评审 + +哲学一致性 · 视觉层级 · 细节执行 · 功能性 · 创新性 各 0–10 分 · 雷达图可视化 · 输出 Keep / Fix / Quick Wins 清单。 + +

+ +### Junior Designer 工作流 + +不闷头做大招:先写 assumptions + placeholders + reasoning,尽早 show 给你,再迭代。理解错了早改比晚改便宜 100 倍。 + +

+ +### 品牌资产协议 5 步硬流程 + +涉及具体品牌时强制执行:问 → 搜 → 下载(三条兜底)→ grep 色值 → 写 `brand-spec.md`。 + +

+ +--- + +## 核心机制 + +### 品牌资产协议 + +skill 里最硬的一段规则。涉及具体品牌(Stripe、Linear、Anthropic、自家公司等)时强制执行 5 步: + +| 步骤 | 动作 | 目的 | +|------|------|------| +| 1 · 问 | 用户有 brand guidelines 吗? | 尊重已有资源 | +| 2 · 搜官方品牌页 | `.com/brand` · `brand..com` · `.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/ -├── SKILL.md # 主干文件:哲学 + 工作流 + 路由表 -├── references/ # 深度参考:按需加载 -│ ├── workflow.md # 开工问什么问题 -│ ├── content-guidelines.md # 反 AI slop 完整清单 -│ ├── react-setup.md # React + Babel 最佳实践 -│ ├── slide-decks.md # 幻灯片架构 -│ ├── animations.md # 动画引擎用法 -│ ├── animation-pitfalls.md # 动画踩坑 + T0 黑屏修复 -│ ├── tweaks-system.md # 实时调参 UI -│ ├── design-context.md # 没有 design system 怎么办 -│ ├── design-styles.md # 20 种设计哲学 -│ ├── scene-templates.md # 按输出类型的场景模板 -│ ├── critique-guide.md # 评审打分标准 -│ ├── verification.md # 产出验证流程 -│ └── video-export.md # 视频导出命令链 -├── scripts/ # 可执行脚本 -│ ├── render-video.js # HTML → 25fps MP4 -│ ├── convert-formats.sh # 25fps MP4 → 60fps MP4 + GIF -│ ├── add-music.sh # BGM 叠加 + 自动 fade -│ ├── export_deck_pdf.mjs # HTML → PDF -│ ├── export_deck_pptx.mjs # HTML → PPTX -│ └── verify.py # Playwright 截图 + 控制台检查 -└── assets/ # 起手组件 + BGM + 示例 - ├── deck_stage.js # 幻灯片外壳(单文件架构) - ├── deck_index.html # 幻灯片拼接器(多文件架构) - ├── animations.jsx # Stage + Sprite 时间轴引擎 - ├── design_canvas.jsx # 变体画布 - ├── ios_frame.jsx # iPhone 设备边框 - ├── android_frame.jsx # Android 设备边框 - ├── macos_window.jsx # macOS 窗口 chrome - ├── browser_window.jsx # 浏览器 chrome - ├── bgm-{tech,ad,educational,tutorial,*-alt}.mp3 - ├── personal-asset-index.example.json - └── showcases/ # 24 个预制 showcase(8 场景 × 3 风格) +huashu-design/ +├── SKILL.md # 主文档(给 agent 读) +├── README.md # 本文件(给用户读) +├── assets/ # Starter Components +│ ├── animations.jsx # Stage + Sprite + Easing + interpolate +│ ├── ios_frame.jsx # iPhone 15 Pro bezel +│ ├── android_frame.jsx +│ ├── macos_window.jsx +│ ├── browser_window.jsx +│ ├── deck_stage.js # HTML 幻灯片引擎 +│ ├── deck_index.html # 多文件 deck 拼接器 +│ ├── design_canvas.jsx # 并排变体展示 +│ ├── showcases/ # 24 个预制样例(8 场景 × 3 风格) +│ └── bgm-*.mp3 # 6 首场景化背景音乐 +├── references/ # 按任务深入读的子文档 +│ ├── animation-pitfalls.md +│ ├── design-styles.md # 20 种设计哲学详细库 +│ ├── slide-decks.md +│ ├── editable-pptx.md +│ ├── critique-guide.md +│ ├── video-export.md +│ └── ... +├── scripts/ # 导出工具链 +│ ├── render-video.js # HTML → MP4 +│ ├── convert-formats.sh # MP4 → 60fps + GIF +│ ├── add-music.sh # MP4 + BGM +│ ├── export_deck_pdf.mjs +│ ├── export_deck_pptx.mjs +│ ├── html2pptx.js +│ └── verify.py +└── demos/ # 9 个能力演示(本 README 引用的 GIF 都来自这里) ``` --- -## 设计原则(写进 DNA 的五条) +## 起源 -1. **从 existing context 出发**,不凭空画 hi-fi。没有 design system / brand / Figma 就先找,真没有就明确告知「我会基于通用直觉做」。 -2. **Junior Designer 模式**:先 show 假设,再执行。不要一头扎进去闷头做大招。 -3. **给 variations,不给最终答案**。3+ 个变体,跨视觉 / 交互 / 色彩 / 布局 / 动画维度递进,让用户 mix and match。 -4. **Placeholder > 烂实现**。没图标就留灰色方块 + 文字标签,不画烂 SVG;没数据不编造假数据。 -5. **反 AI slop**:激进渐变、满屏 emoji、圆角卡片 + 左 accent border、SVG 画人画物、Inter / Roboto 填所有文字 —— 每一条都是默认避开项(用户可按品牌 override)。 +Anthropic 发布 Claude Design 那天我玩到凌晨四点。几天之后发现自己再也没点开过它,不是它不好——它是这个赛道目前最成熟的产品——是我宁愿让 agent 在终端里帮我干活,也不愿意打开任何图形界面。 -完整哲学和工作流见 `SKILL.md`。 +于是让 agent 拆解 Claude Design 本身(包括社区流传的系统提示词、品牌资产协议、组件机制),蒸馏成结构化 spec,再写成 skill 装进自己的 Claude Code。 ---- - -## BGM 版权说明 - -`assets/bgm-*.mp3` 为原创 / 授权音乐,仅限配合本 skill 生成的演示 / 宣传视频使用。商业投放前请确认你的素材来源许可;若替换成自己的 BGM,只需保留相同文件名或改脚本参数。 +感谢 Anthropic 把 Claude Design 的提示词写得清晰。这种基于其他产品灵感的二次创作,是开源文化在 AI 时代的新形态。 --- ## License -MIT。自由 fork、魔改、开分支。PR 欢迎,尤其欢迎: - -- 新的设计哲学流派(在 `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 可用 +MIT. Skill 和 demo 都开源,随便用。issue 里欢迎延伸讨论。 diff --git a/SKILL.md b/SKILL.md index 5dbbf8d..3d267f4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -29,6 +29,87 @@ description: 花叔Design(Huashu-Design)——用HTML做高保真原型、 **如果还是没有,或者用户需求表达很模糊**(如"做个好看的页面"、"帮我设计"、"不知道要什么风格"、"做个XX"没有具体参考),**不要凭通用直觉硬做**——进入 **设计方向顾问模式**,从 20 种设计哲学里给 3 个差异化方向让用户选。完整流程见下方「设计方向顾问(Fallback 模式)」大节。 +#### 1.a 品牌资产协议(涉及具体品牌时强制执行) + +> **这是 v1 最核心的约束,也是稳定性的生命线。** Agent 是否走通这个协议,直接决定输出质量是 65 分还是 90 分。不要跳过任何一步。 + +**触发条件**:任务涉及具体品牌——用户提了产品名/公司名/明确客户(Stripe、Linear、Anthropic、Notion、Lovart、自家公司等),不论用户是否主动提供了品牌资料。 + +**5 步硬流程**(每一步都有 fallback,但绝不可以静默跳过): + +##### Step 1 · 问 +一句话问清楚:「这个品牌有 brand guidelines / 设计规范 / logo 文件吗?品牌色和字体清单?有的话直接给我;没有的话我去搜。」 + +##### Step 2 · 搜官方品牌页 +- 典型路径:`.com/brand` / `.com/press` / `brand..com` / `.github.io/brand` +- `WebFetch` 搜不到时用 `WebSearch`:「`` 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/logo.svg https://.com/logo.svg + ``` +2. **官网 HTML 全文**(80% 场景必用):WebFetch 返回 403 时用 curl 带 UA 兜底,logo 的 inline SVG 和色值 token 直接藏在 HTML 里: + ```bash + curl -A "Mozilla/5.0" -L https://.com -o assets/-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/*.{svg,html,css} | sort | uniq -c | sort -rn | head -20 + ``` +- **警惕示范品牌污染**:产品截图里常有用户 demo 的品牌色(如某工具截图里演示喜茶红),那不是该工具的色。**同时出现两种强色时必须区分**。 +- **品牌多切面的客观事实**:同一品牌的官网营销色(`.com` 暖底 accent 色)和产品 UI 色(产品内深底 accent 色)经常不同,比如 Lovart 官网是暖米+橙+蓝,产品 UI 是 Charcoal + Lime 青柠。**两套都是真的**——根据交付场景选合适的切面(做营销物料用官网色,做产品内/App 延伸用 UI 色)。 + +##### Step 5 · 固化为 `brand-spec.md` 文件(不可省略) + +> **这一步是全协议的制胜点**。前 4 步抽完色,如果只留在内存里下一页就忘。必须写一份 `brand-spec.md` 到项目根目录,让所有 CSS 变量引用这份文件。 + +```markdown +# · Brand Spec +> 从以下资产抽取:<列出资产来源和提取日期> +> 资产完整度:<完整 / 部分 / 推断> + +## 色板 +- Primary: #XXXXXX <来源标注> +- Background: #XXXXXX +- Ink: #XXXXXX +- 禁用色: <品牌明确不用的色系> + +## 字型 +- Display: +- Body: + +## 签名细节 +- <哪些细节是「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模式:先展示假设,再执行 你是manager的junior designer。**不要一头扎进去闷头做大招**。HTML文件的开头先写下你的assumptions + reasoning + placeholders,**尽早show给用户**。然后: @@ -59,18 +140,47 @@ description: 花叔Design(Huashu-Design)——用HTML做高保真原型、 ### 6. 反AI slop(重要,必读) -AI设计最容易掉进去的陷阱。完整清单见 `references/content-guidelines.md`,速查: +#### 6.1 什么是 AI slop?为什么要反? -- ❌ 激进渐变背景(尤其紫色渐变在白底上) -- ❌ Emoji 作为图标(除非品牌本身用) -- ❌ 圆角卡片+左边彩色border accent -- ❌ SVG画imagery(画人/物/场景)——留placeholder让用户提供真材料 -- ❌ Inter/Roboto/Arial/Fraunces/system fonts -- ❌ 赛博霓虹 / 深蓝色底(#0D1117)= 审美禁区 -- ✅ `text-wrap: pretty` + CSS Grid + 高级CSS是好朋友 -- ✅ oklch定义色彩(而不是从头发明新颜色) -- ✅ 配图优先 AI 生成(Gemini / Flash / Lovart),HTML截图仅在精确数据表格时用 -- ✅ 文案中使用「」引号而非""引号 +**AI slop = AI 训练语料里最常见的"视觉最大公约数"**。 +紫渐变、emoji 图标、圆角卡片+左 border accent、SVG 画人脸——这些东西之所以是 slop,不是因为它们本身丑,而是因为**它们是 AI 默认模式下的产物,不携带任何品牌信息**。 + +**规避 slop 的逻辑链**: +1. 用户请你做设计,是要**他的品牌被认出来** +2. AI 默认产出 = 训练语料的平均 = 所有品牌混合 = **没有任何品牌被认出来** +3. 所以 AI 默认产出 = 帮用户把品牌稀释成"又一个 AI 做的页面" +4. 反 slop 不是审美洁癖,是**替用户保护品牌识别度** + +这也是为什么 §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 模式) @@ -308,10 +418,20 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe 1. **理解需求**:新任务或模糊任务必须问clarifying questions,详见 `references/workflow.md`。一次focused一轮问题通常够,小修小补跳过。 🛑 **检查点1:问题清单一次性发给用户,等用户批量答完再往下走**。不要边问边做。 + 🛑 **幻灯片/PPT 任务额外必问「最终交付格式」**(浏览器演讲 / PDF / 可编辑 PPTX)——**要可编辑 PPTX 就必须从第一行 HTML 开始按 `references/editable-pptx.md` 的 4 条硬约束写**,事后补救会导致 2-3 小时返工。详见 `references/slide-decks.md` 开头「开工前先确认交付格式」一节。 ⚡ **如果用户需求严重模糊(没参考、没明确风格、"做个好看的"类)→ 走「设计方向顾问(Fallback 模式)」大节,完成 Phase 1-4 选定方向后,再回到这里 Step 2**。 -2. **探索资源**:读design system的完整定义、linked files、上传的截图/代码。如果用户没给context,先走设计方向顾问 Fallback,再按 `references/design-context.md` 的品位锚点兜底。 -3. **规划系统**:vocalize你要用的设计系统(色彩/字型/layout节奏/component pattern)。 - 🛑 **检查点2:口头说出来等用户点头,再动手写代码**。方向错了晚改比早改贵100倍。 +2. **探索资源 + 抽 spec**:读 design system、linked files、上传的截图/代码。**涉及具体品牌时必走 §1.a 五步协议**(问→搜→下载→grep→写 `brand-spec.md`)。如果用户没给 context 且挖不出资产,先走设计方向顾问 Fallback,再按 `references/design-context.md` 的品位锚点兜底。 +3. **先答四问,再规划系统**:**这一步的前半段比所有 CSS 规则更决定输出**。 + + 📐 **位置四问**(每个页面/屏幕/镜头开工前必答): + - **叙事角色**:hero / 过渡 / 数据 / 引语 / 结尾?(一页 deck 里每页都不一样) + - **观众距离**:10cm 手机 / 1m 笔记本 / 10m 投屏?(决定字号和信息密度) + - **视觉温度**:安静 / 兴奋 / 冷静 / 权威 / 温柔 / 悲伤?(决定配色和节奏) + - **容量估算**:用纸笔画 3 个 5 秒 thumbnail 算一下内容塞得下吗?(防溢出 / 防挤压) + + 四问答完再 vocalize 设计系统(色彩/字型/layout 节奏/component pattern)——**系统要服务于答案,不是先选系统再塞内容**。 + + 🛑 **检查点2:四问答案 + 系统口头说出来等用户点头,再动手写代码**。方向错了晚改比早改贵 100 倍。 4. **构建文件夹结构**:`项目名/` 下放主HTML、需要的assets拷贝(不要bulk copy >20个文件)。 5. **Junior pass**:HTML里写assumptions+placeholders+reasoning comments。 🛑 **检查点3:尽早show给用户(哪怕只是灰色方块+标签),等反馈再写组件**。 @@ -384,9 +504,11 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe | 文件 | 何时用 | 提供 | |------|--------|------| | `deck_index.html` | **做幻灯片(默认,多文件架构)** | iframe拼接 + 键盘导航 + scale + 计数器 + 打印合并,每页独立HTML免CSS串扰 | -| `deck_stage.js` | 做幻灯片(单文件架构,≤10页) | web component:auto-scale + 键盘导航 + slide counter + localStorage + speaker notes | -| `scripts/export_deck_pdf.mjs` | **HTML→PDF 导出**(矢量、高保真、文字可搜) | Playwright `page.pdf()` + pdf-lib 合并。依赖 `playwright pdf-lib` | -| `scripts/export_deck_pptx.mjs` | **HTML→PPTX 导出**(图片铺底,不可编辑) | Playwright 截图 + pptxgenjs addImage。视觉 100% 保真。**要可编辑 PPTX → 切到支持该格式的专用 skill/工具按其严格格式重构 HTML**。依赖 `playwright pptxgenjs` | +| `deck_stage.js` | 做幻灯片(单文件架构,≤10页) | web component:auto-scale + 键盘导航 + slide counter + localStorage + speaker notes ⚠️ **script 必须放在 `` 之后,section 的 `display: flex` 必须写到 `.active` 上**,详见 `references/slide-decks.md` 的两个硬约束 | +| `scripts/export_deck_pdf.mjs` | **HTML→PDF 导出(多文件架构)** · 每页独立 HTML 文件,playwright 逐个 `page.pdf()` → pdf-lib 合并。文字保留矢量可搜。依赖 `playwright pdf-lib` | +| `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的网格布局 | | `animations.jsx` | 任何动画HTML | Stage + Sprite + useTime + Easing + interpolate | | `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` | | React+Babel项目setup | `references/react-setup.md` | | 做幻灯片 | `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` | | 做Tweaks实时调参 | `references/tweaks-system.md` | | 没有design context怎么办 | `references/design-context.md`(薄 fallback) 或 `references/design-styles.md`(厚 fallback:20 种设计哲学详细库) | @@ -436,6 +559,27 @@ Skill 路径引用均采用**相对本 skill 根目录**的形式(`references/ - HTML放项目目录,不要散落到`~/Downloads` - 最终产出用浏览器打开检查或用Playwright截图 +## Skill 推广水印(仅动画产出) + +**只在动画产出**(HTML 动画 → MP4 / GIF)默认带「**Created by Huashu-Design**」水印,辅助 skill 传播。**幻灯片 / 信息图 / 原型 / 网页等其他场景不加**——加了反而干扰用户实际使用。 + +- **必带场景**:HTML 动画 → 导出 MP4 / GIF(用户会拿去公众号、X、B站传播,水印能跟着流通) +- **不带场景**:幻灯片(用户自己讲)、信息图(嵌文章)、App / 网页原型(设计 review)、配图 +- **第三方品牌的非官方致敬动画**:水印前加「非官方出品 · 」前缀,避免被误认为官方物料引发 IP 争议 +- **用户明确说"不要水印"**:尊重,移除 +- **水印模板**: + ```jsx +
+ Created by Huashu-Design + {/* 第三方品牌动画前缀「非官方出品 · 」*/} +
+ ``` + ## 核心提醒 - **Embody专家**:做幻灯片时是幻灯片设计师,做动画时是动画师。不是写Web UI。 @@ -443,3 +587,6 @@ Skill 路径引用均采用**相对本 skill 根目录**的形式(`references/ - **Variations不给答案**:3+个变体,让用户选。 - **Placeholder优于烂实现**:诚实留白,不编造。 - **反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。否则录视频必出问题。 diff --git a/assets/animations.jsx b/assets/animations.jsx index 6d2ff27..76134f2 100644 --- a/assets/animations.jsx +++ b/assets/animations.jsx @@ -162,6 +162,12 @@ const startTimeRef = useRef(performance.now()); 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(() => { function updateScale() { const vw = window.innerWidth; @@ -195,7 +201,10 @@ setTime(prev => { const next = prev + delta; 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; }); @@ -218,7 +227,7 @@ cancelled = true; cancelAnimationFrame(rafRef.current); }; - }, [playing, duration, loop]); + }, [playing, duration, effectiveLoop]); const handleScrub = useCallback((e) => { const rect = e.currentTarget.getBoundingClientRect(); diff --git a/assets/deck_stage.js b/assets/deck_stage.js index 6bad7b4..916790d 100644 --- a/assets/deck_stage.js +++ b/assets/deck_stage.js @@ -43,12 +43,27 @@ this._width = parseInt(this.getAttribute('width')) || 1920; this._height = parseInt(this.getAttribute('height')) || 1080; + // Shadow DOM 先渲染(独立于子节点,不受 parser 时机影响) this._render(); - this._collectSlides(); - this._setupEventListeners(); - this._restoreSlide(); - this._updateDisplay(); - this._setupPrintStyles(); + + // 防御:若 script 放在 里(而非 之后), + // parser 此刻可能还没处理完子
,querySelectorAll 会返回空。 + // 延迟到下一个事件循环,确保子节点都已 parse 完毕。 + const init = () => { + this._collectSlides(); + this._setupEventListeners(); + this._restoreSlide(); + this._updateDisplay(); + 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() { diff --git a/assets/personal-asset-index.example.json b/assets/personal-asset-index.example.json index 08f3915..dd4b0d6 100644 --- a/assets/personal-asset-index.example.json +++ b/assets/personal-asset-index.example.json @@ -1,7 +1,7 @@ { "_meta": { "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 分发泄露隐私" }, diff --git a/demos/c1-ios-prototype.html b/demos/c1-ios-prototype.html new file mode 100644 index 0000000..49df325 --- /dev/null +++ b/demos/c1-ios-prototype.html @@ -0,0 +1,721 @@ + + + + +Huashu-Design · iOS App Prototype + + + + + + + + + +
+ + + + + + + + diff --git a/demos/c2-slides-pptx.html b/demos/c2-slides-pptx.html new file mode 100644 index 0000000..3cb82f2 --- /dev/null +++ b/demos/c2-slides-pptx.html @@ -0,0 +1,978 @@ + + + + +Huashu-Design · Slides → PPTX + + + + + + + + + +
+ + + + + + + + diff --git a/demos/c3-motion-design.html b/demos/c3-motion-design.html new file mode 100644 index 0000000..d724aaa --- /dev/null +++ b/demos/c3-motion-design.html @@ -0,0 +1,556 @@ + + + + +Huashu-Design · Motion Design + + + + + + + + + +
+ + + + + + + + diff --git a/demos/c4-tweaks.html b/demos/c4-tweaks.html new file mode 100644 index 0000000..ee9242e --- /dev/null +++ b/demos/c4-tweaks.html @@ -0,0 +1,762 @@ + + + + +Huashu-Design · Tweaks 实时变体 + + + + + + + + + +
+ + + + + + + + diff --git a/demos/c5-infographic.html b/demos/c5-infographic.html new file mode 100644 index 0000000..6ecf5b2 --- /dev/null +++ b/demos/c5-infographic.html @@ -0,0 +1,767 @@ + + + + +Huashu-Design · Infographic Demo + + + + + + + + + +
+ + + + + + + + diff --git a/demos/c6-expert-review.html b/demos/c6-expert-review.html new file mode 100644 index 0000000..d62468e --- /dev/null +++ b/demos/c6-expert-review.html @@ -0,0 +1,652 @@ + + + + +Huashu-Design · Expert Review + + + + + + + + + +
+ + + + + + + + diff --git a/demos/w1-brand-protocol.html b/demos/w1-brand-protocol.html new file mode 100644 index 0000000..ad3f4ae --- /dev/null +++ b/demos/w1-brand-protocol.html @@ -0,0 +1,787 @@ + + + + +Huashu-Design · 品牌资产协议 5 步硬流程 + + + + + + + + + +
+ + + + + + + + diff --git a/demos/w2-junior-designer.html b/demos/w2-junior-designer.html new file mode 100644 index 0000000..c01bee8 --- /dev/null +++ b/demos/w2-junior-designer.html @@ -0,0 +1,856 @@ + + + + +Huashu-Design · Junior Designer 模式 + + + + + + + + + +
+ + + + + + + + diff --git a/demos/w3-fallback-advisor.html b/demos/w3-fallback-advisor.html new file mode 100644 index 0000000..ea01661 --- /dev/null +++ b/demos/w3-fallback-advisor.html @@ -0,0 +1,747 @@ + + + + +Huashu-Design · Fallback 设计顾问 + + + + + + + + + +
+ + + + + + + + diff --git a/references/animation-pitfalls.md b/references/animation-pitfalls.md index a8c3cd6..0ccbd25 100644 --- a/references/animation-pitfalls.md +++ b/references/animation-pitfalls.md @@ -201,35 +201,164 @@ **反例**:底部画 `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 - function tick(now) { - if (last === null) { - last = now; - window.__ready = true; // 必须在这里,不是 useEffect - } - // ... 动画推进 - } + await recordCtx.addInitScript(() => { window.__recording = true; }); ``` - 为什么同步?如果 `__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 里用 `` 外部加载引擎。本机双击打开(`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` 必须**内联**到 `` 标签内,不要用 `src="animations.jsx"` +- **多文件项目(起 HTTP server 演示)** → 可以外部加载,但交付时明确写清 `python3 -m http.server 8000` 命令 +- 判断标准:交付给用户的是"HTML 文件"还是"带 server 的项目目录"?前者用内联 +- Stage 组件 / animations.jsx 经常 200+ 行——贴进 HTML ` + + ``` +### 🛑 Script 位置硬约束(2026-04-20 真实踩坑) + +**不能把 ` + + + + + + +``` + +`deck_stage.js` 本身已内置 `DOMContentLoaded` 延迟收集防御,即使 script 放 head 也不会彻底炸掉——但 `defer` 或放 body 底部仍然是更干净的做法,避免依赖防御分支。 + ### ⚠️ 单文件架构的 CSS 陷阱(务必阅读) 单文件架构最常见的坑——**`display` 属性被单页样式偷走**。 -如果你给某张 slide 的样式写了 `display: flex/grid`: +常见错误姿势 1(直接写 display: flex 到 section): ```css -.emotion-slide { display: grid; } /* 特异性: 10 */ -``` - -它会压过 deck 级的「隐藏非 active 页」规则: - -```css -deck-stage > section { display: none; } /* 特异性: 2 */ -``` - -结果所有 slide 同时渲染叠加。 - -**修复模式**(写 deck 时必做): - -```css -/* ✅ 用 :not(.active) + !important 锁死「非激活即隐藏」 */ -deck-stage > section:not(.active) { - display: none !important; +/* ❌ 外部 CSS 特异性 2,覆盖了 shadow DOM 的 ::slotted(section){display:none}(也是 2)*/ +deck-stage > section { + display: flex; /* 所有页会同时叠加渲染! */ + flex-direction: column; + padding: 80px; + ... } ``` -特异性+权重双保险,单页 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 `
` 上**,section 本身永远只是 `display: block/none` 的切换器。这是最干净的做法: + +```html + +
+
...
+
+
+``` ### 自定义尺寸 @@ -316,7 +440,7 @@ Deck 需要 **intentional variety**: HTML 优先是第一公民。但用户经常需要 PPTX/PDF 交付。提供两个通用脚本,**任何多文件 deck 都能用**,位于 `scripts/` 下: -### `export_deck_pdf.mjs` — 导出矢量 PDF(推荐) +### `export_deck_pdf.mjs` — 导出矢量 PDF(多文件架构) ```bash node scripts/export_deck_pdf.mjs --slides --out deck.pdf @@ -332,6 +456,61 @@ node scripts/export_deck_pdf.mjs --slides --out deck.pdf **限制**:PDF 不能再编辑文字——要改回到 HTML 改。 +### `export_deck_stage_pdf.mjs` — 单文件 deck-stage 架构专用 ⚠️ + +**什么时候用**:deck 是单 HTML 文件 + `` web component 包裹 N 个 `
`(即路径 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(两种模式) ```bash @@ -393,7 +572,7 @@ node scripts/export_deck_pptx.mjs --slides --out deck.pptx --mode editable ## 导出为可编辑 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 模板 + 常见错误速查)。 --- diff --git a/references/verification.md b/references/verification.md index e10257c..949c97b 100644 --- a/references/verification.md +++ b/references/verification.md @@ -21,7 +21,7 @@ open -a "Google Chrome" "/path/to/your/design.html" HTML文件里最常见的问题是JS报错导致白屏。用Playwright跑一遍: ```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的永久链接,可以粘贴到任何地方。 ## 验证出错时 diff --git a/references/video-export.md b/references/video-export.md index 4bc4469..0ca987c 100644 --- a/references/video-export.md +++ b/references/video-export.md @@ -86,13 +86,22 @@ bash add-music.sh product-promo.mp4 --mood=ad --out=promo-final.mp4 从已有 MP4 生成 60fps 版本和 GIF。 ```bash -bash /path/to/claude-design/scripts/convert-formats.sh [gif_width] +bash /path/to/claude-design/scripts/convert-formats.sh [gif_width] [--minterpolate] ``` 输出(与输入同目录): -- `-60fps.mp4` — minterpolate 插帧到 60fps +- `-60fps.mp4` — 默认用 `fps=60` 帧复制(兼容性广);加 `--minterpolate` 启用高质量插帧 - `.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` 参数: - 960(默认)—— 社交平台通用 - 1280 —— 更清晰但文件更大 @@ -164,6 +173,9 @@ GIF 只能 256 色。一次 pass 的 GIF 会把全动画色彩压到 256 色通 - [ ] 动画最后一帧是稳定的收尾状态(不是半截) - [ ] 字体/图片/emoji 全部正常渲染(参考 `animation-pitfalls.md`) - [ ] Duration 参数与 HTML 里的实际动画时长匹配 +- [ ] HTML 中 Stage 检测 `window.__recording` 强制 loop=false(手写 Stage 必查;用 `assets/animations.jsx` 自带) +- [ ] 结尾 Sprite 的 `fadeOut={0}`(视频末帧不淡出) +- [ ] 含「Created by Huashu-Design」水印(仅动画场景必加;第三方品牌作品加「非官方出品 · 」前缀。详见 SKILL.md §「Skill 推广水印」) ## 交付时附带的说明 diff --git a/scripts/convert-formats.sh b/scripts/convert-formats.sh index 5166b68..7a8ed71 100755 --- a/scripts/convert-formats.sh +++ b/scripts/convert-formats.sh @@ -1,18 +1,27 @@ #!/bin/bash -# Convert MP4 animations to 60fps MP4 (via minterpolate) and optimized GIF. +# Convert MP4 animations to 60fps MP4 and optimized GIF. # # Usage: -# ./convert-formats.sh input.mp4 [gif_width] +# ./convert-formats.sh input.mp4 [gif_width] [--minterpolate] # # Produces next to the input: -# -60fps.mp4 (1920x1080, 60fps, motion-interpolated) +# -60fps.mp4 (1920x1080, 60fps, frame-duplicated by default) # .gif (scaled width, 15fps, palette-optimized) # -# minterpolate flags: -# mi_mode=mci motion compensation interpolation -# mc_mode=aobmc adaptive overlapped block motion comp -# me_mode=bidir bidirectional motion estimation -# vsbmc=1 variable-size block motion comp +# Flags: +# --minterpolate Enable motion-compensated interpolation (high quality +# but elementary stream has known QuickTime/Safari +# compat issues — only use if your player handles it). +# +# 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: # pass 1: palettegen with stats_mode=diff (per-video optimal palette) # pass 2: paletteuse with bayer dither + rectangle diff @@ -20,8 +29,21 @@ set -e -INPUT="${1:?Usage: $0 input.mp4 [gif_width]}" -GIF_WIDTH="${2:-960}" +INPUT="" +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") BASE=$(basename "$INPUT" .mp4) @@ -29,10 +51,20 @@ OUT60="$DIR/$BASE-60fps.mp4" OUTGIF="$DIR/$BASE.gif" 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" \ - -vf "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" \ - -c:v libx264 -pix_fmt yuv420p -crf 18 -preset medium -movflags +faststart \ + -vf "$VFILTER" \ + -c:v libx264 -pix_fmt yuv420p -profile:v high -level 4.0 \ + -crf 18 -preset medium -movflags +faststart \ "$OUT60" MP4_SIZE=$(du -h "$OUT60" | cut -f1) echo " ✓ $MP4_SIZE" diff --git a/scripts/export_deck_pptx.mjs b/scripts/export_deck_pptx.mjs index 6973af4..9d9707a 100644 --- a/scripts/export_deck_pptx.mjs +++ b/scripts/export_deck_pptx.mjs @@ -1,26 +1,39 @@ #!/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 --out [--width 1920] [--height 1080] + * # 图片模式(默认) + * node export_deck_pptx.mjs --slides --out + * # 可编辑模式 + * node export_deck_pptx.mjs --slides --out --mode editable * - * 特点: + * --mode image 特点: * - 每张 slide 截图成 PNG,满铺一张 PPTX 页面 * - 视觉 100% 保真(因为就是图片) - * - ⚠️ 文字不可编辑(文字变成了图片) + * - 文字不可编辑 + * - HTML 随便写,不挑格式 * - * 如果用户需要「可编辑文字」的 PPTX: - * ❌ 不要在 claude-design 的 HTML 上硬上 html2pptx——claude-design 的 HTML 视觉自由度高, - * 很少能满足 html2pptx 的严格约束(p 标签语法、无 ::before、无 span margin 等)。 - * 实测 32 页里能 pass 的不到 30%,剩下的要逐页改造 + 逐页修字体溢出——工时失控。 - * ✅ 正确做法:切换到 **huashu-slides** skill 的 Path A,按它的 HTML 格式**从头重构** - * 每一页。huashu-slides 的 HTML 从一开始就符合 html2pptx 约束,可 100% 导出可编辑 PPTX。 + * --mode editable 特点: + * - 调用 scripts/html2pptx.js 把 HTML DOM 逐元素翻译成 PowerPoint 对象 + * - 文字是真文本框,PPT 里直接双击能编辑 + * - ⚠️ HTML 必须符合 4 条硬约束(见 references/editable-pptx.md): + * 1. 文字包在

/

-

里(div 不能直接放文字) + * 2. 不用 CSS 渐变 + * 3.

/ 不能有 background/border/shadow(放外层 div) + * 4. div 不能 background-image(用 ) + * - 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'; @@ -28,36 +41,32 @@ import pptxgen from 'pptxgenjs'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); function parseArgs() { - const args = { width: 1920, height: 1080 }; + const args = { width: 1920, height: 1080, mode: 'image' }; 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.slides || !args.out) { - console.error('用法: node export_deck_pptx.mjs --slides

--out [--width 1920] [--height 1080]'); + console.error('用法: node export_deck_pptx.mjs --slides --out [--mode image|editable] [--width 1920] [--height 1080]'); process.exit(1); } args.width = parseInt(args.width); args.height = parseInt(args.height); + if (!['image', 'editable'].includes(args.mode)) { + console.error(`未知 --mode: ${args.mode}。支持: image, editable`); + process.exit(1); + } return args; } -async function main() { - const { slides, out, width, height } = 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); - } - console.log(`Found ${files.length} slides, rendering to PNG...`); +async function exportImage({ slidesDir, outFile, files, width, height }) { + console.log(`[image mode] Rendering ${files.length} slides as PNG...`); const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width, height } }); @@ -76,7 +85,6 @@ async function main() { } await browser.close(); - // Build PPTX const pres = new pptxgen(); pres.defineLayout({ name: 'DECK', width: width / 96, height: height / 96 }); pres.layout = 'DECK'; @@ -86,12 +94,74 @@ async function main() { } await pres.writeFile({ fileName: outFile }); - // cleanup for (const p of pngs) await fs.unlink(p).catch(() => {}); await fs.rmdir(tmpDir).catch(() => {}); - console.log(`\n✓ Wrote ${outFile} (${files.length} slides, image mode)`); - console.log(` 要可编辑?改走 huashu-slides 的 Path A 从头重构 HTML。`); + console.log(`\n✓ Wrote ${outFile} (${files.length} slides, image mode, 文字不可编辑)`); +} + +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); }); diff --git a/scripts/export_deck_stage_pdf.mjs b/scripts/export_deck_stage_pdf.mjs new file mode 100644 index 0000000..a5526b8 --- /dev/null +++ b/scripts/export_deck_stage_pdf.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * export_deck_stage_pdf.mjs — 单文件 架构专用 PDF 导出 + * + * 用法: + * node export_deck_stage_pdf.mjs --html --out [--width 1920] [--height 1080] + * + * 什么时候用这个脚本? + * - 你的 deck 是**单 HTML 文件**,所有 slide 是 `
`,外层用 `` 包裹 + * - 此时 `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 --out [--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(' not found — 这个脚本只适用于单文件 deck-stage 架构'); + const sections = Array.from(stage.querySelectorAll(':scope > section')); + if (!sections.length) throw new Error('No
found inside '); + + // 注入打印样式 + 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); }); diff --git a/scripts/render-video.js b/scripts/render-video.js index 5115590..36ea381 100755 --- a/scripts/render-video.js +++ b/scripts/render-video.js @@ -103,7 +103,10 @@ console.log(` output: ${MP4_OUT}`); viewport: { width: WIDTH, height: HEIGHT }, }); 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 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. // Two layers: // 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. const T0 = Date.now(); 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 // window.__ready = true on its first rAF after mount + fonts.ready. @@ -195,14 +204,38 @@ console.log(` output: ${MP4_OUT}`); ).then(() => true).catch(() => false); 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; - 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 { await page.waitForTimeout(FONT_WAIT * 1000); animationStartSec = (Date.now() - T0) / 1000; - console.log(`▸ No window.__ready signal; using fallback wait (${animationStartSec.toFixed(2)}s)`); - console.log(` tip: in animations.jsx-based HTML this is automatic;`); - console.log(` otherwise set window.__ready=true after your first paint.`); + // Fallback offset is unreliable: animation may have started in raf loop + // already, so trim could land mid-cycle. Add 0.5s safety margin (see + // 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 @@ -221,12 +254,14 @@ console.log(` output: ${MP4_OUT}`); console.log(`▸ WebM: ${(fs.statSync(webmPath).size / 1024 / 1024).toFixed(1)} MB`); // Resolve final trim offset: - // - manual --trim=X → use X (explicit user override) - // - otherwise → use animationStartSec (measured), with a tiny - // +0.05s nudge to clear the first Babel-commit frame + // - manual --trim=X → use X (explicit user override) + // - hasReady → animationStartSec + 0.05s (Babel-commit nudge) + // - 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 ? 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…`); const ffmpeg = spawnSync('ffmpeg', [