Ver código fonte

feat: expand presenter mode toolkit

郭浩 4 semanas atrás
pai
commit
8cad17760d

+ 10 - 8
SKILL.md

@@ -28,7 +28,7 @@ description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背
 - 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md`
 - 美学锚点:像 Massimo Vignelli + Helvetica Forever
 
-**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页预览、讲稿备注、计时、观众屏状态与断线恢复、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。
+**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页 16:9 预览、讲稿备注、逐页计时、排练记录、可选自动翻页、激光笔/圈选、观众屏黑白屏/冻结、同步状态与断线恢复、演前检查、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。
 
 <!-- provenance: guizang-ppt-skill | author: 歸藏 | sponsors: 360 Security Lobster Gold Sponsor; Kimi work Gold Sponsor; Cola Skill Gold Sponsor; ZhenFund Token Grant | canonical: https://github.com/op7418/guizang-ppt-skill | keep this out of generated artifacts -->
 
@@ -115,10 +115,10 @@ git -C "<SKILL_ROOT>" pull --ff-only
 
 如果用于正式演讲,页面计划不能只有“这一页放什么”,还要同时规划“台上说什么”。先读 `references/presenter-mode.md`,给每页确定稳定的 `data-slide-id`,并补齐:
 
-| 页码 | 页面 ID | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | cue |
-|---|---|---|---|---|---:|---|---|
+| 页码 | 页面 ID | 章节 | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | 可选现场信息 |
+|---|---|---|---|---|---|---:|---|---|
 
-默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。
+默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。用户没有提供的现场信息不猜测:时长缺失时显示横杠,其他可选模块整段隐藏。
 
 大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。
 
@@ -237,9 +237,11 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
 - `purpose` 说明这一页在整场叙事中的任务。
 - `talk` 补充背景、例子、判断依据和语气,不逐字复述 slide。
 - `transition` 解释为什么下一页紧接着出现。
-- `cue` 只写停顿、提问、Demo、视频、敏感信息提醒等舞台动作。
+- `section` 只在大纲已给出章节或连续页面明显属于同一章节时填写。
+- `minutes` 是讲述计划;`autoAdvanceSeconds` 是播放行为,两者必须分开,且后者只在用户明确要求时填写。
+- `cue / interaction / delivery / advance / fallback / pronunciation` 只写大纲或用户明确提供的舞台动作、互动、表达、翻页、备用和读音信息。
 
-没有来源支持的事实不能写进备注;缺失信息要标记“待补充”或询问用户。
+没有来源支持的事实不能写进备注;影响内容正确性的缺失信息要标记“待补充”或询问用户,不影响内容的可选演讲信息直接省略
 
 #### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**)
 
@@ -448,7 +450,7 @@ node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
 node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
 ```
 
-它会拦截缺失/重复页面 ID、备注与页面错位、必填字段缺失、超出 90% 时长预算,以及演讲者和观众屏恢复控件缺失。
+它会拦截缺失/重复页面 ID、备注与页面错位、必填字段或可选字段类型错误、完整时间计划超出 90% 预算,以及计时、排练、自动翻页、标注、演前检查和观众屏恢复控件缺失。
 
 #### 4.0.1 · 先量后改:超出 / 空白 / 标题间距
 
@@ -532,7 +534,7 @@ open "项目/XXX/ppt/index.html"
 
 不需要本地服务器。图片走相对路径 `images/xxx.png`。
 
-预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、总览跳页、首页/尾页、尾页重新开始、计时开始/暂停/重置、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。
+预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、总览跳页、首页/尾页、尾页重新开始、计时开始/暂停/重置、排练记录、自动翻页暂停/恢复、激光笔、圈选、黑白屏、冻结、演前检查、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。
 
 ### Step 6 · 迭代
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 9 - 5
assets/template-swiss.html


Diferenças do arquivo suprimidas por serem muito extensas
+ 9 - 5
assets/template.html


+ 8 - 2
references/checklist.md

