Claude Skill

tryworld-paper

Create branded 试界TryWorld AI-knowledge videos in the fixed "Paper Algorithm" (纸上算法) style with HyperFrames. Input is a Chinese voiceover script and optional images; output is a 16:9 horizontal video with Azure YunxiNeural (真·云希) voiceover, word-synced captions, ink/paper animatio

LLM Mart · 0 points · 9 views 32 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download TryWorld2026-paper-algorithm-skills_tryworld-paper-79d70ca.zip · 56 KB

Install

skills CLI npx skills add https://github.com/TryWorld2026/paper-algorithm/tree/main/skills/tryworld-paper
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tryworld2026-paper-algorithm@llmmart
Git git clone https://github.com/TryWorld2026/paper-algorithm.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tryworld2026/paper-algorithm collection as a plugin from our marketplace. Git is the plain clone.

README

试界-纸上算法 TryWorld-Paper Algorithm

一页会动的算法笔记 —— 把 AI 知识口播稿变成风格统一的横屏视频、横竖封面与平台标题。

风格 配音 画幅 状态

这是什么

tryworld-paper(试界-纸上算法 TryWorld-Paper Algorithm)是试界TryWorld 的视频生产 Skill:输入一篇中文 AI 口播稿(可附图片),自动产出:

  • 横屏主视频(16:9,口播最长约 10 分钟)—— 纸上算法风格,真云希配音,全程烧录字幕;超长稿提炼核心精华压缩
  • 横版封面 + 竖版封面(1920x1440 横 4:3 / 1080x1440 竖 3:4)—— 深墨海报风(与视频浅纸面两套视觉语言),标题 + 信息块,适配 B站/抖音/小红书
  • 3-5 个平台标题 —— 按 B站/抖音/小红书分别适配
  • 右上角全程常驻的"试界原创"防伪印章
  • 每期固定的默认结尾签名(试界TryWorld 标准收尾,口播+画面同步)

设计契约:纸上算法 Paper Algorithm

设计源头:科学手稿 + 中文印刷传统。每一期视频都是一页"会动的算法笔记",纸面是舞台,墨迹是文字,朱红是重点,印章是签名。

色板(4 色锁定,不新增)

色 值 用途
纸面 #F4EFE4 背景主色
墨黑 #1C1916 主文字、线条
朱红 #C0452F 唯一强调色:关键词、数字、印章
墨水蓝 #2E5E8C 次级批注、图表线

字体

  • 主标题:思源宋体(Noto Serif SC)
  • 批注/引用:ZCOOL XiaoWei(默认);霞鹜文楷(LXGW WenKai)如需使用须自带 woff2
  • 数据/坐标:等宽字体(JetBrains Mono)

动效签名(全片统一)

签名 感觉 缓动
墨落纸 主入场 power3.out
笔写入 批注/小元素 sine.inOut
盖章 数字/结论/印章 back.out(1.4)

转场统一为墨染过渡:前场景被墨色浸染溶解,新场景从纸面浮现;章节间留纸面空场。

动态图表

数据/对比/流程用风格统一的动态图表表达:数字滚动盖章、柱状墨迹增长、折线一笔描画、进度环、对比卡片;等宽数字 + 朱红强调 + 墨水蓝网格,GSAP/SVG 实现,避免大段静态文字配解说。

配音质量保障

  • 多音色:默认云希(Azure YunxiNeural,试界品牌音色),--voice 可换预设(xiaoxiao 晓晓 / yunjian 云健 / yunyang 云扬 / xiaoyi 晓伊 / yunxia 云夏 / xiaobei 小贝东北话 / xiaoni 小妮陕西话),也接受完整 edge-tts 音色 id;本地合成,无需 API Key
  • 默认语速 +8%,可按内容微调 +5%~+15%
  • 防卡顿机制:文本自动规整(清除 markdown、合并句中换行),按句切分合成——停顿只允许出现在句号/问号/感叹号处,句子中间永不硬断
  • 句级时间戳(sentences.json)直接驱动字幕同步,不依赖 whisper

工作流

口播稿 → 读稿理解 → 第一性原理优化 → 活人感改稿七遍 → 净化写作标记 → 硬禁项检查(check_prose.py 清零) → 用户确认优化稿 → 句级切分 → 云希配音 → 句级时间轴
   → 章节/场景规划 → HyperFrames 构图 → lint/validate/inspect --strict
   → 渲染前核验 → 渲染 16:9 主视频 → 横竖封面 → 平台标题 → 交付核验(verify_output.py)→ outputs/ 交付

使用方式

在 Codex 会话中给出口播稿(纯文字或 .txt/.md,可附图片):

用 $tryworld-paper 把这篇口播稿做成视频,加上横竖封面和标题。

可选输入:图片目录(AI 概念图 / 截图 / 图表,会自动"纸面化"处理)。

目录结构

tryworld-paper/
├── SKILL.md                    # 主说明:品牌契约、工作流、质量门禁
├── agents/
│   └── openai.yaml             # UI 元数据
├── references/
│   ├── style-system.md         # 视觉契约(色板/字体/动效/封面)
│   ├── workflow.md             # 生产流程与命令
│   └── titles.md               # 平台标题规则
├── scripts/
│   ├── tts_yunxi.py            # 云希配音管线(句级切分 + 拼接 + 时间轴)
│   ├── verify_output.py        # 交付核验硬门禁(音轨/时长/双封面/标题/发布计划)
│   └── check_prose.py          # 活人感硬禁项检查(源自 human-writing v1.1.0,MIT)
└── assets/
    ├── paper-grain.svg         # 纸纹叠加层
    └── seal.svg                # 朱红"试界原创"印章

环境要求

  • Node.js >= 22
  • FFmpeg(含 ffprobe,加入 PATH;winget install Gyan.FFmpeg)
  • Python 3.10+,pip install edge-tts
  • HyperFrames CLI(npx hyperframes)

运行 npx hyperframes doctor 可一键检查环境。

防伪标识

视频与封面统一携带:

  • 右上角全程常驻朱红试界原创印章(保护区,内容不得覆盖)
  • 不添加"试界TryWorld 纸上算法"文字水印,印章是唯一水印

许可

本仓库采用知识共享 署名-相同方式共享 4.0 国际(CC BY-SA 4.0)。其中 scripts/check_prose.py 源自 KKKKhazix/human-writing v1.1.0(禁令上移到修辞动作级,检测器补盲区降误伤),按 MIT License 使用与改造,版权归原作者所有,完整声明见 LICENSE-MIT。

Skill manifest

TryWorld-Paper Algorithm(试界-纸上算法)

按当前主题的品牌制作 AI 知识类视频(默认主题为试界TryWorld 的纸上算法)。所有产出必须遵守本文件与 references/style-system.md 锁定的设计系统,任何一条都不允许为了省事而让步。

流水线契约(不可配置)

以下机制是这套工作流的价值本身,任何主题都必须遵守,动了不交付:

  • 优化稿必须经用户确认才能进入配音/构图/渲染(闸门);口播稿与平台标题必须过 scripts/check_prose.py 硬禁项清零
  • 字幕全程烧录在画面内并与配音同步;印章/标识右上角全程常驻(根层覆盖实现),是唯一水印,禁止文字水印;印章为保护区
  • 画幅:横屏 1920x1080;口播时长最长约 10 分钟(短可到抖音 30 秒),超长稿先提炼压缩
  • 交付物:主视频(烧录字幕)、横版封面(1920x1440,4:3)、竖版封面(1080x1440,3:4)、3-5 个平台标题、字幕/时间轴
  • 交付核验(硬性,最后一步):交付前必须运行 python -X utf8 scripts/verify_output.py --dir <outputs目录>,逐项核验主视频音轨、时长对齐、双封面尺寸、标题与发布计划——任何一项 FAIL 都不交付,修复后重跑直到全部 PASS。禁止跳过或仅凭肉眼判断。
  • 数据必须有来源;场景必须有入场动画与转场(除末场无退场)

品牌主题(从主题文件读取)

平台名、色板、字体、印章、签名语、封面风格、动效签名、默认配音音色,全部定义在主题文件 themes/paper-algorithm.json(默认主题,试界TryWorld 品牌)。出片前先确认要用的主题(默认 paper-algorithm,用户说「用 <主题名> 主题」或要求换品牌时切换),品牌值一律以主题文件为准,不在本文件中写死。

  • 想用自己的品牌做频道:复制主题 JSON 并按 references/theme-guide.md 修改(可改色板/印章/签名/字体/音色等;流水线契约不可配置)
  • 默认主题的视觉细则见 references/style-system.md(纸上算法视觉系统的完整定义)

配音默认音色来自主题文件:出片配音命令带 --theme <主题文件>,脚本读取主题 voice.preset 作为音色(默认主题为云希);--voice <预设名> 可覆盖(xiaoxiao 晓晓 / yunjian 云健 / yunyang 云扬 / xiaoyi 晓伊 / yunxia 云夏 / xiaobei 小贝东北话 / xiaoni 小妮陕西话),交互规则见「音色选择」章节。

启动前必读

外部依赖:渲染依赖 hyperframes 技能($hyperframes,或 npx hyperframes CLI)与 Node.js >= 22;配音依赖 edge-tts(Python 3.10+)。$hyperframes 不在本仓库内,需另行安装;未安装时出片流程无法执行。

  1. 读 themes/paper-algorithm.json(或用户指定的主题文件)——确认本次出片的品牌值。
  2. 读 references/style-system.md —— 视觉契约。编写任何 HTML/CSS 前必须先读,并作为 hyperframes 流程中的 DESIGN.md 使用。
  3. 读 references/workflow.md —— 生产流程与命令,按顺序执行。
  4. 编写构图时遵循 hyperframes skill($hyperframes)的全部规则。
  5. 生成标题前读 references/titles.md。

工作流

  1. 输入并通读理解:口播稿(文本或 .txt/.md 文件)与可选图片。先通读全文,理解主题、受众、核心结论与章节结构;图片缺失时跳过图片场景,不允许降级风格。
  2. 优化并净化脚本:按流量第一性原理优化口播稿(共鸣选题带流量、认可攒赞、槽点引评论、嘴替促转发、价值认同涨粉),再按"活人感改稿七遍"清模型腔与注水(看谁在说 → 检查推进/删注水 → 拆表演性中文 → 听中文节奏 → 清硬禁项 → 核现实 → 查结尾,固定签名保留),随后清除写作标记/结构标签(如"一、开场钩子"、"(插入截图)")——标记不得以原文出现在视频中(不朗读、不上字幕、不显示),按意图转化为实际表达。净化后正文与平台标题必须运行 scripts/check_prose.py 清零硬禁项。规则见 workflow.md。
  3. 交付优化稿并等待确认:净化后正文先通过 $env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py(硬禁项清零),再把优化后的口播稿完整展示给用户审阅,附简明优化说明(改了什么、为什么)、元素落点清单(钩子/干货/槽点/嘴替/价值收尾)与数据来源清单;未获用户确认前,禁止进入配音/构图/渲染。
  4. 分段:按净化后口播时长分段,每章 40-90 秒;不足 1 分钟不分章;口播目标不超过约 10 分钟,超长稿先提炼核心精华压缩再分段;标记章节标题、关键词、数据点、图片提示。
  5. 配音:python "skills/tryworld-paper/scripts/tts_yunxi.py" <净化后的脚本> --out work/audio --theme "skills/tryworld-paper/themes/paper-algorithm.json"(或用户指定的主题文件)生成配音、分段时间与合并音轨。
  6. 时间轴:字幕时间轴来自 work/audio/sentences.json(edge-tts 句级时间戳,已带绝对时间);如需词级时间轴可用 npx hyperframes transcribe(依赖 whisper,可选)。
  7. 场景规划:按章节规划场景与节奏(开场-讲解-数据-小结),数据/对比/流程优先规划为动态图表场景,避免大段静态文字;先声明节奏模式再写 HTML。
  8. 构图:把 assets/ 复制进项目;按 style-system.md 与 hyperframes 规则编写 16:9 主构图。每个场景必须有入场动画与转场;除末场外禁止退场动画。
  9. 检查:npx hyperframes lint、npx hyperframes validate、npx hyperframes inspect --strict 全部通过——错误与警告清零,禁止元素重叠、文字溢出/出画布/截断等排版错误;封面构图同样核验。
  10. 渲染前核验:渲染前必须按 workflow.md 的"渲染前核验清单"逐项对照本文件与 style-system.md 的全部要求,确认无误后才允许渲染;任一项不满足先修改再渲染,避免返工。
  11. 渲染:npx hyperframes render --fps 30 --quality high 输出主视频(先 --quality draft 预览确认,再 high 出片)。
  12. 封面:按 style-system.md 封面系统独立设计 4:3 与 3:4 静态构图(使用主题定义的封面视觉语言,与视频画面保持两套语言;禁止截取主视频画面),渲染后取帧为 PNG。
  13. 标题:按 titles.md 生成 3-5 个候选,标注平台推荐与命中的增长原则(共鸣/认可/槽点/嘴替/价值认同);候选标题同守硬禁词(禁冒号/破折号/翻案句/黑话/模型路标),生成后自检或跑 $env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py 清零。
  14. 交付:主视频(烧录字幕)、横竖封面、标题、字幕文件统一放入 outputs/。

质量门禁(不通过不交付)

  • lint / validate / inspect 全部通过。
  • 排版:禁止元素重叠、文字溢出/出画布/截断等低级错误;inspect --strict 错误与警告清零才可交付,封面同样核验。
  • 防伪间距:右上角印章与文字/内容/动画保持安全距离,禁止重叠或贴近;视频与封面均适用。
  • 场景间必须转场,禁止硬切;每个场景元素必须有入场动画(hyperframes 硬性规则)。
  • 动效丰富度:禁止连续 3 秒静止;每场 ≥1 ambient、≥3 层视觉、≥2 焦点;动效遵守安全护栏——不入侵字幕/印章区、同屏并发 ≤3、只用风格内变换、无诡异运动与重叠。
  • 无文字水印:视频与封面禁止出现"平台名+主题名"或类似文字水印;印章是唯一水印。
  • 印章常驻:印章必须以根层覆盖实现并全程可见,渲染后抽查首/中/尾帧确认。
  • 对比度:正文 4.5:1,大字(24px+ 或 19px+ 粗体)3:1,只能在本风格色板内调整。
  • 确定性:禁止 Math.random() / Date.now();动画 repeat 必须有限值。
  • 配音:句子只允许在句号/问号/感叹号处停顿;tts_yunxi.py 会自动规整文本并按句切分,禁止句子中间产生停顿或卡顿。
  • 时长:口播最长约 10 分钟;超长稿必须提炼核心精华压缩(保留主线/结论/数据亮点,砍掉重复铺垫与次要细节),时长与质量并重。
  • 脚本净化:写作标记/结构标签(如"一、开场钩子")不得以原文出现在视频中——不朗读、不上字幕、不显示为画面文字,必须转化为实际表达。
  • 口播优化:脚本须按流量第一性原理优化(共鸣选题/认可点赞/槽点评论/嘴替转发/价值认同涨粉),成片须具备钩子、干货、讨论点、嘴替句与价值收尾;结尾固定使用主题 JSON 的 brand.sign_off;数据须场景化"人话"解读、不冷冰冰堆数据;专业人认可价值、普通人觉得用得上看完;全程高价值,能留住观众。
  • 数据真实性:口播稿所有数据点须有来源(原稿出处或标注"待核实"),确认时附数据来源清单;禁止编造数据。
  • 用户确认闸门:优化后的口播稿必须先完整展示给用户审阅并获确认,未经确认禁止生成视频。
  • 字幕:主视频烧录字幕并与配音同步;字幕与画面动画文字不得重复——画面展示核心内容时该句字幕可隐藏或精简,同一信息同屏只呈现一次;字幕文件同步交付。
  • 数据可视化:数据/对比/流程必须用风格统一的动态图表表达(等宽数字 + 主题强调色 + 主题次级色网格,GSAP/SVG 实现),禁止大段静态文字或原图图表。
  • 标题增长:每个候选标题必须命中至少一个增长原则(共鸣/认可/槽点/嘴替/价值认同),禁止为凑数生成无钩子标题。
  • 渲染前核验:渲染前必须逐项对照本文件与 style-system.md/workflow.md 的全部规则并确认通过,未通过禁止渲染。
  • 封面:必须独立构图设计,禁止从主视频截帧或裁切充当封面;封面采用主题定义的封面视觉,与视频画面保持两套语言,避免被平台判定为截图。
  • 防 AI 味:禁止紫蓝霓虹、黑底光效、通用科技字体、机械匀速动画、空荡背景、每句整屏大字。
  • 活人感:净化后口播稿正文与 titles.txt 硬禁项清零——动作级禁令:翻案腔(先立误解再推翻抬价,含 9+ 变形)、三项以上同构排比、抒情借喻(抽象名词配具体动词)、动词名词化;标点:破折号全禁、冒号仅引出直接原话可用;硬停词"说白了/说穿了/先说结论";模型洞察路标;商业与模型黑话(绝对禁词 + 语境判断词两档,清单见 scripts/check_prose.py);check_prose.py 失败不交付。