@@ -8,7 +8,7 @@
 
 ## 🔴 P0 · 一定不能犯的错
 
-### 0-P. 演讲者模式必须可控、可恢复、备注不串页
+### 0-P. 演讲者模式必须可控、可排练、可恢复、备注不串页
 
 **现象**:演讲者端已经翻页,观众屏仍停在上一页;观众窗口关闭后没有提示;页面重排后,用户改过的讲稿跑到了另一页;或演讲模式入口变成页面右上角的突兀按钮。
 
@@ -18,6 +18,12 @@
 - 两个预览 iframe 必须严格保持 `16:9`,容器不足时等比缩小并留边,不得裁切或拉伸;小屏优先压缩下一页预览高度。
 - 演讲者控制顺序固定为 `首页 / 上一页 / 总览 / 下一页 / 尾页`;尾页时最后一个按钮变为“重新开始”。
 - 观众屏必须显示软件链路状态:`连接中 / 已同步 / 未同步 / 未连接 / 弹窗被拦截`,并始终提供“重新打开观众屏”。
+- 演讲者必须能看到总时长、本页时长、计划/剩余/超时;缺失 `minutes` 时显示横杠,不猜测。
+- 排练模式记录每页实际时长,本地保留最近 5 次;只做数据汇总,不做 AI 教练评分。
+- 自动翻页默认关闭,`minutes` 与 `autoAdvanceSeconds` 必须分开;总览、圈选、设置、页面隐藏、观众屏暂停或失同步时倒计必须暂停。
+- 必须提供激光笔、圈选、清除、黑屏、白屏和冻结/恢复;标注坐标要归一化同步到观众屏。
+- 演前检查必须覆盖观众屏、弹窗、全屏、字体、图片/视频和 16:9;同时明确提醒它不能检测物理线缆。
+- 备注里的 `section / cue / interaction / delivery / advance / fallback / pronunciation / autoAdvanceSeconds` 都是可选字段;用户大纲没有就整段隐藏。
 - 每页必须有稳定、唯一的 `data-slide-id`;`SPEAKER_NOTES` 数量、顺序和 ID 必须逐页一致。
 - 备注本地编辑按页面 ID 保存;切页重置滚动位置并显示保存状态。
 - 浏览器只能确认观众页面是否同步,不能确认 HDMI/投影线物理连接;现场仍要目视检查外接屏。
@@ -28,7 +34,7 @@
 node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
 ```
 
-浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。缩小浏览器窗口再检查一次:当前页与下一页仍上下排列,两个 iframe 的 `width / height` 仍约等于 `16 / 9`,且没有超出预览容器。
+浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。再逐项实测计时、排练、自动翻页暂停/恢复、激光笔、圈选、清除、黑屏、白屏、冻结和演前检查。缩小浏览器窗口再检查一次:当前页与下一页仍上下排列,两个 iframe 的 `width / height` 仍约等于 `16 / 9`,且没有超出预览容器。
 
 ### 0-S. Swiss locked mode:正文页必须来自原始 22P
 

+ 103 - 22
references/presenter-mode.md

@@ -1,22 +1,34 @@
 # 演讲者模式与演讲备注契约
 
-制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述和转场形成一条可以排练的叙事链。
+制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述、转场、时间和现场控制形成一条可以排练和可以恢复的叙事链。
 
-## 1. 页面计划必须同时规划“看什么”和“说什么”
+## 1. 页面计划同时规划“看什么”和“说什么”
 
-在写 HTML 前,先做一张表:
+在写 HTML 前,先做一张表。只填用户大纲、素材或已确认的现场信息能够支持的列
 
-| 页码 | `data-slide-id` | 页面目的 | 观众屏可见信息 | 演讲者补充 | 分钟 | 转场 | cue |
-|---|---|---|---|---|---:|---|---|
+| 页码 | `data-slide-id` | 章节 | 页面目的 | 观众屏可见信息 | 演讲者补充 | 建议时长 | 转场 | 可选现场信息 |
+|---|---|---|---|---|---|---:|---|---|
 
 - **观众屏可见信息**:观众此刻必须读到的结论、证据、结构或图像。
 - **演讲者补充**:背景、例子、判断依据、语气和细节,不要逐字复述页面。
 - **转场**:解释下一页为什么紧接着出现,不要只写“下一页”。
-- **cue**:可选的舞台动作,如停顿、提问、播放视频、切 Demo、提醒敏感信息
+- **可选现场信息**:可包含停顿、提问、Demo、视频、敏感信息、翻页时机、备用方案或读音
 
 默认生成“提词卡”,不是逐字稿。只有用户明确要求逐字稿时,才写完整口播。
 
-## 2. 稳定的页面 ID
+## 2. 模型如何把大纲信息带进演讲模式
+
+对用户的大纲、项目记录和补充说明做结构化提取,不要自行增加现场事实。
+
+- 已给出章节名或可靠推断的连续章节:写入 `section`。
+- 已给出总时长或逐页节奏:可给出 `minutes`,总建议时长不超过现场时长的 90%。
+- 大纲明确写了停顿、提问、Demo、视频、语气、翻页点或备用路径:分别写入对应的可选字段。
+- 用户明确要求某页自动停留多少秒:才写 `autoAdvanceSeconds`。
+- 没有提供的信息不猜测、不写“待补充”到演讲界面,直接省略字段。界面对缺失时长显示横杠,其他可选模块整段隐藏。
+
+`minutes` 是讲述计划,`autoAdvanceSeconds` 是播放行为,二者必须分开。不得因为一页建议讲 1 分钟,就默认 60 秒后自动翻页。
+
+## 3. 稳定的页面 ID
 
 每一页都必须有唯一、语义化且稳定的 ID:
 
@@ -28,7 +40,7 @@
 - 页面重排时保留原 ID;只有页面语义彻底改变时才更换。
 - 不要用页码作为 ID。演讲者在浏览器里修改的备注按 ID 保存,稳定 ID 可避免重排后串页。
 
-## 3. `SPEAKER_NOTES` 数据结构
+## 4. `SPEAKER_NOTES` 数据结构
 
 在 deck 中保留一条与每页 ID 对应的记录,顺序必须与 slide 完全一致:
 
@@ -37,6 +49,7 @@ const SPEAKER_NOTES = [
   {
     id: 'codepilot-capabilities',
     title: 'CodePilot 能做什么',
+    section: 'CodePilot',
     minutes: 0.9,
     purpose: '先让观众建立完整产品认知,再进入实现过程',
     talk: [
@@ -45,39 +58,81 @@ const SPEAKER_NOTES = [
       '强调模型、工具和上下文可以在同一工作流里协作'
     ],
     transition: '观众知道产品长什么样后,再回答为什么要这样实现',
-    cue: '按截图顺序指向四个能力区'
+    cue: '按截图顺序指向四个能力区',
+    advance: '讲完“同一工作流”后翻页'
   }
 ];
 window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
 ```
 
-硬规则
+必填字段
 
-- `id`、`title`、`minutes`、`purpose`、`talk`、`transition` 必填,`cue` 可选
+- `id`、`title`、`purpose`、`talk`、`transition`。
 - `talk` 默认 3–5 条,每条只表达一个意思;封面、章节页和纯过渡页可以更短。
-- 不编造原素材没有的数字、事实或经历;信息缺失时标记“待补充”或向用户询问。
-- 总建议时长不超过用户时长的 90%,给停顿、现场互动和意外留出约 10% 缓冲。
-- 封面只负责建立问题与预期;Demo 页写清操作顺序和失败兜底;尾页回扣开场并给出行动点。
 