音色选择(静默默认,按需试听)

  • 默认流程不询问音色,直接用当前主题的默认音色(默认主题为云希);只有用户主动提到换声音(女声/男声/换个音色/有哪些音色等)时才进入选择。
  • 进入选择时:用用户当前稿子的开头两句(约 30 字)对候选音色各合成 3-5 秒试听 mp3,列出文件让用户听完再定;禁止只用文字描述(「温暖」「活泼」)代替试听。
  • 用户选定后,本次配音带 --voice <预设名>,交付物注明所用音色;用户说「以后都用这个」时记录偏好,后续流程默认该音色,不再询问。
  • 推荐组合(供用户参考):资讯盘点 yunyang(播报感)、热血/体育向选题 yunjian、知识讲解 yunxi/xiaoxiao、方言玩梗 xiaobei/xiaoni。

语音回退(按序)

  1. 默认:scripts/tts_yunxi.py(edge-tts,默认云希,--voice 可换预设音色,需联网)。
  2. 用户提供云希音频:直接导入并转写,跳过合成步骤。
  3. 离线兜底:npx hyperframes tts --voice zm_yunxi(音色偏平,需告知用户差异)。

资源

  • themes/paper-algorithm.json:默认品牌主题(色板/印章/签名/字体/音色等全部品牌值)
  • references/style-system.md:默认主题(纸上算法)的视觉契约(色板/字体/动效/转场/字幕/图片处理/防伪/封面)
  • references/theme-guide.md:主题指南——如何创建自己的品牌主题
  • references/workflow.md:详细生产流程与命令
  • references/titles.md:平台标题规则
  • scripts/tts_yunxi.py:配音管线(内置多音色预设,默认云希)
  • scripts/verify_output.py:交付核验硬门禁(音轨/时长/双封面/标题/发布计划,全过才交付)
  • scripts/check_prose.py:活人感硬禁项检查脚本(TryWorld 改造版,源自 KKKKhazix/human-writing v1.1.0,MIT;禁令上移到修辞动作级)
  • assets/paper-grain.svg:纸纹叠加层
  • assets/seal.svg:朱红"试界原创"印章