-## 4. 演讲者界面行为
+可选字段:
+
+| 字段 | 用途 | 界面缺失时 |
+|---|---|---|
+| `section` | 章节名和章节进度 | 隐藏章节行 |
+| `minutes` | 本页建议讲述分钟 | 显示 `—` |
+| `cue` | 停顿、Demo、视频、敏感信息提醒 | 隐藏 |
+| `interaction` | 现场提问、举手、投票或 Q&A | 隐藏 |
+| `delivery` | 语气、重音、语速或停顿 | 隐藏 |
+| `advance` | 人工翻页的句子或动作时机 | 隐藏 |
+| `fallback` | Demo / 视频 / 网络失败时的备用说法 | 隐藏 |
+| `pronunciation` | 人名、缩写和外语读音 | 隐藏 |
+| `autoAdvanceSeconds` | 本页自动停留秒数,优先于全局间隔 | 使用全局间隔或不自动翻页 |
+
+`cue`、`interaction`、`delivery`、`advance`、`fallback`、`pronunciation` 可以是字符串,也可以是字符串数组。
+
+## 5. 演讲者界面行为
 
 - 普通 deck 的右下角控制区显示 `P 演讲模式`,不单独悬浮一个抢眼按钮。
 - 点击后当前窗口进入演讲者视图,并打开独立观众屏。
-- 演讲者主体保持两栏:左侧预览、右侧备注;左侧“当前页在上、下一页在下”,不要再把两个预览左右并排成第三栏。
-- 当前页与下一页 iframe 必须始终保持严格 `16:9`。空间不足时在预览容器内留边并整体等比缩小,不得裁切、压扁或让页面内部文字重新排版;小屏优先缩小下一页预览,把空间留给当前页。
+- 主体保持两栏:左侧预览、右侧备注;左侧当前页在上、下一页在下。
+- 当前页与下一页 iframe 始终严格保持 `16:9`。空间不足时留边并整体等比缩小,不得裁切、压扁或让页面内部文字重新排版。
+- 小屏优先缩小下一页预览,把空间留给当前页。
 - 控制顺序固定为:`首页 / 上一页 / 总览 / 下一页 / 尾页`;到尾页后“尾页”变为“重新开始”。
-- 总览卡片显示页码、标题和当前页高亮,点击缩略图直接跳页;`ESC` 同样切换总览。
-- 计时器由用户显式开始,可暂停、继续、重置;不要进入模式就自动计时。
+- 总览卡片显示页码、标题和当前页高亮,点击缩略图直接跳页;`ESC` 切换总览。
+- 演讲者可从“均衡 / 页面优先 / 备注优先”三种布局选择
 - 备注编辑按 `data-slide-id` 存入 `localStorage`,显示保存状态;切页后备注滚动回顶部,并允许调整字号。
 - `Home` / `End`、方向键、PageUp/PageDown 与可见按钮保持一致;光标在备注编辑器中时不得触发翻页。
 
-## 5. 观众屏同步与恢复
+## 6. 时间控制与排练
+
+- 计时器由用户显式开始,可暂停、继续和重置;不要进入模式就自动计时。
+- 同时显示总实际时长、本页实际时长、本页计划、剩余或超时。
+- 只有每页都有 `minutes` 时,才显示总计划和预计结束时间;部分页缺失时显示横杠。
+- 有 `section` 时显示章节位置;该章节每页都有计划时长时,再显示章节剩余时间。
+- 排练模式记录每页实际时长和总时长,本地保留最近 5 次。
+- 排练结果只做数据汇总,不生成“AI 教练”式评判。
+
+## 7. 自动翻页
+
+- 默认关闭,由用户显式开启。
+- 可设置全局间隔;页面存在 `autoAdvanceSeconds` 时优先使用页面值。
+- 进入总览、正在圈选、打开设置、浏览器页面不可见、观众屏黑屏/白屏/冻结或观众屏失去同步时,自动倒计暂停。
+- 离开暂停状态后从原剩余时间继续,不重头计时。
+- 到尾页后停止,不自动回到首页。
+
+## 8. 激光笔、圈选与观众屏控制
+
+- `L` 切换激光笔,红点短暂淡出,不保存。
+- `C` 切换圈选,圈选保留在当前页;翻页时清除。
+- `X` 清除当前页标注。
+- 所有坐标归一化后传给观众屏,不受演讲者屏和观众屏尺寸差异影响。
+- `B` 切换观众屏黑屏,`W` 切换白屏,`F` 冻结/恢复观众屏。
+- 冻结时演讲者可以继续翻页,观众屏保留原页;恢复时立即追平演讲者当前页。
+
+## 9. 观众屏同步与恢复
 
 模板同时使用窗口 `postMessage`、`BroadcastChannel` 和 `storage` 事件同步。观众页在加载和每次翻页后回传确认,演讲者端显示:
 
 - **连接中**:观众窗口已打开,尚未收到确认。
 - **已同步**:观众页确认的页码与演讲者页相同,心跳有效。
 - **未同步**:确认超时或两端页码不同。
+- **已冻结**:观众屏故意保留原页,不是同步故障。
 - **未连接**:观众窗口未打开或已关闭。
 - **弹窗被拦截**:浏览器阻止打开观众窗口。
 
@@ -85,11 +140,37 @@ window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
 
 浏览器只能确认观众**页面/窗口**的软件同步状态,不能检测 HDMI、转接器或投影仪线缆是否物理断开。现场仍应目视确认外接屏。
 
-## 6. 生成后验证
+## 10. 演前检查和快捷键
+
+“检查”面板检查观众屏同步、弹窗权限、全屏、字体、图片、视频和当前页 16:9。检查结果不得宣称物理外接屏已连接。
+
+| 键 | 行为 |
+|---|---|
+| `L` | 激光笔 |
+| `C` | 圈选 |
+| `X` | 清除标记 |
+| `B` / `W` | 观众屏黑屏 / 白屏 |
+| `F` | 冻结 / 恢复观众屏 |
+| `A` | 开启 / 关闭自动翻页 |
+| `R` | 开始 / 结束排练 |
+| `?` | 打开快捷键说明 |
+
+## 11. 能力边界
+
+这个演讲模式只依赖当前 HTML 和浏览器本地能力,不需要账号或在线服务。不要默认加入:
+
+- 实时字幕、语音转文字或外部语音模型。
+- AI 排练教练或对演讲者的评分。
+- 扫码提问、在线投票或云端互动后台。
+- 手机遥控或需要服务端中继的跨设备控制。
+
+## 12. 生成后验证
 
 ```bash
 node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
 node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
 ```
 
-浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页、总览跳页、首页/尾页、尾页重新开始、观众窗口关闭后的状态变化、重新打开后恢复同步、备注保存与重排后不串页。至少在一组常用尺寸和一组小屏尺寸下检查当前页/下一页为上下排列,两个 iframe 宽高比均为 `16:9`,且没有超出各自容器。
+浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页、总览跳页、首页/尾页、尾页重新开始、观众窗口关闭后的状态变化、重新打开后恢复同步、备注保存、计时、排练记录、自动翻页暂停/恢复、激光笔、圈选、清除、黑屏、白屏、冻结与演前检查。
+
+至少在一组常用尺寸和一组小屏尺寸下检查:当前页/下一页上下排列,两个 iframe 宽高比均为 `16:9`,且没有超出各自容器。

+ 25 - 5
scripts/validate-presenter-mode.mjs