Files (paper-algorithm)
  • agents
    • openai.yaml 335 B
      interface:
        display_name: "试界-纸上算法 TryWorld-Paper Algorithm"
        short_description: "纸上算法风格+云希配音,产出试界TryWorld AI科普视频、横竖封面与标题"
        default_prompt: "Use $tryworld-paper to turn this AI voiceover script into a 试界TryWorld branded horizontal video with covers and titles."
      
  • assets
    • paper-grain.svg 380 B · in bundle
    • seal.svg 945 B · in bundle
  • references
    • style-system.md 10 KB
      # 纸上算法 Paper Algorithm —— 试界TryWorld 视觉契约
      
      本文件是**默认主题(纸上算法)视觉细则的唯一来源**,其结构(色板四色制、字体三系统、三动效签名、转场、字幕、防伪、封面)对所有主题生效;非默认主题的品牌值以其主题文件为准。所有构图、封面、素材处理的配色、字体、动效、转场必须精确遵循,不允许临时换色、换字或添加装饰。
      
      > **主题说明**:本文件描述的是**默认主题 `paper-algorithm`**(试界TryWorld 品牌)的视觉系统,品牌值以 `themes/paper-algorithm.json` 为准。想换自己的品牌:复制主题 JSON 并按 `references/theme-guide.md` 创建新主题——本文件的结构(色板四色制/字体三系统/三动效签名/转场/字幕/防伪/封面)是所有主题的模板,值随主题变,结构不变。
      
      ## 设计源头
      
      科学手稿 + 中文印刷传统(实验记录本、文献批注、古籍印章)。每一期视频都是一页"会动的算法笔记":纸面是舞台,墨迹是文字,朱红是重点,印章是签名。
      
      ## 色板(全片锁定,4 色,禁止新增)
      
      | 色 | 值 | 用途与规则 |
      |---|---|---|
      | 纸面 | `#F4EFE4` | 背景主色;卡片、封面底色 |
      | 墨黑 | `#1C1916` | 主文字、线条、边框 |
      | 朱红 | `#C0452F` | 唯一强调色:关键词、关键数字、重点划线、印章、图表强调线 |
      | 墨水蓝 | `#2E5E8C` | 次级批注文字、图表辅助线;仅限这两处 |
      
      规则:朱红一次只服务一个焦点;同一画面最多 2 处朱红。禁止渐变紫蓝、霓虹;视频画面禁止纯黑背景,封面深墨海报的深墨底色为例外(见封面系统)。
      
      ## 字体(三系统)
      
      - 主标题/大字:思源宋体(Noto Serif SC,900/700 字重)——编译器原生支持,已实测。
      - 批注/引用/小标签:ZCOOL XiaoWei(小薇宋体)或手写感字体,模拟笔注——编译器原生支持,已实测。
      - 数据/坐标/来源:等宽字体(JetBrains Mono 等),用于 `Fig.1`、数据来源、编号。
      - 字号:标题 60px+,正文 20px+,数据标签 16px+;display 字号 tracking -0.03~-0.05em。
      - 数字一律 `font-variant-numeric: tabular-nums`。
      - 禁止:Inter、Roboto、Open Sans、Noto Sans(含 SC)、Syne、Poppins、Playfair 等被 hyperframes 禁止的字体;禁止黑体默认感。
      - 字体嵌入(实测结论 2026-08-03):Noto Serif SC ✅、ZCOOL XiaoWei ✅ 由编译器自动嵌入;LXGW WenKai(霞鹜文楷)❌ 无编译器映射,如确需使用必须自带 woff2 并以 `@font-face` 声明(可选,非默认)。
      - 回退链:思源宋体 → ZCOOL XiaoWei(均原生可用),保持气质(衬线/楷体,不用黑体)。
      
      ## 动效语法(全局三种签名,全片一致)
      
      1. **墨落纸**:主入场。`gsap.from` y+40~80 + opacity 0→1,`power3.out`,0.5-0.8s。
      2. **笔写入**:小元素/批注。scale 0.2→1 + rotation 3-6°,`sine.inOut`,0.4-0.6s,像一笔写出来。
      3. **盖章**:数字、结论、印章。scale 1.4→1(印章 1.6→1)+ opacity,`back.out(1.4)`,0.3-0.45s,带轻微压印感。
      
      节奏:教程型慢-中-重音;每 8-15 秒一个动效重音(数字、结论、强调句);每场景 5-15 秒;章节切换前留 0.3 秒纸面空场。同一场景内入场模式不得重复;每场至少使用 2 种签名缓动。禁止匀速线性动画、无意义的弹跳。
      
      ## 转场(统一,不硬切)
      
      - 默认:**墨染过渡**(前场景被墨色浸染溶解,新场景从纸面浮现),可用 shader 或遮罩 + 纸纹实现。
      - 轻量场景间:纸纹交叉溶解 0.4s + 墨线擦除。
      - 章节级:墨染 + 0.3s 纸面空场 + 新章节标题盖印入场。
      - 遵守 hyperframes 转场硬性规则:每场有入场动画;除末场外不做退场动画;转场即退场。
      
      ## 字幕与文字强调
      
      - 强制:主视频必须全程带字幕(烧录在画面内),不允许有无字幕的段落。
      - 字幕底色:墨黑字(浅纸底上),当前句关键词染朱红。
      - 逐词高亮只用于关键词(马克笔扫过/手绘圈/划线/涂鸦,用 hyperframes css-patterns),普通句子整句淡入,避免闪动疲劳。
      - 数字/代码/专有名词:等宽字体 + 朱红,可盖章入场。
      - 引用/注释:墨水蓝小字 + 手绘下划线,模拟批注。
      - 正文 20px+,避免过小字号。
      
      ## 图片处理(混合素材统一通道)
      
      所有外部图片进入"纸面化"管线,按类型处理:
      
      - **截图**:嵌入米色纸面卡片(白底截图 + 纸纹 + 墨色描边 2-4px + 手绘感圆角/不规则边框),卡片可 0.5-1° 轻微旋转交错摆放。
      - **AI 概念图**:整幅使用但必须叠纸纹 + 墨色 vignette,轻微降饱和。
      - **图表**:重绘为本风格动态图表,规则见"动态图表与可视化"章节,不直接用原图。
      - **照片**:同概念图处理,叠纸纹。
      
      ## 动态图表与可视化(数据动起来)
      
      原则:数据、对比、流程、时间线优先用**动态图表**表达,避免大段静态文字配解说;每个数据重音(8-15 秒一次)尽量配一个可视化。
      
      风格(与全片一致):
      
      - 数字:等宽字体 + `tabular-nums`,朱红标出关键值/最高点。
      - 网格/辅助线:墨水蓝细线,克制使用;主数据柱/线用墨黑描边 + 朱红强调。
      - 载体:纸面浅色卡片/纸纹底、墨色细边框、手绘感。
      
      动态模式(用 GSAP + SVG/CSS 实现,禁止图表库):
      
      1. **数字滚动/盖章计数**:从 0 滚到目标值,落点时盖章强调。
      2. **柱状增长**:墨迹从底部向上填充或线条生长(`power3.out`)。
      3. **折线/趋势**:墨线一笔画出(笔写入签名)。
      4. **进度环/比例条**:弧线或填充条表示占比,数字居中。
      5. **对比卡片**:2-3 个指标并排,同一版式只变数值(视觉连续性)。
      6. **流程/时间线**:步骤节点用印章/圆点 + 墨线连接,逐点亮起。
      
      硬规则(来自 hyperframes data-in-motion):
      
      - 同一概念的多组数据放在同一视觉空间,只变数值;换视觉 = 换概念。
      - 数字必须有视觉载体(填充条/环/形状),禁止孤零零一个数字。
      - 禁止饼图、多轴图、6 宫格仪表盘、网格线/刻度/图例。
      - 禁止 D3/Chart.js 等图表库输出;一律 GSAP + SVG/CSS。
      - 图表必须重绘为本风格,禁止直接使用原图/截图。
      - 数据动画出现时刻 = 口播讲到该数据的时刻,**必须**从 `sentences.json` 的句级 `start` 时间戳反推 `data-start`,禁止手动估时。所有场景的 `data-start` 同理——从 `sentences.json` 时间轴反推,不凭感觉排布;这是动画与口播衔接的唯一正确做法。
      
      ## 防伪标识(视频与封面都必须有)
      
      - **朱红印章**:右上角**全程常驻显示**——朱红「试界原创」印章(`assets/seal.svg`)。主视频中印章用盖章动画入场(scale 1.6→1 + back.out);**封面中印章为静态可见**(无 `gsap.from`,CSS opacity 1),保证截帧 t=0 即完整画面。印章是防伪标识也是品牌记忆点。
      - **保护区(硬性)**:印章(右上角)为固定保护区——文字、字幕、内容元素与动画不得进入或贴近,内容不得从印章上盖过。构图时先预留保护区,任何元素不得与印章重叠。
      - 封面右上角同样放置印章(可缩小),遵守同一保护区规则。
      - **实现要求(硬性)**:印章用根层常驻覆盖元素实现(根 index.html 中 `data-start="0"`、`data-duration` = 全片时长,右上角定位,z-index 高于墨染层),禁止只放在章节首幕;章节构图内不再重复放印章。
      - **禁止文字水印**:视频与封面禁止出现"试界TryWorld 纸上算法"文字水印(右下角 `watermark.svg`、封面 `.wm` 文本等一律不要);印章是唯一水印。
      
      ## 封面系统(横 4:3 + 竖 3:4,深墨海报)
      
      封面必须与视频画面分开、独立设计构图;禁止从主视频截帧或裁切充当封面(截帧封面会降低平台流量)。
      
      ### 封面视觉语言:深墨海报(与视频刻意区分)
      
      封面与视频使用**两套视觉语言**:视频是浅纸面,封面是深墨海报。平台按"封面与视频画面相似度"判定截图,两套语言可避免封面被识别为截图;品牌一致性由固定元素承担(顶部栏目名小字、朱红印章、思源宋体),禁止把"试界TryWorld 纸上算法"作为水印文字叠加在封面上。
      
      | 元素 | 规范 |
      |---|---|
      | 底色 | 深墨 `#12100D`(仅封面使用),可叠微纸纹 |
      | 主标题 | 纸面浅色大字 `#F4EFE4`,思源宋体;1-2 个关键词染朱红 `#C0452F` |
      | 朱红光晕 | 标题/数字背后用朱红径向光晕(低透明度 8-15%),禁止大面直线渐变 |
      | 信息卡片 | 深色卡片 `#221F1B` + 纸面色描边(透明度 15-25%),浅色文字 |
      | 批注小字 | 墨水蓝 `#2E5E8C` 或纸面色 70% |
      | 品牌元素 | 顶部"试界TryWorld · 栏目/期数"、右上角朱红印章,全部保留 |
      | 水印 | 只允许右上角朱红印章;禁止"试界TryWorld 纸上算法"文字水印(含 `.wm` 文本元素) |
      
      禁止:纯黑 `#000`、霓虹紫蓝、蓝紫渐变。
      
      ### 信息结构
      
      三层信息结构,杜绝稀疏:
      
      1. **顶部品牌条**:试界TryWorld · 栏目/期数,浅色小字 + 朱红点缀。
      2. **中央主标题**:浅色大字 2-3 行,可 1-2 个关键词染朱红。
      3. **周围 2-4 个小信息块**:深色卡片承载朱红数字、短结论、悬念句(如"5 分钟"、"3 个误区"),围绕主标题。
      
      版式:
      
      - 横版:标题占左 60%,信息块列于右侧;右上角朱红印章。
      - 竖版:标题上部 40%,信息块环绕左右;右上角朱红印章。
      - 安全区:主要信息保持在画面中央约 85% 区域;横版避让右下角平台时长角标,竖版避让底部 15% 评论区/文案区。
      - 封面标题短(6-12 字),细节交给信息块,避免封面文字堆叠。
      
    • theme-guide.md 5.2 KB
      # 主题指南 · 如何把这套流水线换成你自己的品牌
      
      本技能的视觉与品牌全部集中在**主题文件**里(`themes/<主题名>.json`)。默认主题 `paper-algorithm` 是试界TryWorld 的品牌。想用自己的品牌做频道,照本指南创建一个新主题即可——**流水线的所有质量机制(闸门、活人感门禁、字幕烧录、质量检查)原样生效,一个都不用改。**
      
      ## 一、两层契约
      
      - **流水线契约(不可配置)**:优化稿确认闸门、活人感门禁清零、字幕全程烧录、印章/标识右上角全程常驻且为唯一水印、句级停顿规则、对比度要求、时长与画幅上限(横屏 1920x1080)、交付物规格(主视频/横竖封面/平台标题/字幕时间轴)、场景入场动画与转场要求、数据真实性。完整清单以 SKILL.md「流水线契约」章节为准。这些是这套工作流的价值本身,任何主题都必须遵守。
      - **品牌主题(可配置)**:色板、字体、印章文字、平台名、签名语、封面风格、动效签名、配音音色。全部写在主题 JSON 里。
      
      > **边界须知**:品牌参数化当前覆盖 **tryworld-paper(出片技能)**。tryworld-koubo(写稿方法论)与 tryworld-topics(选题规则)内置了试界频道的选题定位与受众画像;邮件通知主题、发布计划抬头也为试界文案。换品牌用户主要使用 paper 出片;如需 Koubo/Topics 完全贴合你的频道,需按你自己的频道定位调整其 SKILL.md 与 selection-rules.md(后续版本会参数化这些点)。
      
      ## 二、创建你的主题(三步)
      
      1. 复制 `themes/paper-algorithm.json` 为 `themes/<你的主题名>.json`。
      2. 改里面的值(见下方可改清单)。
      3. 出片时告诉 AI 使用你的主题(「用 <主题名> 主题出片」),或在会话中直接说想换品牌,AI 会引导你完成。
      
      ## 三、可改的值
      
      | 字段 | 说明 | 注意 |
      |---|---|---|
      | `theme` | 主题名(调用依据) | 必须改为你的主题名且与文件名一致,出片时按此名引用 |
      | `name` / `description` | 主题显示名与一句话描述 | 自由填写 |
      | `brand.platform_name` | 平台/频道名,出现在视频、封面、品牌条 | 全片统一 |
      | `brand.seal_text` | 印章文字(1-4 字最佳) | 需同步替换 `assets/seal.svg` 为你自己的印章 SVG |
      | `brand.sign_off` | 每期固定结尾签名(口播+画面同步) | 建议 50-70 字(默认主题即此长度),含召唤关注 |
      | `brand.cover_brand_line` | 封面顶部品牌条文案 | |
      | `palette.*` | 全部颜色值(background 背景 / text 主文字 / accent 唯一强调色 / secondary 次级色 / cover_background 封面深底 / cover_card 封面卡片) | 保持 4 色制;强调色一次只服务一个焦点的规则不变;`cover_background` 与 `cover.style` 使用同一深底色,两处需同步改 |
      | `fonts.*` | 主标题/批注/数据字体 | 只能选编译器原生支持的字体(见 style-system.md 字体嵌入实测表),非原生字体须自带 woff2 |
      | `assets.*` | 印章、纹理 SVG 路径 | 换成你自己的资产文件 |
      | `voice.engine / preset` | 默认配音引擎与音色(出片配音命令带 `--theme` 时自动生效,`--voice` 可覆盖) | 引擎当前支持 edge-tts;详见 SKILL.md 音色章节 |
      | `motion_signatures` | 三种动效签名(主入场/小元素/重音) | 形式可自定义,但必须是三种、全片一致、每场至少用两种 |
      | `transition` | 转场方式 | 场景间必须有转场,禁止硬切的规则不变 |
      | `cover.*` | 封面视觉语言 | 封面必须与视频画面两套语言(避免被平台判定截图)、禁止截帧当封面 |
      | `publish_plan.title` | 发布计划.txt 的标题行 | 替换为你自己的品牌名 |
      | `publish_plan.platforms[].name / .time` | 发布计划平台名与推荐时间(数组,逐条渲染) | 平台数量与顺序自定;脚本与文档自动读取 |
      
      ## 四、不可改的(动了不交付)
      
      - 任何「可配置」都不豁免流水线契约:**优化稿必须经你确认才能出片;口播稿与平台标题必须过活人感门禁清零;印章/标识右上角全程常驻且是唯一水印(不得加文字水印);字幕烧录且与配音同步;数据必须有来源**。
      - 4 色制:色板值可以换,但「背景 + 主文字 + 唯一强调色 + 次级色」的四色结构与克制原则不能破坏(禁止渐变紫蓝、霓虹、大面渐变)。
      - 印章保护区:右上角保护区规则、根层常驻覆盖实现方式,任何主题都必须遵守。
      
      ## 五、质量自检(换主题后第一次出片前)
      
      - [ ] 新印章 SVG 右上角常驻、与内容保持安全距离
      - [ ] 新色板下正文对比度 ≥ 4.5:1(大字 ≥ 3:1)
      - [ ] 新字体在编译器中正确嵌入(先跑一次 draft 渲染确认)
      - [ ] 签名语口播与画面同步、长度适中
      - [ ] 封面与视频仍是两套视觉语言
      
      ## 六、主题与开源
      
      主题文件随仓库以 CC BY-SA 4.0 发布。欢迎提交你设计的新主题(PR 到 `skills/tryworld-paper/themes/` 目录,附一版 720p 示例),入选后会在 README 展示并标注作者。注意:主题里不得包含他人商标或未授权的音色克隆。
      
    • titles.md 3.8 KB
      # 标题规则(试界TryWorld)
      
      原则:主标题必须有**具体钩子**(数字、反常识结论、问题、悬念),禁止"AI 时代来了"这类空泛标题。封面标题要短,细节给信息块。
      
      ## 增长第一性原理(标题与表达的核心依据)
      
      | 目标 | 第一性原理 | 在标题/表达中的落地 |
      |---|---|---|
      | 流量 | 有共鸣的选题内容 | 标题直指目标人群的真实困惑与好奇,开头即"与我有关" |
      | 点赞 | 内容认可 | 标题承诺明确价值,内容有"学到了/说得对"的落点,不夸大 |
      | 评论 | 制造槽点 | 标题留一个可争论、可补充的钩子(反常识、待反驳结论、开放问题) |
      | 转发 | 给人当嘴替 | 替观众说出他们想说而不会说的话,观点鲜明、立场清晰 |
      | 涨粉 | 价值认同 | 体现"这套方法/这个认知值得长期关注",有方法论或系列感 |
      
      生成要求:每个候选标题至少命中一个原则,推荐命中 2 个(如流量+评论、转发+涨粉),并在候选下标注命中的原则。
      
      ## 平台适配
      
      | 平台 | 风格 | 字数 | 要点 |
      |---|---|---|---|
      | B站 | 专业 + 信息量 | 10-20 字 | 关键词前置,可带系列感("纸上算法"栏目) |
      | 抖音 | 前 5 字即钩子 | 8-15 字 | 口语化、悬念/反常识、可带情绪词 |
      | 小红书 | 清单/攻略句式 | 10-20 字 | 价值明确("3 个方法""1 个模型"),emoji 克制使用 |
      
      ## 生成流程
      
      1. 从脚本提取:核心结论、最大反常识点、关键数字、目标人群。
      2. 按平台各写 1-2 个候选(共 3-5 个),格式统一为 `<钩子>`(可选 `<副信息>`)。
      3. 每个候选标注:推荐平台、理由、命中的增长原则。
      
      输出到 `titles.txt` 时,候选标题行与说明行统一遵守硬禁词(见下节):说明行里的"命中"用空格或括号分隔(如 `命中 流量(共鸣)+ 涨粉(方法论感)`),不用冒号,保证整份 `titles.txt` 能一次过 `check_prose.py` 清零。
      
      ## 公式示例
      
      - 反常识:`大模型其实不会"思考",它只是在做下一词预测`
      - 数字:`5 分钟看懂 Transformer,一个数字拆穿它`
      - 悬念:`为什么 AI 越强,越需要"纸上算法"?`
      - 清单:`AI 科普 3 个误区,第 2 个几乎人人中招`
      
      ## 硬禁词(标题与正文同守)
      
      平台标题与口播正文一样执行活人感硬禁项,动作级定义(完整清单见 `scripts/check_prose.py`):
      
      - **翻案腔**:禁先立误解再推翻抬价("不是……而是……""你以为……其实……""回头才发现"等 9+ 变形);标题空间小,判断从正面下。
      - **同构排比**:三项以上整齐排比禁,两项为限。
      - **抒情借喻**:不给抽象名词配具体动词("时间保管细节"类)。
      - **动词名词化**:禁"实现了……的提升"式,还原成直接动词。
      - **标点**:标题禁中文/英文冒号(`:` `:`)与破折号(`—` `——` `–`)。标题无"引出原话"场景,冒号一律不用(与正文的分级规则不同)。
      - **硬停词**:"说白了/说穿了/先说结论"。
      - **模型洞察路标**:"值得注意的是""还有一层""从某种意义上说"等。
      - **商业/模型黑话**:绝对禁词 + 语境判断词两档(清单见 `scripts/check_prose.py`)。
      - 改写方法:冒号换逗号或括号,破折号换逗号或句号,翻案腔拆回事实陈述。
      - 标题生成后运行 `python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py" outputs/titles.txt`,失败项改写后清零。
      - 说明行(推荐平台/理由/命中原则)同样避免冒号与破折号,推荐理由里的专有名词、数据与括号不受影响。
      
      ## 反例(禁止)
      
      - `AI 时代来了`、`你必须要知道的 AI`、`震惊!`、`看完震惊了`
      - 无信息量、标题与内容脱节、夸大虚假承诺
      
    • workflow.md 21.6 KB
      # 生产流程(按顺序执行)
      
      > **主题说明**:本文件以默认主题 `paper-algorithm` 描述生产流程。使用其他品牌主题时,流程步骤不变,品牌值(色板/印章/签名/字体/封面语言等)以主题文件为准,视觉细节按该主题的视觉定义执行。
      
      环境前提:Node >= 22、FFmpeg(含 ffprobe,加入 PATH;`winget install Gyan.FFmpeg` 可安装,装后用 `npx hyperframes doctor` 验证)、Python 3.10+、`edge-tts`(`python -m pip install edge-tts`)、hyperframes CLI(`npx hyperframes`)。转写需要 whisper(`npx hyperframes transcribe` 首次使用会给出安装提示)。
      
      ## 1. 输入、读稿理解、优化与用户确认
      
      输入:口播稿文本(可直接粘贴或 .txt/.md 路径)+ 可选图片目录。
      
      ### 1.1 读稿理解(强制,第一步)
      
      先通读全稿,理解:主题是什么、讲给谁、核心结论、章节结构、每段要传达的意图。只有理解了意图,才能正确净化与补写。同时评估**选题共鸣点**:主题是否对准目标人群的真实困惑;共鸣弱时在开场钩子与标题上强化(增长原则见 titles.md)。
      
      ### 1.2 口播稿优化(按流量第一性原理)
      
      优化目标:让口播稿的每一部分都服务于平台行为。五项第一性原理及落地规则:
      
      1. **流量 ← 有共鸣的选题内容**:开场 0-15 秒直接命中目标人群的共鸣点(痛点/好奇/反常识);确认选题切中受众真实处境,不做自嗨内容。
      2. **点赞 ← 内容认可**:全片要有"值得认可"的干货——专业判断、清晰逻辑、具体数据;关键观点明确不模棱两可;认可点集中在数据与结论场景。
      3. **评论 ← 制造槽点**:在合适位置埋 1-2 个"槽点"——可讨论、可反驳、有悬念的提问或观点(反常识结论、二选一、留白问题),引导观众发言;槽点贴合内容与品牌气质,不低俗引战。
      4. **转发 ← 给人当嘴替**:写 1-2 句"观众想替自己说"的话——替观众说出痛点、表达立场或情绪;独立成句、便于截取,让人转发时能说"这就是我想说的"。
      5. **涨粉 ← 价值认同**:结尾**必须使用当前主题的 `sign_off`**(默认主题为"我是试界TryWorld,持续把 AI 讲清楚,让每个普通人都看得懂、用得上。觉得这条视频有用,点个赞,也欢迎关注,谢谢大家,下期见。",见主题文件);让观众因认同价值而关注。
      
      执行要求:
      
      - 优化后仍须通顺自然、符合口播习惯(句级停顿规则不变)。
      - 优化可能引入"开场钩子""金句"等写作标记,最终统一按 1.3 净化规则清除。
      - 不得编造数据:数据点必须来自原稿,或明确标注"需补充核实"。
      - **时长控制**:口播目标最长约 10 分钟;原稿过长时提炼核心精华压缩——保留主线、结论、关键数据与案例,砍掉重复铺垫、次要细节与低信息量段落;压缩后的稿子同样要过双观众检验与用户确认闸门。
      
      优化验收标准(提交确认前逐项自检):
      
      - **内容吸引人、全程高价值**:每段都有增量信息,删掉低信息量流水句;没有"为了凑时长"的内容。
      - **不是冷冰冰的数据口述**:每个数据/概念都要有场景、"人话翻译"和"与观众的关系"(回答"所以呢?")。
      - **有共鸣点**:对准目标人群的真实处境;专业人士认可其专业与判断,普通人觉得"以后用得上"或"值得看完"。
      - **留住观众**:每 30-60 秒给一个继续看下去的理由(悬念、问题、案例、转折、钩子回收)。
      - **口播自然**:像对朋友讲,不像念报告;长句拆短、术语配解释。
      - **可核对交付**:优化稿须能逐句指认五类元素落点——钩子句、干货句、槽点句、嘴替句、价值收尾句(确认时附元素落点清单);压缩稿附"删除清单"(删了哪段、为什么删);每 30-60 秒的留人点须能在稿中指认。
      
      ### 1.2.1 活人感改稿(七遍,强制)
      
      目标:让稿子像一个见过事、查过材料、愿意把来龙去脉讲清楚的人在说话,而不是模型在完成写作任务。七遍按顺序走:
      
      1. **看谁在说**:这段话凭什么知道这件事?哪句换一个模型也能原样写出?后者优先补信息来路、作者取舍或具体材料,没得补就删。
      2. **检查有没有往前走**:给每段标一个主要作用(动作/事实/解释/例子/疑问/判断/背景/情绪),连续几段做同一件事就合并;后段只把前段换说法,删;删掉三分之一后事实与判断几乎没变,说明在注水。
      3. **拆表演性中文**:删假深刻(单独截图好看、放回正文没用的句子)、假具体(无来源的时间/天气/神态/摆设/对白)、假口语("老铁""兄弟们""泡杯茶慢慢说")、抽象名词堆砌(问谁做了什么、影响了谁、钱和时间落在哪里)、比喻换场(短距离出现多套借喻先还原成本义)。
      4. **听中文顺不顺**:主干早出现(先给做事的人与动作,再接时间/原因/条件);代词指向清楚;连续短句像敲桌子就合并,长句一口气没落脚处就拆开;动作/原话已经把情绪写出来,删掉后面的解释。
      5. **清硬禁项(动作级)**:禁的是修辞动作不是字面。翻案腔按动作查(先立读者没有的误解再推翻抬价,外衣包括"不是……而是……""你以为……其实……""回头才发现""说到底"等 9+ 变形);三项以上同构排比;抒情借喻(抽象名词配具体动词);动词名词化("完成了对……的优化");标点:破折号全禁、冒号仅引出直接原话可用;硬停词"说白了/说穿了/先说结论";模型路标("值得注意的是""还有一层"等);黑话(绝对禁词 + 语境判断词,清单见 `scripts/check_prose.py`)。命中就拆回普通句子,不要换另一种漂亮句式。
      6. **核现实**:时间、数字、身份、引语、因果与第一人称动作逐项核对;数据点无来源标"待核实",禁止编造。
      7. **查结尾**:删掉最后两段各读一遍,删后更有力就提前结束;结尾不重新摘要全文;固定签名保留,正文在签名前自然收束。
      
      检查命令(净化后正文与 `titles.txt` 各跑一次,清零才进入 1.4 闸门):
      
      ```powershell
      python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py" <净化后正文文件>
      python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py" <titles.txt>
      ```
      
      `发布计划.txt`、邮件正文、文件名、数据来源清单不受标点禁令限制。
      
      ### 1.3 脚本净化(强制,优化后执行)
      
      识别**写作标记/结构标签**——这些内容**永远不得以原文出现在成片中**:不朗读、不上字幕、不显示为画面文字。常见类型:
      
      - 章节/结构标签:"一、开场钩子"、"二、痛点"、"三、案例"、"结论总结"、"小标题"等。
      - 括号指令:"(此处插入截图)"、"(停顿)"、"(配数据)"、"【画面:...】"等。
      - 写作提示词:稿子里提示"开场钩子""金句""过渡句""留悬念"等做法的文字。
      
      净化规则(灵活处理,不机械套用):
      
      - 标签本身删除,保留其后真正的口播内容;如"一、开场钩子:今天我们来聊……"→ 只保留"今天我们来聊……"。
      - 标签后没有内容时,根据对主题的理解补写实际表达(如"开场钩子"→ 写一个真正的钩子句)。
      - 括号指令转化为对应内容:插数据→写出具体数据表达;配图→留给构图,不进入口播文本。
      - 净化后的文本才是配音、字幕、画面文字的输入;成片中不得出现任何写作标签原文。
      
      ### 1.4 用户确认闸门(强制)
      
      优化与净化完成后,**先停下来**:
      
      1. 先运行 `python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py" <净化后正文>` 清零硬禁项,再在对话中完整展示优化后的口播稿正文。
      2. 附简明优化说明:改了什么、为什么(命中哪些第一性原理);附**元素落点清单**(钩子/干货/槽点/嘴替/价值收尾逐句指认)与**数据来源清单**(每个数据点 + 出处,无法核实标注"待核实")。
      3. 交付前自检(双受众视角):
         - **专业人士**:干货密度、逻辑严谨、数据可靠、观点专业——看完觉得"内容不错、有价值"。
         - **普通人**:有共鸣、听得懂、觉得"以后可能用得上"或"值得看完"——不是冷冰冰的数据口述。
         - 全程无数据堆砌:每个数据/概念都要有"人话翻译"和"与观众的关系"。
      4. **未获用户确认,禁止进入 ## 2 配音及后续所有制作步骤**;用户确认后再继续。
      
      闸门的技术边界(必须知道):`.prose_pass` / `.confirmed` 是 `work/` 下绑定了 draft 内容 sha256 的普通 JSON 文件,由 `pipeline/state.py` 读写。它能防住"草稿改了但没重新过门禁"这类漂移与误操作,**但不能防住同一用户/同一进程伪造标记**——所以"停下来等用户确认"必须是真实发生在对话里的动作,不能由 agent 自己补一个 `--confirm` 或手写标记文件来代替。tests/test_gate_adversarial.py 固化了一组绕过尝试(跳过步骤 03、只传 `--confirm`、过期确认等),回归时一并跑。
      
      口播稿约定(保证配音无生硬停顿/卡顿):
      
      - 句子以句号/问号/感叹号/省略号结束;句内不要为排版硬换行(脚本会自动把句子中间的换行合并,不会产生停顿)。
      - 段落之间留空行;段落会在句号边界切分合成,停顿只出现在句子结束处。
      - 脚本里的 markdown 标题行(`#`)是结构标记,不会被朗读;`**`、反引号、列表符等符号会自动去除,不要依赖它们表达语气。
      - 若某句实际读起来仍不顺,检查该句标点(过长句可拆成两句)。
      
      分段规则(口播最长约 10 分钟,按口播时长自适应):
      
      - 每章 40-90 秒口播(约 180-400 字)。
      - 不足 1 分钟:不分章,单场景直接做。
      - 1-3 分钟:1-3 个章节。
      - 3-10 分钟:3-6 个章节。
      - 原稿超过约 10 分钟:先按"提炼核心精华"压缩至 10 分钟以内(左右),再按压缩后时长分段;禁止带着超长原稿直接做片。
      - 每章标记:章节标题、3-5 个关键词、2-4 个数据点、图片/图表提示、重音句。
      - 章节结构建议:开场钩子(0-15s)→ 概念讲解 → 数据/示例 → 小结。每章结尾留一个可做转场的"结论句"。
      
      产出:`plan.json`(或 Markdown 场景规划表),含每章标题、口播段落、场景数、节奏模式。
      
      ## 2. 配音(默认云希,可选预设音色)
      
      ```powershell
      python "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\tts_yunxi.py" script.txt --out work/audio
      ```
      
      输出:
      
      - `work/audio/segment-XXX.mp3`:每段落一轨
      - `work/audio/narration.mp3`:合并全片
      - `work/audio/segments.json`:每段 start/duration,用于章节时间轴
      
      参数:默认 `--rate +8%`(语速偏快一档,可按内容微调 +5%~+15%)、`--voice yunxi`(默认云希;预设音色 xiaoxiao/xiaoyi/yunjian/yunyang/yunxia/xiaobei/xiaoni,也接受完整 edge-tts 音色 id)。
      
      用户提供云希音频时:跳过本步,直接用其音频,记录章节切点。
      
      ## 3. 字幕时间轴
      
      字幕同步使用 `work/audio/sentences.json`(edge-tts 句级时间戳,脚本已按段落偏移换算为绝对时间)。每句含 start/end/text,用于字幕淡入与关键词高亮窗口。
      
      可选:如需词级时间轴(更细的高亮),安装 whisper 后运行 `npx hyperframes transcribe work/audio/narration.mp3`。
      
      **强制要求**:字幕必须烧录进最终视频画面(硬字幕),不是仅提供字幕文件;全片不允许有无字幕的段落。
      
      ## 4. 场景规划
      
      在写 HTML 前声明节奏,例如:
      
      `开场快-讲解慢-数据重音-小结慢-章节墨染`
      
      每个章节 1-3 个场景:章节标题卡、讲解场景、数据卡/图片场景。数据点/对比/流程优先规划为**动态图表场景**(计数/柱状/折线/进度环/流程),避免大段静态文字配解说。规划动效丰富度:**每 3-5 秒一个可见动效、每 8-15 秒一个重音、每场 1 个 ambient、同屏并发元素 ≤3**,并标注动效禁区(字幕区/印章区)。**画面文字与字幕错位规划**:数字图表化/缩写、金句画面与字幕择一呈现,同一信息同屏只出现一次。
      
      ## 5. 构图
      
      - 项目结构:`npx hyperframes init <slug> --non-interactive`(或用现有项目)。
      - 把 `assets/` 的纸纹、印章复制进项目。
      - 主构图 `index.html`:1920x1080;多章节用子构图(sub-composition)组织。
      - 先按 style-system.md 写静态布局(hero frame),再加 GSAP 入场;遵守 hyperframes 全部规则(时间轴注册、data-track-index、无 repeat:-1、无随机、场景转场等)。
      - 布局以 hero frame(元素完全就位的那一帧)为准:禁止元素重叠、文字溢出/出画布/截断;入场动画的临时溢出需标 `data-layout-allow-overflow`,最终帧必须干净。
      - 图表:用 GSAP + SVG/CSS 实现(禁图表库);同一概念数据保持同一版式只变数值;数字配视觉载体(填充条/环/形状);数据动画出现时刻与口播时间轴对齐。
      - 字幕为强制元素:全片逐句烧录在画面内并与配音同步,但**不得与画面动画文字重复**——画面展示核心内容(标题/数据/结论/关键词)时,该句字幕可隐藏或精简;逐词时间轴驱动关键词高亮,不逐词闪动普通句子。
      - 防伪:印章(右上角)全程常驻显示,用根 index.html 常驻覆盖层实现(id 如 `seal-overlay`,`data-start="0"`、`data-duration` = 全片总时长,右上角定位,z-index 高于墨染层);章节构图内禁止再放印章(避免重复)。
      - 禁止文字水印:不得生成 `watermark.svg` 或 `#wm`/`.wm` 等"试界TryWorld 纸上算法"文字水印层;印章是唯一水印。
      
      ## 6. 检查
      
      ```powershell
      npx hyperframes lint
      npx hyperframes validate
      npx hyperframes inspect --strict --samples 15
      ```
      
      规则:**错误与警告全部清零**才允许进入下一步;禁止元素重叠、文字溢出/出画布/截断等排版错误。对比度警告在色板内调整(正文 4.5:1,大字 3:1)。溢出问题优先用 max-width/字号/换行解决;装饰性元素用 `data-layout-ignore`。封面构图同样跑 lint/validate/inspect,取帧前用 `inspect --at <封面hero帧>` 复核版式。长视频建议跑 animation-map 检查节奏与死区。
      
      ## 7. 渲染前核验(Render Gate)
      
      最终渲染前,逐项核对以下清单,**全部通过才允许渲染**;任一项不满足,先修改再重新核验,避免返工浪费。
      
      1. **脚本净化**:全稿无写作标记残留("一、开场钩子"、括号指令、写作提示等均不朗读、不上字幕、不显示为画面文字)。
      2. **配音**:按 `--voice` 指定的音色(默认取主题 voice 字段,当前为真云希 Azure YunxiNeural)、语速默认 +8%、句级切分、无句中停顿/卡顿;`sentences.json` 时间轴与口播对得上;结尾为主题签名句(默认主题为试界TryWorld 标准收尾,见主题文件 sign_off)。
      3. **字幕**:全片烧录在画面内、无字幕空段;样式为墨黑字 + 当前句关键词朱红,与配音句级同步;与画面动画文字无重复(同一信息同屏只呈现一次)。
      4. **风格与动效**:4 色板无越界色、字体合规(无被禁字体)、三种签名动效;动效丰富度达标(无连续 3 秒静止、每场有 ambient、≥3 层视觉、≥2 焦点);安全护栏通过(无字幕/印章区入侵、同屏并发 ≤3、无诡异运动、无重叠);场景间有墨染转场、无硬切。**所有场景与数据动画的 `data-start` 必须从 `sentences.json` 句级 `start` 反推,禁止手动估时。**
      5. **防伪**:右上角朱红"试界原创"印章全程常驻显示(抽查首/中/尾帧确认);与字幕/内容/动画保持安全距离,无重叠、不贴近;封面同样适用;视频与封面无"试界TryWorld 纸上算法"文字水印。
      6. **图片与图表**:所有外部图片已纸面化处理,无原图直出;图表已重绘为本风格动态图表(GSAP/SVG、无原图截图、数字有视觉载体)。
      7. **封面**:独立深墨海报构图、横 4:3 / 竖 3:4、素材与视频完全独立、不截取视频画面;封面 HTML 无 `gsap.from`(元素 CSS 默认 opacity 1,截帧 t=0 即完整画面)。
      8. **工程**:`lint` / `validate` / `inspect --strict` 全部通过(错误与警告清零,无排版错误/元素重叠/文字溢出);封面构图同样核验;视频时长与口播 + 头尾留白对齐。
      
      渲染策略:先 `--quality draft` 预览确认整体效果,再 `--quality high` 出片;draft 阶段发现问题直接修改,避免 high 渲染返工。
      
      ## 8. 渲染主视频
      
      ```powershell
      npx hyperframes render --fps 30 --quality high --output outputs/tryworld_<slug>.mp4
      ```
      
      迭代用 `--quality draft`。确认总时长 ≈ 口播时长 + 头尾留白(通常 +2-4s)。
      
      ## 9. 封面
      
      封面必须独立制作,禁止从主视频截帧或裁切画面(截帧封面会降低平台流量);封面采用**深墨海报**视觉(深墨底 + 朱红光晕 + 浅色大字 + 深色卡片),与视频浅纸面画面刻意区分,避免被平台判定为截图。制作两个独立静态构图:
      
      - `covers/horizontal.html`:1920x1440(4:3)
      - `covers/vertical.html`:1080x1440(3:4)
      
      构图规则见 style-system.md 封面系统,标题与信息块为封面单独设计,不复用视频画面;印章(右上角)为保护区,主标题与信息卡片不得与其重叠或贴近。封面 HTML 必须为独立构图(standalone composition,不可用 `<template>` 包裹)。推荐直接复制 `templates/cover-horizontal.html` 模板改占位符(`{{TITLE}}` / `{{SUBTITLE}}` / `{{CARDS}}` / `{{BRAND_LINE}}` / `{{DATE_LINE}}` / `{{SEAL_SVG_PATH}}`),模板已符合下方硬性规则。**硬性**:封面禁用 `gsap.from` 动画(from 初始态为透明,截帧可能得到全黑画面)——封面元素用 CSS 默认可见(opacity 1),如需入场预备态用 `gsap.set`;截帧在 t=0 即得完整画面。渲染时用 `--composition` 指定封面文件(否则默认渲染主视频 index.html)。渲染后取帧为 PNG:
      
      ```powershell
      npx hyperframes render --composition covers/horizontal.html --output covers/h.mp4
      ffmpeg -y -i covers/h.mp4 -frames:v 1 outputs/cover_4x3.png
      npx hyperframes render --composition covers/vertical.html --output covers/v.mp4
      ffmpeg -y -i covers/v.mp4 -frames:v 1 outputs/cover_3x4.png
      ```
      
      封面必须含:主标题、2-4 个信息块、品牌条、右上角印章。封面水印只允许印章,禁止"试界TryWorld 纸上算法"文字水印(包括 `.wm` 文本元素)。
      
      ## 10. 标题
      
      按 `references/titles.md` 生成 3-5 个候选,每个候选标注平台推荐与命中的增长原则(共鸣/认可/槽点/嘴替/价值认同),输出 `outputs/titles.txt`。候选标题同守硬禁词:禁冒号/破折号/翻案句/黑话/模型路标;生成后运行 `python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py" outputs/titles.txt`,失败项改写后清零。
      
      ## 11. 交付清单与核验(硬性门禁)
      
      `outputs/` 下应有:
      
      - `tryworld_<slug>.mp4`(主视频)
      - `cover_4x3.png`、`cover_3x4.png`
      - `titles.txt`(含平台推荐)
      - `发布计划.txt`(四平台推荐发布时间与发布状态记录)
      - `narration.mp3` + `sentences.json`(配音与字幕时间轴,便于二次编辑)
      
      **交付核验(硬性,不通过不交付)**——整理完 `outputs/` 后必须运行:
      
      ```powershell
      python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\verify_output.py" --dir <outputs目录>
      ```
      
      逐项核验主视频音轨存在、时长与时间轴对齐、双封面尺寸(4:3 与 3:4)、标题与发布计划齐全。任何一项 FAIL:修复后重跑,直到输出「核验全部通过」才允许向用户交付。禁止跳过此步或仅凭肉眼判断(音轨缺失、封面漏渲染在画面抽帧中看不出来)。
      - `plan.json`(场景规划,便于复用章节结构)
      
      ## 常见问题
      
      - edge-tts 报错/无网络:用 `npx hyperframes tts --voice zm_yunxi` 兜底并告知用户音色差异,或改用用户提供的音频。
      - 转写时间轴与章节对不上:用 segments.json 的 start/duration 校准,而不是逐句估算。
      - 字体不支持:按 style-system.md 回退链换字,不换风格。
      - 渲染时长异常:检查 data-duration 与 timeline 长度、音频时长;确认没有空 tween 撑时长。
      - 配音出现不自然停顿/卡顿:检查脚本是否有句子中间换行或段落被硬切;脚本已按句切分,合成边界只在句号/问号/感叹号处。
      - 口播稿里有"一、开场钩子"这类章节标签怎么办:不朗读、不上字幕、不显示,先按净化规则转成实际表达再进入配音与构图。
      - check_prose.py 报失败项怎么办:按 1.2.1 第五遍处理——把冒号改成逗号或拆成两句、把破折号换成逗号/句号、把"不是……而是……"拆回事实陈述、把黑话与模型路标改成普通说法;改写后重跑直到清零,不要寻找另一种漂亮句式替换。
      - 成片右下角出现"试界TryWorld 纸上算法"水印:检查根 index.html 是否残留 `#wm`/`assets/watermark.svg`,删除并改用印章常驻覆盖层后重新渲染。
      
  • scripts
    • check_prose.py 22.6 KB
      #!/usr/bin/env python3
      """检查中文成稿的硬禁令与常见模型化形状。只报警,不自动改文。
      
      TryWorld 改造版:源自 KKKKhazix/human-writing v1.1.0
      (https://github.com/KKKKhazix/human-writing, tag v1.1.0),
      Copyright (c) 2026 Human Writing Skill contributors,按 MIT License 使用,
      完整许可文本见同目录 LICENSE-MIT。
      
      禁令上移到修辞动作级:翻案腔按动作查(变形族列入警告层),新增同构排比/抒情借喻/
      名词化检查,冒号分级(引出原话放行、提示性冒号仍禁),新增句长变异系数与连词密度统计;
      保留上游禁词清单与基础检查逻辑,检查逻辑在上游基础上扩展(见 LICENSE-MIT)。
      """
      
      from __future__ import annotations
      
      import argparse
      import collections
      import re
      import sys
      from dataclasses import dataclass
      from pathlib import Path
      
      
      HARD_STOPS = (
          "说白了",
          "说穿了",
          "先说结论",
      )
      
      HARD_JARGON = (
          "赋能",
          "抓手",
          "商业闭环",
          "价值闭环",
          "能力沉淀",
          "拉通",
          "底层逻辑",
          "顶层设计",
          "认知跃迁",
          "价值释放",
          "能力建设",
          "降本增效",
          "内容矩阵",
          "全链路",
          "组合拳",
          "打开想象空间",
          "结构性机会",
          "关键命题",
          "深层逻辑",
          "技术底座",
          "公共底座",
          "技术主权",
          "单点风险",
          "主脊柱",
          "材料锚点",
          "认知增量",
          "迭代闭环",
      )
      
      CONTEXT_JARGON = (
          "沉淀",
          "颗粒度",
          "对齐",
          "协同",
          "链路",
          "生态位",
          "心智",
          "范式",
          "方法论",
          "核心变量",
          "打法",
          "想象空间",
          "闭环",
          "不丢",
      )
      
      LYRIC_WORDS = (
          "安放",
          "抵达",
          "微光",
          "褶皱",
          "丰盈",
          "滚烫",
          "轻盈",
          "赤裸",
          "剥开",
      )
      
      ROAD_SIGNS = (
          "更微妙的是",
          "还有一层",
          "只说对了一半",
          "值得注意的是",
          "需要指出的是",
          "从某种意义上说",
      )
      
      ROAD_STRIP_CHARS = "。!?!? \n"
      
      FORBIDDEN_PUNCTUATION = {
          ":": "中文冒号",
          ":": "英文冒号",
          "—": "破折号",
          "–": "连接号式破折号",
      }
      
      PIVOT_PATTERNS = (
          re.compile(r"(?:并)?不是[^。!?\n]{0,90}而是"),
          re.compile(r"并非[^。!?\n]{0,90}而是"),
          re.compile(r"不在于[^。!?\n]{0,90}而在于"),
          re.compile(r"与其说[^。!?\n]{0,90}(?:不如|毋宁|倒不如)"),
          re.compile(r"[。!?!?]\s*而是"),
          re.compile(r"表面(?:上)?[^。!?\n]{0,90}(?:其实|实际|实则)"),
          re.compile(r"看似[^。!?\n]{0,90}(?:其实|实际|实则)"),
      )
      
      SEMANTIC_PIVOT_PATTERNS = (
          re.compile(r"(?:总|一直|曾|都)?以为[^!?\n]{2,60}?(?:其实|才发现|才明白|才知道|后来才)"),
          re.compile(r"(?:总|都|一直)以为[^!?\n]{2,60}?[。,](?:可|但|其实)"),
          re.compile(r"回头(?:看|一看)?才(?:发现|明白|知道)"),
          re.compile(r"(?:并)?不是[^。!?\n]{1,40},(?:更|才)?是[^,。!?\n]"),
          re.compile(r"从来(?:都)?(?:不是|与[^。!?,\n]{1,12}无关)"),
          re.compile(r"答案(?:是否定的|恰恰相反)|恰恰相反"),
          re.compile(r"表面(?:上)?[^!?\n]{0,60}。[^!?\n]{0,12}(?:其实|实际|实则)"),
          re.compile(r"看似[^!?\n]{0,60}。[^!?\n]{0,12}(?:其实|实际|实则)"),
          re.compile(r"[^,。!?\n]{1,12}不重要,(?:重要|要紧)的是"),
          re.compile(r"真正[^,。!?\n]{0,16}的(?:,)?是"),
          re.compile(r"不只(?:是)?[^。!?\n]{0,90}(?:还|也)"),
      )
      
      NOMINALIZATION_PATTERNS = (
          re.compile(r"进行(?:了|一次|一场|着)?[^。,!?\n]{0,10}(?:调整|优化|升级|分析|讨论|沟通|梳理|复盘|迭代|探索|尝试|思考|规划|布局)"),
          re.compile(r"实现了?[^。,!?\n]{0,14}的?[^。,!?\n]{0,6}(?:提升|增长|突破|转变|跃升|落地)"),
          re.compile(r"完成了?对[^。,!?\n]{0,16}的"),
          re.compile(r"起到了?[^。,!?\n]{0,12}的?作用"),
          re.compile(r"具有[^。,!?\n]{0,10}(?:意义|价值)"),
      )
      
      CONJUNCTIONS = (
          "因为",
          "所以",
          "但是",
          "然而",
          "同时",
          "此外",
          "而且",
          "并且",
          "因此",
          "不仅",
      )
      
      ROAD_SIGN_PATTERNS = (
          re.compile(
              rf"(?:^|[。!?!?]\s*){re.escape(ROAD_SIGNS[0])}[^。!?!?\n]{{0,24}}",
              re.MULTILINE,
          ),
          re.compile(
              rf"(?:^|[。!?!?]\s*){re.escape(ROAD_SIGNS[1])}(?=(?:更|原因|问题|意思|考虑|变化|逻辑|价值|作用|风险|影响|值得|很少|不容易|常被|往往))[^。!?!?\n]{{0,24}}",
              re.MULTILINE,
          ),
          *(
              re.compile(
                  rf"(?:^|[。!?!?]\s*){re.escape(phrase)}[^。!?!?\n]{{0,24}}",
                  re.MULTILINE,
              )
              for phrase in ROAD_SIGNS[2:]
          ),
      )
      
      SOFT_MARKERS = (
          "真正",
          "本质上",
          "更深层次",
          "归根结底",
          "换句话说",
          "不可否认",
          "核心是",
          "关键在于",
          "这意味着",
      )
      
      REPEATED_OPENERS = (
          "其实",
          "不过",
          "当然",
          "所以",
          "但是",
          "后来",
          "当时",
          "很多人",
          "问题是",
          "更重要的是",
          "说到这里",
      )
      
      LEFT_BRANCH_PATTERNS = (
          re.compile(r"(?:^|[。!?]\s*)在[^,。!?\n]{12,70}(?:以后|之后|之前|以前|过程中|情况下|背景下),"),
          re.compile(r"(?:^|[。!?]\s*)那些[^,。!?\n]{10,60}的[^,。!?\n]{2,30}[,。]"),
          re.compile(r"(?:^|[。!?]\s*)(?:真正|最终|最后)让[^,。!?\n]{8,70}的,是"),
      )
      
      METAPHOR_FIELDS = {
          "温度": ("降温", "升温", "冷却", "余温", "温度最高"),
          "生死战争": ("杀死", "死因", "枪响", "开火", "战场", "引爆", "弹药"),
          "建筑灾害": ("坍塌", "崩塌", "地基", "砖头", "支柱", "废墟"),
          "仓储租赁": ("仓库", "库房", "租金", "取货", "入库", "库存"),
          "道路竞赛": ("赛道", "跑道", "岔路", "十字路口", "终点线", "门票"),
          "机器器官": ("齿轮", "引擎", "发动机", "血管", "骨架", "肌肉"),
          "海洋航行": ("蓝海", "浪潮", "潮水", "航船", "灯塔", "彼岸"),
      }
      
      
      @dataclass
      class Paragraph:
          position: int
          text: str
          han: int
          sentences: int
      
      
      def han_count(text: str) -> int:
          return len(re.findall(r"[\u4e00-\u9fff]", text))
      
      
      def line_number(text: str, position: int) -> int:
          return text.count("\n", 0, position) + 1
      
      
      def excerpt(value: str, width: int = 72) -> str:
          value = re.sub(r"\s+", " ", value).strip()
          return value if len(value) <= width else value[: width - 1] + "…"
      
      
      def mask_non_prose(text: str) -> str:
          """屏蔽代码、网址和机器元数据,同时保留字符位置与换行。"""
      
          def mask(match: re.Match[str]) -> str:
              return "".join("\n" if char == "\n" else " " for char in match.group())
      
          patterns = (
              re.compile(r"\A---\s*\n.*?\n---\s*(?:\n|\Z)", re.DOTALL),
              re.compile(r"```.*?```", re.DOTALL),
              re.compile(r"`[^`\n]*`"),
              re.compile(r"\]\([^\n)]*\)"),
              re.compile(r"https?://[^\s)>]+"),
              re.compile(r"<[^>\n]+>"),
          )
          masked = text
          for pattern in patterns:
              masked = pattern.sub(mask, masked)
          return masked
      
      
      def non_overlapping_terms(text: str, terms: tuple[str, ...]):
          matches = []
          occupied = []
          for term in sorted(terms, key=len, reverse=True):
              for match in re.finditer(re.escape(term), text):
                  start, end = match.span()
                  if any(start < old_end and end > old_start for old_start, old_end in occupied):
                      continue
                  matches.append((start, term))
                  occupied.append((start, end))
          return sorted(matches)
      
      
      def all_matches(text: str, patterns: tuple[re.Pattern[str], ...]):
          matches = []
          for pattern in patterns:
              matches.extend(pattern.finditer(text))
          return sorted(matches, key=lambda match: match.start())
      
      
      def heavy_de_sentences(text: str):
          """找出主干可能被多个“的”压到后面的长句。"""
      
          matches = []
          pattern = re.compile(r"[^。!?!?\n]+(?:[。!?!?]|$)")
          for match in pattern.finditer(text):
              value = match.group()
              if han_count(value) >= 38 and value.count("的") >= 4:
                  matches.append(match)
          return matches
      
      
      def anaphora_runs(text: str, minimum: int = 3):
          """找出同一句里三个以上小句用同一个开头的排比。"""
      
          matches = []
          for sentence in re.finditer(r"[^。!?!?\n]+(?:[。!?!?]|$)", text):
              clauses = [
                  clause.strip()
                  for clause in re.split(r"[,、;,;]", sentence.group())
                  if han_count(clause) >= 3
              ]
              if len(clauses) < minimum:
                  continue
              run = 1
              for previous, current in zip(clauses, clauses[1:]):
                  if previous[:2] == current[:2] and re.match(r"[一-鿿]{2}", current):
                      run += 1
                      if run >= minimum:
                          matches.append(sentence)
                          break
                  else:
                      run = 1
          return matches
      
      
      def sentence_length_cv(text: str):
          """句长变异系数。人写的长短句差距大,模型的句长彼此接近。"""
      
          lengths = [
              han_count(match.group())
              for match in re.finditer(r"[^。!?!?\n]+[。!?!?]", text)
              if han_count(match.group()) >= 4
          ]
          if len(lengths) < 12:
              return None
          mean = sum(lengths) / len(lengths)
          if mean == 0:
              return None
          variance = sum((value - mean) ** 2 for value in lengths) / len(lengths)
          return (variance ** 0.5) / mean, len(lengths)
      
      
      def bracket_highlights(text: str):
          """「」括起来的短语。太密说明在批量造金句。"""
      
          return list(re.finditer(r"[「『][^」』\n]{1,6}[」』]", text))
      
      
      def prose_paragraphs(text: str) -> list[Paragraph]:
          paragraphs = []
          cursor = 0
          for block in re.split(r"\n\s*\n", text):
              position = text.find(block, cursor)
              cursor = max(position + len(block), cursor)
              clean = re.sub(r"[>*_`]", "", block).strip()
              if not clean or clean.startswith(("#", "http", "![", "```")):
                  continue
              if re.match(r"^(?:[-+*]|\d+[.、])\s", clean):
                  continue
              count = han_count(clean)
              if count < 4:
                  continue
              sentences = max(1, len(re.findall(r"[。!?!?]", clean)))
              paragraphs.append(Paragraph(position, clean, count, sentences))
          return paragraphs
      
      
      def metaphor_cluster(text: str, distance: int = 800):
          hits = []
          for field, words in METAPHOR_FIELDS.items():
              for word in words:
                  for match in re.finditer(re.escape(word), text):
                      hits.append((match.start(), field, word))
          hits.sort()
          for index, (start, _, _) in enumerate(hits):
              window = [hit for hit in hits[index:] if hit[0] - start <= distance]
              fields = {hit[1] for hit in window}
              if len(fields) >= 3:
                  return window, fields
          return None
      
      
      def short_streak(paragraphs: list[Paragraph], limit: int = 4):
          streak = []
          for paragraph in paragraphs:
              if paragraph.han <= 24 and paragraph.sentences <= 1:
                  streak.append(paragraph)
                  if len(streak) >= limit:
                      return streak
              else:
                  streak = []
          return None
      
      
      def opener_counts(paragraphs: list[Paragraph]):
          counts = collections.Counter()
          examples = {}
          for paragraph in paragraphs:
              value = paragraph.text.lstrip("“‘\"((")
              for opener in REPEATED_OPENERS:
                  if value.startswith(opener):
                      counts[opener] += 1
                      examples.setdefault(opener, paragraph.position)
                      break
          return counts, examples
      
      
      def read_text(path: str) -> str:
          if path == "-":
              return sys.stdin.read()
          return Path(path).read_text(encoding="utf-8")
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description="检查中文成稿的硬禁令与模型化形状")
          parser.add_argument("path", help="Markdown 或文本文件路径。使用 - 从标准输入读取")
          parser.add_argument("--whitelist", type=str, default=None, help="Path to whitelist file. Each line is a regex pattern to strip from analysis.")
          args = parser.parse_args()
          try:
              text = read_text(args.path)
          except (OSError, UnicodeError) as error:
              print(f"无法读取稿件。{error}", file=sys.stderr)
              return 2
      
          if args.whitelist:
              import re as _re
              try:
                  wl_text = Path(args.whitelist).read_text(encoding="utf-8")
                  wl_patterns = [line.strip() for line in wl_text.splitlines() if line.strip() and not line.startswith("#")]
                  for pattern in wl_patterns:
                      if pattern in (".*", ".+", ".*?", ".+?"):
                          print("WARNING: whitelist pattern is too broad, this will clear the entire text.", file=sys.stderr)
                      try:
                          text = _re.sub(pattern, "", text)
                      except _re.error as e:
                          print(f"WARNING: invalid whitelist regex '{pattern}': {e}", file=sys.stderr)
              except (OSError, UnicodeError) as e:
                  print(f"WARNING: cannot read whitelist file: {e}", file=sys.stderr)
      
          prose = mask_non_prose(text)
          total_han = han_count(prose)
          if total_han == 0:
              print("没有检测到汉字。", file=sys.stderr)
              return 2
      
          failures = []
          warnings = []
      
          quote_colons = []
          for symbol, label in FORBIDDEN_PUNCTUATION.items():
              matches = list(re.finditer(re.escape(symbol), prose))
              if symbol in (":", ":"):
                  hard = []
                  for match in matches:
                      tail = prose[match.end() : match.end() + 2].lstrip()
                      if tail[:1] in ("「", "『", "“", "‘", '"'):
                          quote_colons.append(match)
                      else:
                          hard.append(match)
                  matches = hard
              if matches:
                  lines = "、".join(str(line_number(text, match.start())) for match in matches[:8])
                  failures.append(f"{label}共 {len(matches)} 处,出现在第 {lines} 行。")
          if quote_colons:
              lines = "、".join(
                  str(line_number(text, match.start())) for match in quote_colons[:8]
              )
              warnings.append(
                  f"引出原话的冒号 {len(quote_colons)} 处,第 {lines} 行。确认引号里确实是原话,且不是提示性用法。"
              )
      
          stop_matches = non_overlapping_terms(prose, HARD_STOPS)
          for position, phrase in stop_matches:
              failures.append(f"硬停词,第 {line_number(text, position)} 行,{phrase}")
      
          jargon_matches = non_overlapping_terms(prose, HARD_JARGON)
          for position, phrase in jargon_matches:
              failures.append(f"黑话,第 {line_number(text, position)} 行,{phrase}")
      
          context_jargon_matches = non_overlapping_terms(prose, CONTEXT_JARGON)
          hard_spans = [
              (position, position + len(phrase)) for position, phrase in jargon_matches
          ]
          context_jargon_matches = [
              (position, phrase)
              for position, phrase in context_jargon_matches
              if not any(
                  position < end and position + len(phrase) > start
                  for start, end in hard_spans
              )
          ]
          if context_jargon_matches:
              samples = "、".join(
                  dict.fromkeys(phrase for _, phrase in context_jargon_matches)
              )
              lines = "、".join(
                  dict.fromkeys(
                      str(line_number(text, position))
                      for position, _ in context_jargon_matches[:8]
                  )
              )
              warnings.append(
                  f"有 {len(context_jargon_matches)} 处词语需要结合语境判断。"
                  f"第 {lines} 行出现 {samples}。本义准确时保留,用来抬价时改写。"
              )
      
          road_signs = all_matches(prose, ROAD_SIGN_PATTERNS)
          for match in road_signs:
              failures.append(
                  f"模型路标,第 {line_number(text, match.start())} 行,"
                  f"“{excerpt(match.group().lstrip(ROAD_STRIP_CHARS))}”"
              )
      
          pivots = all_matches(prose, PIVOT_PATTERNS)
          for match in pivots:
              failures.append(
                  f"禁用翻案句,第 {line_number(text, match.start())} 行,"
                  f"“{excerpt(match.group())}”"
              )
      
          occupied_spans = [match.span() for match in pivots]
          semantic_pivots = []
          for match in all_matches(prose, SEMANTIC_PIVOT_PATTERNS):
              if any(
                  match.start() < end and match.end() > start
                  for start, end in occupied_spans
              ):
                  continue
              semantic_pivots.append(match)
              occupied_spans.append(match.span())
          for match in semantic_pivots:
              warnings.append(
                  f"疑似翻案腔变形,第 {line_number(text, match.start())} 行,"
                  f"“{excerpt(match.group(), 44)}”。先立误解再推翻就改成正面陈述,正常用法保留。"
              )
      
          anaphoras = anaphora_runs(prose)
          for match in anaphoras[:4]:
              warnings.append(
                  f"三连以上同构排比,第 {line_number(text, match.start())} 行,"
                  f"“{excerpt(match.group(), 44)}”。留两项,第三项换说法或删掉。"
              )
      
          lyric_matches = non_overlapping_terms(prose, LYRIC_WORDS)
          if len(lyric_matches) >= 2:
              samples = "、".join(dict.fromkeys(term for _, term in lyric_matches))
              warnings.append(
                  f"模型偏爱的抒情词 {len(lyric_matches)} 处。{samples}。"
                  "写具体事物时保留,给抽象概念穿衣服时删掉。"
              )
      
          nominalizations = all_matches(prose, NOMINALIZATION_PATTERNS)
          for match in nominalizations[:4]:
              warnings.append(
                  f"名词化句式,第 {line_number(text, match.start())} 行,"
                  f"“{excerpt(match.group(), 36)}”。还原成直接的动词。"
              )
      
          conjunction_hits = non_overlapping_terms(prose, CONJUNCTIONS)
          if total_han >= 600 and len(conjunction_hits) * 1000 / total_han > 7:
              samples = "、".join(
                  f"{term} {count} 次"
                  for term, count in collections.Counter(
                      term for _, term in conjunction_hits
                  ).most_common(4)
              )
              warnings.append(
                  f"连词密度偏高,每千字 {len(conjunction_hits) * 1000 // total_han} 个。{samples}。"
                  "中文小句靠语序和事理相接,删掉一半试试。"
              )
      
          highlights = bracket_highlights(prose)
          highlight_limit = max(3, total_han // 700)
          if len(highlights) > highlight_limit:
              samples = "、".join(dict.fromkeys(match.group() for match in highlights[:6]))
              warnings.append(
                  f"「」括起的短语共 {len(highlights)} 处。{samples}。太密说明在批量造金句。"
              )
      
          cv_result = sentence_length_cv(prose)
          if cv_result and cv_result[0] < 0.42:
              warnings.append(
                  f"全文 {cv_result[1]} 个句子长度过于接近(变异系数 {cv_result[0]:.2f})。"
                  "人写的段落里十个字的句子会挨着四十个字的句子,放开几句,压短几句。"
              )
      
          marker_matches = non_overlapping_terms(prose, SOFT_MARKERS)
          marker_limit = max(2, total_han // 900)
          if len(marker_matches) > marker_limit:
              samples = "、".join(dict.fromkeys(term for _, term in marker_matches))
              warnings.append(
                  f"洞察路标共 {len(marker_matches)} 处,当前提醒线为 {marker_limit} 处。"
                  f"重点检查 {samples}。"
              )
      
          left_branches = all_matches(prose, LEFT_BRANCH_PATTERNS)
          left_limit = max(2, total_han // 1200)
          if len(left_branches) > left_limit:
              samples = ";".join(
                  f"第 {line_number(text, match.start())} 行“{excerpt(match.group(), 44)}”"
                  for match in left_branches[:4]
              )
              warnings.append(
                  f"长前置成分共 {len(left_branches)} 处,可能让主干来得太晚。{samples}"
              )
      
          dense_de = heavy_de_sentences(prose)
          dense_de_limit = max(1, total_han // 1500)
          if len(dense_de) > dense_de_limit:
              samples = ";".join(
                  f"第 {line_number(text, match.start())} 行“{excerpt(match.group(), 44)}”"
                  for match in dense_de[:4]
              )
              warnings.append(
                  f"有 {len(dense_de)} 个长句包含四个以上的“的”,可能要先交代人和动作。{samples}"
              )
      
          paragraphs = prose_paragraphs(prose)
          if len(paragraphs) >= 10:
              one_sentence = sum(paragraph.sentences <= 1 for paragraph in paragraphs)
              ratio = one_sentence / len(paragraphs)
              if ratio >= 0.75:
                  warnings.append(
                      f"可识别段落中有 {ratio:.0%} 只有一句话,可能形成统一的短段鼓点。"
                  )
      
          streak = short_streak(paragraphs)
          if streak:
              first = streak[0]
              warnings.append(
                  f"从第 {line_number(text, first.position)} 行起连续出现 {len(streak)} 个短促单句段,"
                  "检查是否在排队喊结论。"
              )
      
          counts, opener_examples = opener_counts(paragraphs)
          repeated = [(opener, count) for opener, count in counts.items() if count >= 4]
          if repeated:
              details = "、".join(f"{opener} {count} 次" for opener, count in repeated)
              first_position = min(opener_examples[opener] for opener, _ in repeated)
              warnings.append(
                  f"段落开场重复,从第 {line_number(text, first_position)} 行附近开始。{details}。"
              )
      
          metaphors = metaphor_cluster(prose)
          if metaphors:
              window, fields = metaphors
              samples = "、".join(dict.fromkeys(hit[2] for hit in window))
              warnings.append(
                  f"八百字内出现 {len(fields)} 套借喻。{'、'.join(sorted(fields))}。"
                  f"例词有 {samples}。"
              )
      
          print(f"汉字数 {total_han}")
          print(
              f"翻案句 {len(pivots)},翻案腔变形 {len(semantic_pivots)},"
              f"同构排比 {len(anaphoras)},名词化 {len(nominalizations)},"
              f"黑话 {len(jargon_matches)},硬停词 {len(stop_matches)},"
              f"模型路标 {len(road_signs)},需辨语境词 {len(context_jargon_matches)},"
              f"抒情词 {len(lyric_matches)},洞察路标 {len(marker_matches)},"
              f"长前置成分 {len(left_branches)},重定语句 {len(dense_de)}"
          )
      
          if failures:
              print("\n需要修改")
              for item in failures:
                  print(f"- {item}")
      
          if warnings:
              print("\n需要人工判断")
              for item in warnings:
                  print(f"- {item}")
      
          if not failures and not warnings:
              print("\n未发现这份检查器覆盖的问题。")
      
          return 1 if failures else 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
    • tts_yunxi.py 13.8 KB
      #!/usr/bin/env python3
      """Synthesize Chinese narration via edge-tts (multi-voice, default 真·云希).
      
      Usage:
        python tts_yunxi.py <script.txt> --out work/audio [--voice yunxi]
                             [--rate +8%] [--volume +0%] [--pitch +0Hz]
                             [--max-chars 700]
      
      Voice presets (--voice takes a preset name or any full edge-tts voice id):
        yunxi     zh-CN-YunxiNeural              male, sunny (default, brand voice)
        xiaoxiao  zh-CN-XiaoxiaoNeural           female, warm
        xiaoyi    zh-CN-XiaoyiNeural             female, lively
        yunjian   zh-CN-YunjianNeural            male, passionate
        yunyang   zh-CN-YunyangNeural            male, news anchor
        yunxia    zh-CN-YunxiaNeural             male, youthful
        xiaobei   zh-CN-liaoning-XiaobeiNeural   female, Northeastern dialect
        xiaoni    zh-CN-shaanxi-XiaoniNeural     female, Shaanxi dialect
      
      Input: UTF-8 text file. Blank-line separated paragraphs are preserved as
      groups. Mid-sentence line breaks are joined (no artificial pause). Segments
      are split ONLY at sentence endings (。!?…), so the narration never pauses or
      stutters in the middle of a sentence.
      
      Output:
        segment-XXX.mp3   one file per sentence group
        narration.mp3     concatenated full narration
        segments.json     per-segment start/duration + total (for timeline math)
        sentences.json    absolute sentence-level timings from edge-tts (caption sync)
      """
      
      from __future__ import annotations
      
      import argparse
      import asyncio
      import json
      import os
      import re
      import shutil
      import subprocess
      import sys
      from pathlib import Path
      
      SENTENCE_END = "。!?…!?"
      
      
      def find_bin(name: str) -> Path | None:
          """Locate ffmpeg/ffprobe: PATH, env override, then common WinGet locations."""
          which = shutil.which(name)
          if which:
              return Path(which)
          env_dir = os.environ.get("HYPERFRAMES_FFMPEG_DIR") or os.environ.get("FFMPEG_BIN")
          if env_dir:
              candidate = Path(env_dir) / (name + ".exe")
              if candidate.exists():
                  return candidate
          localappdata = os.environ.get("LOCALAPPDATA", "")
          winget = Path(localappdata) / "Microsoft" / "WinGet" / "Packages"
          if winget.exists():
              for sub in sorted(winget.glob("Gyan.FFmpeg*/ffmpeg-*/bin")):
                  candidate = sub / (name + ".exe")
                  if candidate.exists():
                      return candidate
          return None
      
      
      
      async def synth(
          text: str, out_path: Path, voice: str, rate: str, volume: str, pitch: str
      ) -> list[dict]:
          """Synthesize one segment; return sentence boundaries relative to this segment."""
          last_error: Exception | None = None
          for attempt in range(2):
              try:
                  comm = _get_edge_tts().Communicate(text, voice, rate=rate, volume=volume, pitch=pitch)
                  sentences: list[dict] = []
                  with open(out_path, "wb") as audio_file:
                      async for chunk in comm.stream():
                          if chunk["type"] == "audio":
                              audio_file.write(chunk["data"])
                          elif chunk["type"] == "SentenceBoundary":
                              start_s = chunk["offset"] / 10_000_000
                              end_s = start_s + chunk["duration"] / 10_000_000
                              sentences.append(
                                  {
                                      "start": round(start_s, 3),
                                      "end": round(end_s, 3),
                                      "text": chunk["text"],
                                  }
                              )
                  if not out_path.exists() or out_path.stat().st_size == 0:
                      raise RuntimeError("edge-tts produced empty audio")
                  return sentences
              except Exception as exc:  # network hiccups are common; retry once
                  last_error = exc
                  await asyncio.sleep(1)
          raise RuntimeError(f"edge-tts failed for {out_path.name}: {last_error}")
      
      
      def duration_seconds(path: Path, text: str) -> tuple[float, str]:
          """Return (duration, source). source is ffprobe, mutagen, or estimate."""
          ffprobe = find_bin("ffprobe")
          if ffprobe:
              try:
                  result = subprocess.run(
                      [
                          str(ffprobe), "-v", "error", "-show_entries", "format=duration",
                          "-of", "default=noprint_wrappers=1:nokey=1", str(path),
                      ],
                      capture_output=True,
                      text=True,
                      check=True,
                  )
                  return round(float(result.stdout.strip()), 3), "ffprobe"
              except (subprocess.SubprocessError, ValueError):
                  pass
          try:
              from mutagen.mp3 import MP3
      
              return round(float(MP3(str(path)).info.length), 3), "mutagen"
          except ImportError:
              pass
          except Exception:
              pass
          estimate = max(0.5, round(len(text) / 3.5, 3))
          print(f"warning: no ffprobe/mutagen, duration estimated for {path.name}", file=sys.stderr)
          return estimate, "estimate"
      
      
      def concat_mp3(segments: list[Path], out_path: Path) -> bool:
          ffmpeg = find_bin("ffmpeg")
          if ffmpeg is None:
              print(
                  "warning: ffmpeg not found, skipping narration.mp3 concat; "
                  "use segment files individually or install FFmpeg",
                  file=sys.stderr,
              )
              return False
          list_file = out_path.with_suffix(".list.txt")
          list_file.write_text(
              "".join(f"file '{s.resolve().as_posix()}'\n" for s in segments),
              encoding="utf-8",
          )
          subprocess.run(
              [
                  str(ffmpeg), "-y", "-f", "concat", "-safe", "0", "-i", str(list_file),
                  "-c", "copy", str(out_path),
              ],
              check=True,
              capture_output=True,
          )
          return True
      
      
      MARKDOWN_PREFIX = re.compile(r"^\s*(#{1,6}\s*|[-*+]\s+|\d+[.、]\s*|>\s*)")
      
      
      def strip_markdown(line: str) -> str | None:
          """Strip markdown artifacts; return None for structural heading lines (not spoken)."""
          if re.match(r"^\s*#{1,6}\s+", line):
              return None
          line = MARKDOWN_PREFIX.sub("", line)
          for token in ("**", "__", "`", "~~"):
              line = line.replace(token, "")
          return line.strip() or None
      
      
      
      _edge_tts = None
      
      
      def _get_edge_tts():
          """Lazy import edge_tts so the module can be imported without the dependency."""
          global _edge_tts
          if _edge_tts is None:
              import edge_tts
              _edge_tts = edge_tts
          return _edge_tts
      
      
      def normalize_paragraph(paragraph: str) -> str:
          """Strip markdown, join mid-sentence line breaks, ensure sentence-ending punctuation."""
          lines = [stripped for line in paragraph.split("\n") if (stripped := strip_markdown(line))]
          if not lines:
              return ""
          joined = lines[0]
          for line in lines[1:]:
              joined += line  # Chinese needs no space; removes mid-sentence line-break pauses
          joined = re.sub(r"\s+", " ", joined).strip()
          if joined and joined[-1] not in SENTENCE_END:
              joined += "。"
          return joined
      
      
      def split_sentences(text: str) -> list[str]:
          parts = re.split(r"(?<=[。!?…!?])", text)
          return [part.strip() for part in parts if part.strip()]
      
      
      def build_segments(paragraphs: list[str], max_chars: int) -> list[tuple[int, str]]:
          """Group sentences into segments; boundaries only at paragraph or sentence ends."""
          segments: list[tuple[int, str]] = []
          for paragraph_index, paragraph in enumerate(paragraphs, start=1):
              buffer = ""
              for sentence in split_sentences(paragraph):
                  if buffer and len(buffer) + len(sentence) > max_chars:
                      segments.append((paragraph_index, buffer))
                      buffer = ""
                  buffer += sentence
              if buffer:
                  segments.append((paragraph_index, buffer))
          return segments
      
      
      # 中文音色预设(实测于 edge-tts --list-voices,2026-08-28;--voice 也接受完整音色 id)
      VOICE_PRESETS = {
          "yunxi": "zh-CN-YunxiNeural",  # 男 · 阳光(默认,试界品牌音色)
          "xiaoxiao": "zh-CN-XiaoxiaoNeural",  # 女 · 温暖(新闻/小说)
          "xiaoyi": "zh-CN-XiaoyiNeural",  # 女 · 活泼
          "yunjian": "zh-CN-YunjianNeural",  # 男 · 浑厚激情(体育/小说)
          "yunyang": "zh-CN-YunyangNeural",  # 男 · 新闻播报,专业沉稳
          "yunxia": "zh-CN-YunxiaNeural",  # 男 · 少年感
          "xiaobei": "zh-CN-liaoning-XiaobeiNeural",  # 女 · 东北方言
          "xiaoni": "zh-CN-shaanxi-XiaoniNeural",  # 女 · 陕西方言
      }
      DEFAULT_VOICE = "yunxi"
      
      
      def main() -> int:
          parser = argparse.ArgumentParser(description="Chinese narration pipeline (default 真·云希, multi-voice)")
          parser.add_argument("script", type=Path, help="UTF-8 script file (.txt/.md)")
          parser.add_argument("--out", type=Path, default=Path("work/audio"), help="output directory")
          parser.add_argument(
              "--voice", default=None,
              help="voice preset (yunxi/xiaoxiao/xiaoyi/yunjian/yunyang/yunxia/xiaobei/xiaoni) "
                   "or a full edge-tts voice id; overrides --theme",
          )
          parser.add_argument(
              "--theme", type=Path, default=None,
              help="theme JSON file; its voice.preset is used when --voice is omitted",
          )
          parser.add_argument("--rate", default="+8%", help="speech rate, e.g. +5%% / +8%% / +15%%")
          parser.add_argument("--volume", default="+0%", help="volume, e.g. +0%%")
          parser.add_argument("--pitch", default="+0Hz", help="pitch, e.g. +0Hz")
          parser.add_argument("--max-chars", type=int, default=700, help="max chars per segment")
          args = parser.parse_args()
      
          theme_preset = None
          if args.theme is not None:
              if not args.theme.exists():
                  print(f"error: theme file not found: {args.theme}", file=sys.stderr)
                  return 1
              try:
                  cfg = json.loads(args.theme.read_text(encoding="utf-8"))
              except (OSError, ValueError) as exc:
                  print(f"error: cannot read theme file: {exc}", file=sys.stderr)
                  return 1
              theme_voice = cfg.get("voice") or {}
              theme_preset = theme_voice.get("preset")
              engine = theme_voice.get("engine")
              if engine and engine != "edge-tts":
                  print(
                      f"warning: theme requests engine '{engine}' but this script only supports "
                      "edge-tts; continuing with edge-tts.",
                      file=sys.stderr,
                  )
      
          voice_name = args.voice or theme_preset or DEFAULT_VOICE
          voice = VOICE_PRESETS.get(voice_name, voice_name)
          if "-" not in voice:
              print(
                  f"error: unknown voice '{voice_name}'. Available presets: "
                  f"{'/'.join(VOICE_PRESETS)}; or pass a full edge-tts voice id "
                  "(e.g. zh-CN-XiaoxiaoNeural).",
                  file=sys.stderr,
              )
              return 1
          print(f"voice: {voice}")
      
          if not args.script.exists():
              print(f"error: script file not found: {args.script}", file=sys.stderr)
              return 1
      
          args.out.mkdir(parents=True, exist_ok=True)
          raw_text = args.script.read_text(encoding="utf-8")
          paragraphs = [
              normalize_paragraph(p)
              for p in raw_text.replace("\r\n", "\n").split("\n\n")
              if p.strip()
          ]
          if not paragraphs:
              print("error: no paragraphs found (separate paragraphs with blank lines)", file=sys.stderr)
              return 1
      
          segments = build_segments(paragraphs, args.max_chars)
          segments_meta: list[dict] = []
          all_sentences: list[dict] = []
          mp3s: list[Path] = []
          offset = 0.0
      
          for segment_index, (paragraph_index, text) in enumerate(segments, start=1):
              mp3 = args.out / f"segment-{segment_index:03d}.mp3"
              print(f"[{segment_index}/{len(segments)}] synthesizing {len(text)} chars ...")
              sentences = asyncio.run(
                  synth(text, mp3, voice, args.rate, args.volume, args.pitch)
              )
              duration, duration_source = duration_seconds(mp3, text)
              segments_meta.append(
                  {
                      "index": segment_index,
                      "paragraph": paragraph_index,
                      "file": mp3.name,
                      "start": round(offset, 3),
                      "duration": duration,
                      "durationSource": duration_source,
                      "text": text,
                  }
              )
              for sentence in sentences:
                  all_sentences.append(
                      {
                          "segment": segment_index,
                          "start": round(sentence["start"] + offset, 3),
                          "end": round(sentence["end"] + offset, 3),
                          "text": sentence["text"],
                      }
                  )
              offset += duration
              mp3s.append(mp3)
      
          # edge-tts sentence boundaries can slightly overlap; clamp to avoid caption collisions.
          normalized_sentences: list[dict] = []
          last_end = 0.0
          for sentence in all_sentences:
              sentence["start"] = round(max(sentence["start"], last_end), 3)
              sentence["end"] = round(max(sentence["end"], sentence["start"]), 3)
              last_end = sentence["end"]
              normalized_sentences.append(sentence)
      
          narration = args.out / "narration.mp3"
          concatenated = concat_mp3(mp3s, narration)
      
          (args.out / "segments.json").write_text(
              json.dumps(
                  {
                      "segments": segments_meta,
                      "totalDuration": round(offset, 3),
                      "narrationConcat": concatenated,
                  },
                  ensure_ascii=False,
                  indent=2,
              ),
              encoding="utf-8",
          )
          (args.out / "sentences.json").write_text(
              json.dumps(
                  {
                      "sentences": normalized_sentences,
                      "totalDuration": round(offset, 3),
                  },
                  ensure_ascii=False,
                  indent=2,
              ),
              encoding="utf-8",
          )
          print(json.dumps(
              {
                  "segments": len(segments_meta),
                  "totalDuration": round(offset, 3),
                  "narration": str(narration) if concatenated else None,
                  "warning": None if concatenated else "ffmpeg not found; narration.mp3 not concatenated",
              },
              ensure_ascii=False,
          ))
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
    • verify_output.py 7.7 KB
      #!/usr/bin/env python3
      """交付核验硬门禁:verify_output.py --dir <outputs 目录>
      
      逐项检查交付物完整性,任何一项不通过则 exit 1(不交付)。
      检查项:主视频存在且有音轨、时长>0 且与字幕时间轴一致、双封面存在且尺寸正确、
      titles.txt 非空、发布计划.txt 存在、字幕时间轴存在。
      """
      import argparse
      import json
      import os
      import shutil
      import subprocess
      import sys
      from pathlib import Path
      
      
      def is_within_directory(path: Path, directory: Path) -> bool:
          """Return whether path resolves inside directory, including the directory itself."""
          try:
              path.resolve().relative_to(directory.resolve())
              return True
          except ValueError:
              return False
      
      
      def find_bin(name: str) -> str | None:
          """Locate ffmpeg/ffprobe: PATH first, then env override, then WinGet locations.
      
          Kept in sync with tts_yunxi.find_bin so both scripts resolve the same binaries.
          """
          which = shutil.which(name)
          if which:
              return which
          env_dir = os.environ.get("HYPERFRAMES_FFMPEG_DIR") or os.environ.get("FFMPEG_BIN")
          if env_dir:
              candidate = Path(env_dir) / (name + ".exe")
              if candidate.exists():
                  return str(candidate)
          localappdata = os.environ.get("LOCALAPPDATA", "")
          winget = Path(localappdata) / "Microsoft" / "WinGet" / "Packages"
          if winget.exists():
              for sub in sorted(winget.glob("Gyan.FFmpeg*/ffmpeg-*/bin")):
                  candidate = sub / (name + ".exe")
                  if candidate.exists():
                      return str(candidate)
          return None
      
      
      def ffprobe(path: Path, args: list[str]) -> str:
          binary = find_bin("ffprobe")
          if not binary:
              print("error: ffprobe 不可用(请安装 FFmpeg 并加入 PATH,或设置 HYPERFRAMES_FFMPEG_DIR / FFMPEG_BIN),无法执行核验。")
              sys.exit(2)
          try:
              r = subprocess.run(
                  [binary, "-v", "error", *args, "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
                  capture_output=True, text=True,
              )
          except FileNotFoundError:
              print("error: ffprobe 不可用(请安装 FFmpeg 并加入 PATH,或设置 HYPERFRAMES_FFMPEG_DIR / FFMPEG_BIN),无法执行核验。")
              sys.exit(2)
          return r.stdout.strip() if r.returncode == 0 else ""
      
      
      
      def _cover_brightness(path: Path) -> float | None:
          """Read first frame of image and return mean brightness 0-255. None if tools unavailable."""
          try:
              from PIL import Image
              import statistics
              img = Image.open(path).convert("L").resize((64, 64))
              return round(statistics.mean(img.getdata()), 1)
          except ImportError:
              pass
          except Exception:
              pass
          import tempfile
          ffmpeg = find_bin("ffmpeg")
          if not ffmpeg:
              return None
          try:
              with tempfile.NamedTemporaryFile(suffix=".gray", delete=False) as tmp:
                  tmp_path = tmp.name
              subprocess.run([ffmpeg, "-y", "-i", str(path), "-vframes", "1",
                              "-f", "rawvideo", "-pix_fmt", "gray", tmp_path],
                             capture_output=True, check=True)
              data = Path(tmp_path).read_bytes()
              Path(tmp_path).unlink(missing_ok=True)
              if not data:
                  return None
              sample = data[::16]
              return round(sum(sample) / len(sample), 1)
          except (subprocess.SubprocessError, OSError):
              return None
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="交付核验硬门禁")
          ap.add_argument("--dir", type=Path, default=Path("outputs"), help="outputs 目录")
          ap.add_argument("--video", type=Path, default=None, help="主视频路径(outputs 中有多个 mp4 时用于指定)")
          args = ap.parse_args()
          out = args.dir
          failures: list[str] = []
      
          def check(ok: bool, label: str, detail: str = "") -> None:
              mark = "PASS" if ok else "FAIL"
              print(f"[{mark}] {label}" + (f"  ({detail})" if detail else ""))
              if not ok:
                  failures.append(label)
      
          # 1. 主视频唯一、有视频流与音轨
          videos = sorted(out.glob("*.mp4"))
          dur_f = 0.0
          video = args.video if args.video else (videos[0] if len(videos) == 1 else None)
          if video is None:
              if len(videos) > 1:
                  check(False, "outputs 中恰好一个主视频 mp4",
                        f"发现 {len(videos)} 个:{'、'.join(v.name for v in videos)};删除多余文件,或用 --video 指定主视频")
              else:
                  check(False, "主视频 *.mp4 存在")
          else:
              if not is_within_directory(video, out):
                  check(False, "主视频位于 outputs 目录内", str(video))
              elif not video.is_file():
                  check(False, "主视频存在(--video 指定的文件不存在)", str(video))
              else:
                  streams = ffprobe(video, ["-show_entries", "stream=codec_type"])
                  has_video = "video" in streams
                  has_audio = "audio" in streams
                  check(has_video, "主视频含视频流")
                  check(has_audio, "主视频含音轨", "配音缺失即静音成片,禁止交付")
                  dur = ffprobe(video, ["-show_entries", "format=duration"])
                  try:
                      dur_f = float(dur)
                  except ValueError:
                      dur_f = 0.0
                  check(dur_f > 1, "主视频时长 > 1s", f"{dur_f:.1f}s")
      
          # 2. 字幕时间轴与主视频时长一致(±1s)
          tl_candidates = [out / "字幕时间轴_sentences.json", out / "sentences.json"]
          tl = next((f for f in tl_candidates if f.exists()), tl_candidates[0])
          if tl.exists() and video:
              try:
                  total = json.loads(tl.read_text(encoding="utf-8")).get("totalDuration", 0)
                  check(abs(total - dur_f) <= 1.0, "时间轴与主视频时长对齐", f"{total:.1f}s vs {dur_f:.1f}s")
              except (ValueError, json.JSONDecodeError):
                  failures.append("时间轴 JSON 无法解析")
                  print("[FAIL] 时间轴 JSON 无法解析")
          else:
              check(tl.exists(), "字幕时间轴_sentences.json 存在")
      
          # 3. 双封面存在且尺寸正确(兼容文档约定 cover_* 与中文交付命名)
          cover_specs = [
              (("封面_横版4x3.png", "cover_4x3.png"), (1920, 1440), "横版封面"),
              (("封面_竖版3x4.png", "cover_3x4.png"), (1080, 1440), "竖版封面"),
          ]
          for names, size, label in cover_specs:
              f = next((out / name for name in names if (out / name).exists()), out / names[0])
              name = f.name
              if not f.exists():
                  check(False, f"{name} 存在")
                  continue
              dim = ffprobe(f, ["-select_streams", "v:0", "-show_entries", "stream=width,height"])
              nums = [int(x) for x in dim.split() if x.strip().isdigit()]
              ok = len(nums) >= 2 and (nums[0], nums[1]) == size
              check(ok, f"{name} 尺寸 {size[0]}x{size[1]}", "x".join(str(n) for n in nums[:2]))
              # 黑帧检测:封面第一帧不应全黑(gsap.from 竞态的兜底防线)
              brightness = _cover_brightness(f)
              if brightness is not None:
                  check(brightness > 8.0, f"{name} 非全黑(均值亮度 {brightness:.1f})",
                        "封面可能被截到透明帧(gsap.from 竞态);重新渲染封面" if brightness <= 8.0 else "")
      
          # 4. 标题与发布计划
          titles = out / "titles.txt"
          check(titles.exists() and titles.stat().st_size > 50, "titles.txt 存在且非空(3-5 个标题)")
          check((out / "发布计划.txt").exists(), "发布计划.txt 存在")
      
          print()
          if failures:
              print(f"核验未通过:{len(failures)} 项失败 → {', '.join(failures)}")
              print("不满足交付条件,禁止交付。")
              return 1
          print("核验全部通过,允许交付。")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • templates
    • cover-horizontal.html 3.2 KB · in bundle
  • themes
    • content-default.json 501 B
      {
        "domain": "AI",
        "audience": "对 AI 感兴趣的普通人",
        "topic_sources": ["aihot"],
        "topic_rules": "关注 AI 与普通人的交叉点,选题须能回答「所以呢」",
        "tone": "人话,不用术语堆砌,写给耳朵",
        "standard_length_chars": [2500, 2800],
        "short_length_seconds": [30, 60],
        "sign_off_key": "brand.sign_off",
        "platforms": ["小红书", "抖音", "B站", "微信视频号"],
        "topic_dedup_dir": "E:\\Codex口播视频",
        "writing_rules": "tryworld"
      }
      
    • content-test-brand.json 510 B
      {
        "domain": "效率工具",
        "audience": "经常用电脑工作但效率不高的职场人",
        "topic_sources": ["manual"],
        "topic_rules": "关注工具的隐藏用法和效率提升技巧,选题须能回答「用了能省多少时间」",
        "tone": "实用、具体、不绕弯子",
        "standard_length_chars": [1800, 2200],
        "short_length_seconds": [30, 45],
        "sign_off_key": "brand.sign_off",
        "platforms": ["B站", "抖音"],
        "topic_dedup_dir": "E:\\TestVideos",
        "writing_rules": "tryworld"
      }
      
    • paper-algorithm.json 1.8 KB
      {
        "theme": "paper-algorithm",
        "name": "纸上算法 Paper Algorithm",
        "description": "科学手稿 + 中文印刷传统。默认主题:纸面是舞台,墨迹是文字,朱红是重点,印章是签名。",
        "brand": {
          "platform_name": "试界TryWorld",
          "seal_text": "试界原创",
          "sign_off": "我是试界TryWorld,持续把 AI 讲清楚,让每个普通人都看得懂、用得上。觉得这条视频有用,点个赞,也欢迎关注,谢谢大家,下期见。",
          "cover_brand_line": "试界TryWorld · 栏目/期数"
        },
        "palette": {
          "background": "#F4EFE4",
          "text": "#1C1916",
          "accent": "#C0452F",
          "secondary": "#2E5E8C",
          "cover_background": "#12100D",
          "cover_card": "#221F1B"
        },
        "fonts": {
          "heading": "Noto Serif SC(思源宋体,900/700 字重)",
          "notes": "ZCOOL XiaoWei(小薇宋体)",
          "data": "JetBrains Mono / 等宽字体"
        },
        "assets": {
          "seal": "assets/seal.svg",
          "grain": "assets/paper-grain.svg"
        },
        "voice": {
          "engine": "edge-tts",
          "preset": "yunxi"
        },
        "motion_signatures": [
          "ink_drop 墨落纸(主入场)",
          "brush_write 笔写入(小元素/批注)",
          "seal_stamp 盖章(数字/结论/印章)"
        ],
        "transition": "ink_bleed 墨染过渡",
        "cover": {
          "style": "dark_poster 深墨海报(与视频浅纸面两套语言)",
          "background": "#12100D"
        },
        "publish_plan": {
          "title": "试界TryWorld · 平台发布计划",
          "platforms": [
            {
              "name": "小红书",
              "time": "中午 12:30"
            },
            {
              "name": "抖音",
              "time": "晚上 19:30"
            },
            {
              "name": "B站",
              "time": "晚上 20:30"
            },
            {
              "name": "微信视频号",
              "time": "晚上 20:30"
            }
          ]
        }
      }
      
    • test-brand.json 1.4 KB
      {
        "theme": "test-brand",
        "name": "Test Brand",
        "description": "Test theme for brand parameterization validation.",
        "brand": {
          "platform_name": "TestChannel",
          "seal_text": "测试原创",
          "sign_off": "我是TestChannel,每周拆解一个工具的隐藏用法。如果你也有效率困惑,点个赞,关注我不迷路,下期见。",
          "cover_brand_line": "TestChannel · 效率工具"
        },
        "palette": {
          "background": "#FAFAFA",
          "text": "#222222",
          "accent": "#0066CC",
          "secondary": "#888888",
          "cover_background": "#111111",
          "cover_card": "#222222"
        },
        "fonts": {
          "heading": "Noto Serif SC(思源宋体,900/700 字重)",
          "notes": "ZCOOL XiaoWei(小薇宋体)",
          "data": "JetBrains Mono / 等宽字体"
        },
        "assets": {
          "seal": "assets/seal.svg",
          "grain": "assets/paper-grain.svg"
        },
        "voice": {
          "engine": "edge-tts",
          "preset": "xiaoxiao"
        },
        "motion_signatures": [
          "ink_drop 墨落纸(主入场)",
          "brush_write 笔写入(小元素/批注)",
          "seal_stamp 盖章(数字/结论/印章)"
        ],
        "transition": "ink_bleed 墨染过渡",
        "cover": {
          "style": "dark_poster 深墨海报(与视频浅纸面两套语言)",
          "background": "#111111"
        },
        "publish_plan": {
          "title": "TestChannel · 平台发布计划",
          "platforms": [
            { "name": "B站", "time": "晚上 20:00" },
            { "name": "抖音", "time": "晚上 18:00" }
          ]
        }
      }
      
  • .gitignore 19 B · in bundle
  • LICENSE-MIT 1.5 KB · in bundle
  • NOTICE.md 1005 B
    # NOTICE
    
    本技能是 [TryWorld2026/paper-algorithm](https://github.com/TryWorld2026/paper-algorithm) 仓库的一部分,采用 **CC BY-SA 4.0** 许可。
    
    使用与二创时请保留本声明与上游链接,并同样以 CC BY-SA 4.0 发布衍生作品。
    
    本技能内的 `scripts/check_prose.py` 源自 [KKKKhazix/human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0(Copyright (c) 2026 Human Writing Skill contributors),按 **MIT License** 使用与改造,完整许可文本见 `LICENSE-MIT`。
    
    This skill is part of the [TryWorld2026/paper-algorithm](https://github.com/TryWorld2026/paper-algorithm) repository, licensed under **CC BY-SA 4.0**. Derivative works must retain this notice, credit the upstream, and be released under the same license. `scripts/check_prose.py` is derived from [KKKKhazix/human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0 (Copyright (c) 2026 Human Writing Skill contributors), used under the **MIT License** — see `LICENSE-MIT`.
    
  • README.md 5.6 KB
    # 试界-纸上算法 TryWorld-Paper Algorithm
    
    > 一页会动的算法笔记 —— 把 AI 知识口播稿变成风格统一的横屏视频、横竖封面与平台标题。
    
    ![风格](https://img.shields.io/badge/风格-纸上算法-C0452F)
    ![配音](https://img.shields.io/badge/配音-多音色·默认云希-2E5E8C)
    ![画幅](https://img.shields.io/badge/画幅-1920x1080-1C1916)
    ![状态](https://img.shields.io/badge/状态-已启用-8FAF8C)
    
    ## 这是什么
    
    `tryworld-paper`(试界-纸上算法 TryWorld-Paper Algorithm)是试界TryWorld 的视频生产 Skill:输入一篇中文 AI 口播稿(可附图片),自动产出:
    
    - **横屏主视频**(16:9,口播最长约 10 分钟)—— 纸上算法风格,真云希配音,全程烧录字幕;超长稿提炼核心精华压缩
    - **横版封面 + 竖版封面**(1920x1440 横 4:3 / 1080x1440 竖 3:4)—— 深墨海报风(与视频浅纸面两套视觉语言),标题 + 信息块,适配 B站/抖音/小红书
    - **3-5 个平台标题** —— 按 B站/抖音/小红书分别适配
    - 右上角全程常驻的"试界原创"防伪印章
    - 每期固定的默认结尾签名(试界TryWorld 标准收尾,口播+画面同步)
    
    ## 设计契约:纸上算法 Paper Algorithm
    
    设计源头:**科学手稿 + 中文印刷传统**。每一期视频都是一页"会动的算法笔记",纸面是舞台,墨迹是文字,朱红是重点,印章是签名。
    
    ### 色板(4 色锁定,不新增)
    
    | 色 | 值 | 用途 |
    |---|---|---|
    | 纸面 | `#F4EFE4` | 背景主色 |
    | 墨黑 | `#1C1916` | 主文字、线条 |
    | 朱红 | `#C0452F` | 唯一强调色:关键词、数字、印章 |
    | 墨水蓝 | `#2E5E8C` | 次级批注、图表线 |
    
    ### 字体
    
    - 主标题:思源宋体(Noto Serif SC)
    - 批注/引用:ZCOOL XiaoWei(默认);霞鹜文楷(LXGW WenKai)如需使用须自带 woff2
    - 数据/坐标:等宽字体(JetBrains Mono)
    
    ### 动效签名(全片统一)
    
    | 签名 | 感觉 | 缓动 |
    |---|---|---|
    | 墨落纸 | 主入场 | `power3.out` |
    | 笔写入 | 批注/小元素 | `sine.inOut` |
    | 盖章 | 数字/结论/印章 | `back.out(1.4)` |
    
    转场统一为**墨染过渡**:前场景被墨色浸染溶解,新场景从纸面浮现;章节间留纸面空场。
    
    ### 动态图表
    
    数据/对比/流程用风格统一的动态图表表达:数字滚动盖章、柱状墨迹增长、折线一笔描画、进度环、对比卡片;等宽数字 + 朱红强调 + 墨水蓝网格,GSAP/SVG 实现,避免大段静态文字配解说。
    
    ## 配音质量保障
    
    - **多音色**:默认云希(Azure YunxiNeural,试界品牌音色),`--voice` 可换预设(xiaoxiao 晓晓 / yunjian 云健 / yunyang 云扬 / xiaoyi 晓伊 / yunxia 云夏 / xiaobei 小贝东北话 / xiaoni 小妮陕西话),也接受完整 edge-tts 音色 id;本地合成,无需 API Key
    - **默认语速** +8%,可按内容微调 +5%~+15%
    - **防卡顿机制**:文本自动规整(清除 markdown、合并句中换行),按句切分合成——停顿只允许出现在句号/问号/感叹号处,句子中间永不硬断
    - 句级时间戳(`sentences.json`)直接驱动字幕同步,不依赖 whisper
    
    ## 工作流
    
    ```text
    口播稿 → 读稿理解 → 第一性原理优化 → 活人感改稿七遍 → 净化写作标记 → 硬禁项检查(check_prose.py 清零) → 用户确认优化稿 → 句级切分 → 云希配音 → 句级时间轴
       → 章节/场景规划 → HyperFrames 构图 → lint/validate/inspect --strict
       → 渲染前核验 → 渲染 16:9 主视频 → 横竖封面 → 平台标题 → 交付核验(verify_output.py)→ outputs/ 交付
    ```
    
    ## 使用方式
    
    在 Codex 会话中给出口播稿(纯文字或 .txt/.md,可附图片):
    
    ```text
    用 $tryworld-paper 把这篇口播稿做成视频,加上横竖封面和标题。
    ```
    
    可选输入:图片目录(AI 概念图 / 截图 / 图表,会自动"纸面化"处理)。
    
    ## 目录结构
    
    ```text
    tryworld-paper/
    ├── SKILL.md                    # 主说明:品牌契约、工作流、质量门禁
    ├── agents/
    │   └── openai.yaml             # UI 元数据
    ├── references/
    │   ├── style-system.md         # 视觉契约(色板/字体/动效/封面)
    │   ├── workflow.md             # 生产流程与命令
    │   └── titles.md               # 平台标题规则
    ├── scripts/
    │   ├── tts_yunxi.py            # 云希配音管线(句级切分 + 拼接 + 时间轴)
    │   ├── verify_output.py        # 交付核验硬门禁(音轨/时长/双封面/标题/发布计划)
    │   └── check_prose.py          # 活人感硬禁项检查(源自 human-writing v1.1.0,MIT)
    └── assets/
        ├── paper-grain.svg         # 纸纹叠加层
        └── seal.svg                # 朱红"试界原创"印章
    ```
    
    ## 环境要求
    
    - Node.js >= 22
    - FFmpeg(含 ffprobe,加入 PATH;`winget install Gyan.FFmpeg`)
    - Python 3.10+,`pip install edge-tts`
    - HyperFrames CLI(`npx hyperframes`)
    
    运行 `npx hyperframes doctor` 可一键检查环境。
    
    ## 防伪标识
    
    视频与封面统一携带:
    
    - 右上角全程常驻朱红**试界原创**印章(保护区,内容不得覆盖)
    - 不添加"试界TryWorld 纸上算法"文字水印,印章是唯一水印
    
    ## 许可
    
    本仓库采用知识共享 **署名-相同方式共享 4.0 国际(CC BY-SA 4.0)**。其中 `scripts/check_prose.py` 源自 [KKKKhazix/human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0(禁令上移到修辞动作级,检测器补盲区降误伤),按 **MIT License** 使用与改造,版权归原作者所有,完整声明见 [LICENSE-MIT](LICENSE-MIT)。
    
  • SKILL.md 13.7 KB
    ---
    name: tryworld-paper
    description: Create branded 试界TryWorld AI-knowledge videos in the fixed "Paper Algorithm" (纸上算法) style with HyperFrames. Input is a Chinese voiceover script and optional images; output is a 16:9 horizontal video with Azure YunxiNeural (真·云希) voiceover, word-synced captions, ink/paper animations and unified transitions, an always-visible anti-counterfeit red seal, plus horizontal (4:3) and vertical (3:4) cover images and platform-optimized titles for Bilibili/Douyin/Xiaohongshu. Brand values are theme-driven (themes/paper-algorithm.json by default; custom brand themes supported — see references/theme-guide.md). Use when the user provides an AI 科普/教程/内容解读/知识分享 script and wants a TryWorld/试界 branded video, asks for the 纸上算法/Paper Algorithm style, wants a consistent script-to-video pipeline with covers and titles, or wants this pipeline with their own brand theme.
    ---
    
    # TryWorld-Paper Algorithm(试界-纸上算法)
    
    按当前主题的品牌制作 AI 知识类视频(默认主题为试界TryWorld 的纸上算法)。所有产出必须遵守本文件与 `references/style-system.md` 锁定的设计系统,任何一条都不允许为了省事而让步。
    
    ## 流水线契约(不可配置)
    
    以下机制是这套工作流的价值本身,任何主题都必须遵守,动了不交付:
    
    - 优化稿必须经用户确认才能进入配音/构图/渲染(闸门);口播稿与平台标题必须过 `scripts/check_prose.py` 硬禁项清零
    - 字幕全程烧录在画面内并与配音同步;印章/标识右上角全程常驻(根层覆盖实现),是唯一水印,禁止文字水印;印章为保护区
    - 画幅:横屏 1920x1080;口播时长最长约 10 分钟(短可到抖音 30 秒),超长稿先提炼压缩
    - 交付物:主视频(烧录字幕)、横版封面(1920x1440,4:3)、竖版封面(1080x1440,3:4)、3-5 个平台标题、字幕/时间轴
    - **交付核验(硬性,最后一步)**:交付前必须运行 `python -X utf8 scripts/verify_output.py --dir <outputs目录>`,逐项核验主视频音轨、时长对齐、双封面尺寸、标题与发布计划——任何一项 FAIL 都不交付,修复后重跑直到全部 PASS。禁止跳过或仅凭肉眼判断。
    - 数据必须有来源;场景必须有入场动画与转场(除末场无退场)
    
    ## 品牌主题(从主题文件读取)
    
    平台名、色板、字体、印章、签名语、封面风格、动效签名、默认配音音色,全部定义在主题文件 **`themes/paper-algorithm.json`**(默认主题,试界TryWorld 品牌)。出片前先确认要用的主题(默认 `paper-algorithm`,用户说「用 <主题名> 主题」或要求换品牌时切换),品牌值一律以主题文件为准,不在本文件中写死。
    
    - 想用自己的品牌做频道:复制主题 JSON 并按 `references/theme-guide.md` 修改(可改色板/印章/签名/字体/音色等;流水线契约不可配置)
    - 默认主题的视觉细则见 `references/style-system.md`(纸上算法视觉系统的完整定义)
    
    配音默认音色来自主题文件:出片配音命令带 `--theme <主题文件>`,脚本读取主题 `voice.preset` 作为音色(默认主题为云希);`--voice <预设名>` 可覆盖(xiaoxiao 晓晓 / yunjian 云健 / yunyang 云扬 / xiaoyi 晓伊 / yunxia 云夏 / xiaobei 小贝东北话 / xiaoni 小妮陕西话),交互规则见「音色选择」章节。
    
    ## 启动前必读
    
    > **外部依赖**:渲染依赖 hyperframes 技能(`$hyperframes`,或 `npx hyperframes` CLI)与 Node.js >= 22;配音依赖 edge-tts(Python 3.10+)。`$hyperframes` 不在本仓库内,需另行安装;未安装时出片流程无法执行。
    
    1. 读 `themes/paper-algorithm.json`(或用户指定的主题文件)——确认本次出片的品牌值。
    2. 读 `references/style-system.md` —— 视觉契约。编写任何 HTML/CSS 前必须先读,并作为 hyperframes 流程中的 DESIGN.md 使用。
    3. 读 `references/workflow.md` —— 生产流程与命令,按顺序执行。
    4. 编写构图时遵循 hyperframes skill(`$hyperframes`)的全部规则。
    5. 生成标题前读 `references/titles.md`。
    
    ## 工作流
    
    1. **输入并通读理解**:口播稿(文本或 .txt/.md 文件)与可选图片。先通读全文,理解主题、受众、核心结论与章节结构;图片缺失时跳过图片场景,不允许降级风格。
    2. **优化并净化脚本**:按流量第一性原理优化口播稿(共鸣选题带流量、认可攒赞、槽点引评论、嘴替促转发、价值认同涨粉),再按"活人感改稿七遍"清模型腔与注水(看谁在说 → 检查推进/删注水 → 拆表演性中文 → 听中文节奏 → 清硬禁项 → 核现实 → 查结尾,固定签名保留),随后清除写作标记/结构标签(如"一、开场钩子"、"(插入截图)")——标记不得以原文出现在视频中(不朗读、不上字幕、不显示),按意图转化为实际表达。净化后正文与平台标题必须运行 `scripts/check_prose.py` 清零硬禁项。规则见 workflow.md。
    3. **交付优化稿并等待确认**:净化后正文先通过 `$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py`(硬禁项清零),再把优化后的口播稿完整展示给用户审阅,附简明优化说明(改了什么、为什么)、元素落点清单(钩子/干货/槽点/嘴替/价值收尾)与数据来源清单;**未获用户确认前,禁止进入配音/构图/渲染**。
    4. **分段**:按净化后口播时长分段,每章 40-90 秒;不足 1 分钟不分章;口播目标不超过约 10 分钟,超长稿先提炼核心精华压缩再分段;标记章节标题、关键词、数据点、图片提示。
    5. **配音**:`python "skills/tryworld-paper/scripts/tts_yunxi.py" <净化后的脚本> --out work/audio --theme "skills/tryworld-paper/themes/paper-algorithm.json"`(或用户指定的主题文件)生成配音、分段时间与合并音轨。
    6. **时间轴**:字幕时间轴来自 `work/audio/sentences.json`(edge-tts 句级时间戳,已带绝对时间);如需词级时间轴可用 `npx hyperframes transcribe`(依赖 whisper,可选)。
    7. **场景规划**:按章节规划场景与节奏(开场-讲解-数据-小结),数据/对比/流程优先规划为动态图表场景,避免大段静态文字;先声明节奏模式再写 HTML。
    8. **构图**:把 `assets/` 复制进项目;按 style-system.md 与 hyperframes 规则编写 16:9 主构图。每个场景必须有入场动画与转场;除末场外禁止退场动画。
    9. **检查**:`npx hyperframes lint`、`npx hyperframes validate`、`npx hyperframes inspect --strict` 全部通过——错误与警告清零,禁止元素重叠、文字溢出/出画布/截断等排版错误;封面构图同样核验。
    10. **渲染前核验**:渲染前必须按 workflow.md 的"渲染前核验清单"逐项对照本文件与 style-system.md 的全部要求,确认无误后才允许渲染;任一项不满足先修改再渲染,避免返工。
    11. **渲染**:`npx hyperframes render --fps 30 --quality high` 输出主视频(先 `--quality draft` 预览确认,再 high 出片)。
    12. **封面**:按 style-system.md 封面系统独立设计 4:3 与 3:4 静态构图(使用主题定义的封面视觉语言,与视频画面保持两套语言;禁止截取主视频画面),渲染后取帧为 PNG。
    13. **标题**:按 titles.md 生成 3-5 个候选,标注平台推荐与命中的增长原则(共鸣/认可/槽点/嘴替/价值认同);候选标题同守硬禁词(禁冒号/破折号/翻案句/黑话/模型路标),生成后自检或跑 `$env:USERPROFILE\.agents\skills\tryworld-paper\scripts\check_prose.py` 清零。
    14. **交付**:主视频(烧录字幕)、横竖封面、标题、字幕文件统一放入 `outputs/`。
    
    ## 质量门禁(不通过不交付)
    
    - `lint` / `validate` / `inspect` 全部通过。
    - 排版:禁止元素重叠、文字溢出/出画布/截断等低级错误;`inspect --strict` 错误与警告清零才可交付,封面同样核验。
    - 防伪间距:右上角印章与文字/内容/动画保持安全距离,禁止重叠或贴近;视频与封面均适用。
    - 场景间必须转场,禁止硬切;每个场景元素必须有入场动画(hyperframes 硬性规则)。
    - 动效丰富度:禁止连续 3 秒静止;每场 ≥1 ambient、≥3 层视觉、≥2 焦点;动效遵守安全护栏——不入侵字幕/印章区、同屏并发 ≤3、只用风格内变换、无诡异运动与重叠。
    - 无文字水印:视频与封面禁止出现"平台名+主题名"或类似文字水印;印章是唯一水印。
    - 印章常驻:印章必须以根层覆盖实现并全程可见,渲染后抽查首/中/尾帧确认。
    - 对比度:正文 4.5:1,大字(24px+ 或 19px+ 粗体)3:1,只能在本风格色板内调整。
    - 确定性:禁止 `Math.random()` / `Date.now()`;动画 repeat 必须有限值。
    - 配音:句子只允许在句号/问号/感叹号处停顿;`tts_yunxi.py` 会自动规整文本并按句切分,禁止句子中间产生停顿或卡顿。
    - 时长:口播最长约 10 分钟;超长稿必须提炼核心精华压缩(保留主线/结论/数据亮点,砍掉重复铺垫与次要细节),时长与质量并重。
    - 脚本净化:写作标记/结构标签(如"一、开场钩子")不得以原文出现在视频中——不朗读、不上字幕、不显示为画面文字,必须转化为实际表达。
    - 口播优化:脚本须按流量第一性原理优化(共鸣选题/认可点赞/槽点评论/嘴替转发/价值认同涨粉),成片须具备钩子、干货、讨论点、嘴替句与价值收尾;结尾固定使用主题 JSON 的 `brand.sign_off`;数据须场景化"人话"解读、不冷冰冰堆数据;专业人认可价值、普通人觉得用得上看完;全程高价值,能留住观众。
    - 数据真实性:口播稿所有数据点须有来源(原稿出处或标注"待核实"),确认时附数据来源清单;禁止编造数据。
    - 用户确认闸门:优化后的口播稿必须先完整展示给用户审阅并获确认,未经确认禁止生成视频。
    - 字幕:主视频烧录字幕并与配音同步;字幕与画面动画文字不得重复——画面展示核心内容时该句字幕可隐藏或精简,同一信息同屏只呈现一次;字幕文件同步交付。
    - 数据可视化:数据/对比/流程必须用风格统一的动态图表表达(等宽数字 + 主题强调色 + 主题次级色网格,GSAP/SVG 实现),禁止大段静态文字或原图图表。
    - 标题增长:每个候选标题必须命中至少一个增长原则(共鸣/认可/槽点/嘴替/价值认同),禁止为凑数生成无钩子标题。
    - 渲染前核验:渲染前必须逐项对照本文件与 style-system.md/workflow.md 的全部规则并确认通过,未通过禁止渲染。
    - 封面:必须独立构图设计,禁止从主视频截帧或裁切充当封面;封面采用主题定义的封面视觉,与视频画面保持两套语言,避免被平台判定为截图。
    - 防 AI 味:禁止紫蓝霓虹、黑底光效、通用科技字体、机械匀速动画、空荡背景、每句整屏大字。
    - 活人感:净化后口播稿正文与 `titles.txt` 硬禁项清零——动作级禁令:翻案腔(先立误解再推翻抬价,含 9+ 变形)、三项以上同构排比、抒情借喻(抽象名词配具体动词)、动词名词化;标点:破折号全禁、冒号仅引出直接原话可用;硬停词"说白了/说穿了/先说结论";模型洞察路标;商业与模型黑话(绝对禁词 + 语境判断词两档,清单见 `scripts/check_prose.py`);`check_prose.py` 失败不交付。
    
    ## 音色选择(静默默认,按需试听)
    
    - 默认流程**不询问音色**,直接用当前主题的默认音色(默认主题为云希);只有用户主动提到换声音(女声/男声/换个音色/有哪些音色等)时才进入选择。
    - 进入选择时:用用户当前稿子的开头两句(约 30 字)对候选音色各合成 3-5 秒试听 mp3,列出文件让用户听完再定;禁止只用文字描述(「温暖」「活泼」)代替试听。
    - 用户选定后,本次配音带 `--voice <预设名>`,交付物注明所用音色;用户说「以后都用这个」时记录偏好,后续流程默认该音色,不再询问。
    - 推荐组合(供用户参考):资讯盘点 `yunyang`(播报感)、热血/体育向选题 `yunjian`、知识讲解 `yunxi`/`xiaoxiao`、方言玩梗 `xiaobei`/`xiaoni`。
    
    ## 语音回退(按序)
    
    1. 默认:`scripts/tts_yunxi.py`(edge-tts,默认云希,`--voice` 可换预设音色,需联网)。
    2. 用户提供云希音频:直接导入并转写,跳过合成步骤。
    3. 离线兜底:`npx hyperframes tts --voice zm_yunxi`(音色偏平,需告知用户差异)。
    
    ## 资源
    
    - `themes/paper-algorithm.json`:默认品牌主题(色板/印章/签名/字体/音色等全部品牌值)
    - `references/style-system.md`:默认主题(纸上算法)的视觉契约(色板/字体/动效/转场/字幕/图片处理/防伪/封面)
    - `references/theme-guide.md`:主题指南——如何创建自己的品牌主题
    - `references/workflow.md`:详细生产流程与命令
    - `references/titles.md`:平台标题规则
    - `scripts/tts_yunxi.py`:配音管线(内置多音色预设,默认云希)
    - `scripts/verify_output.py`:交付核验硬门禁(音轨/时长/双封面/标题/发布计划,全过才交付)
    - `scripts/check_prose.py`:活人感硬禁项检查脚本(TryWorld 改造版,源自 KKKKhazix/human-writing v1.1.0,MIT;禁令上移到修辞动作级)
    - `assets/paper-grain.svg`:纸纹叠加层
    - `assets/seal.svg`:朱红"试界原创"印章
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related