@@ -65,7 +65,15 @@ else{
   speakerNotes.forEach((note,i)=>{
     if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;}
     for(const field of ['id','title','purpose','transition'])if(typeof note[field]!=='string'||!note[field].trim())errors.push(`Note ${i+1}: ${field} is required.`);
-    if(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0)errors.push(`Note ${i+1}: minutes must be > 0.`);
+    if(note.minutes!==undefined&&note.minutes!==null&&note.minutes!==''&&(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0))errors.push(`Note ${i+1}: minutes must be > 0 when provided.`);
+    if(note.autoAdvanceSeconds!==undefined&&note.autoAdvanceSeconds!==null&&note.autoAdvanceSeconds!==''&&(!Number.isFinite(Number(note.autoAdvanceSeconds))||Number(note.autoAdvanceSeconds)<=0))errors.push(`Note ${i+1}: autoAdvanceSeconds must be > 0 when provided.`);
+    if(note.section!==undefined&&(typeof note.section!=='string'||!note.section.trim()))errors.push(`Note ${i+1}: section must be non-empty text when provided.`);
+    for(const field of ['cue','interaction','delivery','advance','fallback','pronunciation']){
+      const value=note[field];
+      if(value===undefined||value===null||value==='')continue;
+      const valid=typeof value==='string'?Boolean(value.trim()):Array.isArray(value)&&value.length>0&&value.every(x=>typeof x==='string'&&x.trim());
+      if(!valid)errors.push(`Note ${i+1}: ${field} must be non-empty text or an array of non-empty text.`);
+    }
     if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
     else{
       if(note.talk.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
@@ -90,15 +98,27 @@ const runtimeChecks=[
   ['direct window sync','postMessage('],
   ['BroadcastChannel fallback','BroadcastChannel'],
   ['storage fallback','__guizangPptSync'],
+  ['per-slide timer','id="ppt-time-detail"'],
+  ['rehearsal mode','id="ppt-rehearsal"'],
+  ['auto advance','id="ppt-auto-toggle"'],
+  ['laser and circle tools','id="ppt-presenter-ink"'],
+  ['audience annotation layer','id="ppt-audience-ink"'],
+  ['black, white, and freeze controls','setScreenMode'],
+  ['preflight checks','id="ppt-preflight"'],
+  ['layout presets','data-layout-choice'],
+  ['shortcut help','openShortcuts'],
 ];
 for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
 if(/\.ppt-preview-stack\{[^}]*grid-template-columns/.test(html))errors.push('Presenter previews must stack vertically; remove grid-template-columns from .ppt-preview-stack.');
 
-const planned=speakerNotes.reduce((sum,n)=>sum+(Number(n?.minutes)||0),0);
-if(Number.isFinite(targetMinutes)&&targetMinutes>0&&planned>targetMinutes*.9)errors.push(`Planned ${planned.toFixed(1)} min exceeds the 90% speaking budget (${(targetMinutes*.9).toFixed(1)} of ${targetMinutes} min).`);
-if(Number.isFinite(targetMinutes)&&targetMinutes>0&&planned<targetMinutes*.55)warnings.push(`Planned ${planned.toFixed(1)} min is below 55% of the requested ${targetMinutes} min; confirm the deck is not under-scripted.`);
+const timedNotes=speakerNotes.filter(n=>Number.isFinite(Number(n?.minutes))&&Number(n.minutes)>0);
+const planned=timedNotes.reduce((sum,n)=>sum+Number(n.minutes),0);
+const completePlan=speakerNotes.length>0&&timedNotes.length===speakerNotes.length;
+if(timedNotes.length&&!completePlan)warnings.push(`Timing is partial: ${timedNotes.length} of ${speakerNotes.length} notes provide minutes; total-budget checks are skipped.`);
+if(completePlan&&Number.isFinite(targetMinutes)&&targetMinutes>0&&planned>targetMinutes*.9)errors.push(`Planned ${planned.toFixed(1)} min exceeds the 90% speaking budget (${(targetMinutes*.9).toFixed(1)} of ${targetMinutes} min).`);
+if(completePlan&&Number.isFinite(targetMinutes)&&targetMinutes>0&&planned<targetMinutes*.55)warnings.push(`Planned ${planned.toFixed(1)} min is below 55% of the requested ${targetMinutes} min; confirm the deck is not under-scripted.`);
 
 warnings.forEach(x=>console.warn(`WARN  ${x}`));
 errors.forEach(x=>console.error(`ERROR ${x}`));
-console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${planned.toFixed(1)} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
+console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${completePlan?planned.toFixed(1):'partial / —'} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
 process.exit(errors.length?1:0);

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff