From 4c0d26b0b83ab2544fd9c918c9c727317d36c966 Mon Sep 17 00:00:00 2001 From: weijian Date: Mon, 21 Sep 2026 23:49:50 +0800 Subject: [PATCH] fix(guizang-product-video-skill): report the narration key when it is actually needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environment check never mentioned EASYROUTER_API_KEY, and the only place that told the agent to ask for it sat in references/onboarding.md — which the flow gates behind "load it only for reported gaps". A gap that is never reported is never read about, so a narrated film kept discovering the missing key at the mixing step, after the entire picture had been built. - check_environment.py: add `--voiceover yes|no|undecided`. Only `yes` (the step-1 answer) reports the key, in a separate `prereqs` list so a missing credential is not read as a broken toolchain and does not skip the browser launch; `undecided` warns instead. The answer is deliberately not read from plan.json — at that point the plan is still the starter sample and its voiceoverRequired is stale, which would silently suppress the reminder. - SKILL.md: resolve the key the moment the user says yes (it blocks mixing, not rendering); pass the answer to the check. - audio-sourcing.md: give the narration pace positively — use the voice's natural pace, measured on this film's own copy (typically 4–6 chars/s), and adjust by changing the voice or shortening the line. The existing 6–9 chars/s figure is a *reading* line, not a narration target, and budgeting copy to it overruns the shots. - onboarding.md: Homebrew python3 for the gateway (system 3.9 + LibreSSL fails TLS) and the transient first-request disconnect. - tests: two regressions for the gating, plus one asserting the key value never reaches the report or evidence/environment.json. --- skills/guizang-product-video-skill/SKILL.md | 12 ++--- .../marketplace.json | 4 +- .../references/audio-sourcing.md | 19 +++++++- .../references/onboarding.md | 13 ++++-- .../references/starter.md | 2 +- .../scripts/check_environment.py | 44 ++++++++++++++++--- .../tests/test_regressions.py | 42 +++++++++++++++++- 7 files changed, 115 insertions(+), 21 deletions(-) diff --git a/skills/guizang-product-video-skill/SKILL.md b/skills/guizang-product-video-skill/SKILL.md index c6210d0..d2194de 100644 --- a/skills/guizang-product-video-skill/SKILL.md +++ b/skills/guizang-product-video-skill/SKILL.md @@ -15,11 +15,11 @@ license: "AGPL-3.0; CodePilot fallback assets: BUSL-1.1 (see README.md)" ## 流程 -1. **确认范围与三个选择。** 已有工程沿用 brief 和用户决定,只处理本次修改。新片先补齐产品/仓库、版本范围、发布平台、时长与画幅、语言和链接要求,再用一次 `ask_user_question` 问清三件会改变工作量的事:**风格**(`repo` 沿用产品设计/`default` 默认暖白炭黑/`hybrid` 保留产品识别并调整外层排版)、**动效**(界面操作动效 + 镜头 zoom in/out/只做界面动效/只做镜头推拉/全静态)、**旁白**(要 Gemini 画外音/只要字幕与音效/稍后再定)。用户已经指定、或已有工程里记过答案时不要重复问;旧工程缺 `motion` 或 `voiceoverRequired` 时补问一次写进 plan 再继续(脚本对缺失只告警,对自相矛盾才报错)。答案写进 `plan.json` 的 `motion.uiEffects`、`motion.cameraMove` 和 `voiceoverRequired`;用户否决的一项用 `motion.exceptionReason` 或 `voiceoverExceptionReason` 留下依据。普通场景可建议横版、45–60 秒、中文。 -2. **初始化后检查环境。** 风格确定后,按下方工具入口初始化独立视频目录,再运行环境检查。仅对报告的缺项加载 [依赖安装](references/onboarding.md) 并补装,随后复查;`ready:true` 继续制作。已有工程直接检查,无需再次初始化。Python 本身缺失时先按依赖安装文档补齐 Python。检查每次执行,安装文档按缺项加载;browser 每次重查环境并实际启动浏览器,`cached:true` 仅表示环境指纹匹配上次成功记录;HyperFrames 指纹匹配时可跳过 doctor。 +1. **确认范围与三个选择。** 已有工程沿用 brief 和用户决定,只处理本次修改。新片先补齐产品/仓库、版本范围、发布平台、时长与画幅、语言和链接要求,再用一次 `ask_user_question` 问清三件会改变工作量的事:**风格**(`repo` 沿用产品设计/`default` 默认暖白炭黑/`hybrid` 保留产品识别并调整外层排版)、**动效**(界面操作动效 + 镜头 zoom in/out/只做界面动效/只做镜头推拉/全静态)、**旁白**(要 Gemini 画外音/只要字幕与音效/稍后再定)。用户已经指定、或已有工程里记过答案时不要重复问;旧工程缺 `motion` 或 `voiceoverRequired` 时补问一次写进 plan 再继续(脚本对缺失只告警,对自相矛盾才报错)。答案写进 `plan.json` 的 `motion.uiEffects`、`motion.cameraMove` 和 `voiceoverRequired`;用户否决的一项用 `motion.exceptionReason` 或 `voiceoverExceptionReason` 留下依据。旁白选定为「要」时**当场**解析 `EASYROUTER_API_KEY`(环境变量或视频工程 `.env`):缺就立刻向用户索取,不要等画面做完——申请 key 要用户花时间,而它挡的是混音不是渲染,拖到最后才发现等于让用户白等整轮制作。普通场景可建议横版、45–60 秒、中文。 +2. **初始化后检查环境。** 风格确定后,按下方工具入口初始化独立视频目录,再运行环境检查。把第 1 步的旁白答案传给检查脚本(`--voiceover yes|no|undecided`):只有 `yes` 时 `EASYROUTER_API_KEY` 会作为 `prereqs` 报出来,`undecided` 只给告警。**不要改用 `plan.json` 的 `voiceoverRequired`**——此刻 plan 还是起步工程的技术样片,那个字段是过期的,读它会静默压掉提示。仅对报告的缺项加载 [依赖安装](references/onboarding.md) 并补装(`prereqs` 是用户提供的凭据,你装不了,只能索取),随后 `--force` 复查;`ready:true` 继续制作。已有工程直接检查,无需再次初始化。Python 本身缺失时先按依赖安装文档补齐 Python。检查每次执行,安装文档按缺项加载;browser 每次重查环境并实际启动浏览器,`cached:true` 仅表示环境指纹匹配上次成功记录;HyperFrames 指纹匹配时可跳过 doctor。 3. **调查更新并接通组件。** 按 [仓库与风格审计](references/repo-and-style.md) 确定日期/版本、发布状态和 3–5 组核心变化。找到对应业务组件、完整样式和所需状态,先接通一个功能镜头。React 项目的依赖解析、CSS/Tailwind 接入见 [起步工程](references/starter.md),其他挂载路径见 [组件接入](references/component-pipeline.md)。 4. **编排画面与文案。** 按 [分镜与文案](references/story-and-copy.md) 写解释、标题、动作及阅读时间,交替安排字卡、组件特写、工作区和细节。用真实渲染路径输出 3–6 张关键静帧自检;用户要求先看方向时等反馈,否则继续。默认样式可先看 [标题预览](assets/fallback/title-preview.png) 与 [组件预览](assets/fallback/preview.png)。 -5. **完成动效与声音。** 用主时间轴控制组件状态和镜头,支持前后 seek;界面动效与镜头推拉按 [分镜与文案](references/story-and-copy.md) 的镜头语言执行,动效对应真实状态变化,缩放后重新核对变换后的边界。按 [配乐与音效来源](references/audio-sourcing.md) 为当前影片代码原创配乐,先查找适合产品和动作的音效,缺项才用内置 WAV;用户要旁白时用 `scripts/make_voiceover.py` 按镜头逐句生成并量出实际时长。按 [混音与验收](references/audio-and-qa.md) 对齐 `audio.cues` 与 `audio.voiceover`、压低关键音效与人声期间的音乐并完成混音。 +5. **完成动效与声音。** 用主时间轴控制组件状态和镜头,支持前后 seek;界面动效与镜头推拉按 [分镜与文案](references/story-and-copy.md) 的镜头语言执行,动效对应真实状态变化,缩放后重新核对变换后的边界。按 [配乐与音效来源](references/audio-sourcing.md) 为当前影片代码原创配乐,先查找适合产品和动作的音效,缺项才用内置 WAV;用户要旁白时**先用候选音色各试一句、量出这部片子自己的语速**,再按它决定每句长度,然后用 `scripts/make_voiceover.py` 按镜头逐句生成并量出实际时长(旁白语速靠选音色和写短句子来调,见 [配乐与音效来源](references/audio-sourcing.md))。按 [混音与验收](references/audio-and-qa.md) 对齐 `audio.cues` 与 `audio.voiceover`、压低关键音效与人声期间的音乐并完成混音。 6. **验证并交付。** 检查最终 MP4 的裁切、字体、图片、Logo、阅读时间、声音和用户指定的链接处理。修改后重新导出并复查相关镜头。交付 MP4、可复现工程和少量预览;区分自动检查、实际观看/试听及仍受限部分。遇到历史同类问题可查 [案例复盘](references/case-study.md)。 ## 硬约束 @@ -28,7 +28,7 @@ license: "AGPL-3.0; CodePilot fallback assets: BUSL-1.1 (see README.md)" - **原组件。** 功能镜头优先接入实际业务组件、原样式及状态;抽象化用于取景、布局和外层动画。逐镜头核对导入图、来源与静帧。平台确实无法接入时记录阻碍和替代方式,遵从已有授权。 - **清楚排版。** 宣传标题默认有意义的英文与中文各占一个 span、分别指定字体,中文无衬线;中文说明交代对象、动作和结果。原产品内部字体保持其设计;用户指定的语言/字体优先。 - **完整声音。** 默认代码原创音乐与独立动作音效均入轨,关键反馈可闻、音画同步。用户要求静音或省略音效时,记录 `audioExceptionReason`;该字段保存用户依据。 -- **旁白服务于画面。** 用户要旁白时按镜头逐句生成,人声清楚、音乐在其间让位;每句的 `at` 默认落在所属镜头内、`duration` 用实测值,越界或超长时脚本给出告警、需要人确认是否有意为之;每句只讲一个有来源的信息点,不照抄标题。用户不要旁白时记录 `voiceoverExceptionReason`,不静默跳过。 +- **旁白服务于画面。** 用户要旁白时按镜头逐句生成,人声清楚、音乐在其间让位;每句的 `at` 默认落在所属镜头内、`duration` 用实测值,越界或超长时脚本给出告警、需要人确认是否有意为之;每句只讲一个有来源的信息点,不照抄标题;语速用该音色的自然语速,要调整就换音色或删词。用户不要旁白时记录 `voiceoverExceptionReason`,不静默跳过。 - **动效有依据。** 界面动效对应选中、展开、切换、完成等真实状态变化,镜头推拉服务“要让观众看哪里”;用户选全静态时不要为了“看起来在动”加无意义晃动,并记录 `motion.exceptionReason`。 - **隔离工程。** 源码适配、展示依赖和构建配置放视频工程;原产品代码和依赖保持不动。读取产品已安装依赖,必要时在视频工程固定版本补装适配所需包。 - **授权与真实验收。** 使用素材时保留来源和适用许可。默认样式仍受 [BSL 授权](assets/fallback/SOURCE.md) 约束。自动检查证明结构与文件一致性,视觉、语义和听感由实际审阅补充。 @@ -39,8 +39,8 @@ license: "AGPL-3.0; CodePilot fallback assets: BUSL-1.1 (see README.md)" ```sh python3 /scripts/init_project.py --output --style repo --repo -python3 /scripts/check_environment.py --project --engine browser -# 缺项 → 按 references/onboarding.md 补装 → 使用 --force 复查。 +python3 /scripts/check_environment.py --project --engine browser --voiceover +# 缺项 / prereqs → 按 references/onboarding.md 处理 → 使用 --force 复查。 ``` HyperFrames 工程使用 `--engine hyperframes`。新工程附 10 秒技术样片,用于验证链路;正式制作按实际产品替换内容。 diff --git a/skills/guizang-product-video-skill/marketplace.json b/skills/guizang-product-video-skill/marketplace.json index 00abad0..a41177e 100644 --- a/skills/guizang-product-video-skill/marketplace.json +++ b/skills/guizang-product-video-skill/marketplace.json @@ -6,7 +6,7 @@ "zh": "产品宣传视频", "en": "Guizang Product Video" }, - "version": "1.1.0", + "version": "1.1.1", "usageExample": "我们刚发布了 v2.3.0,更新内容在 CHANGELOG.md 里。帮我把这次更新做成一支 45 秒左右的横版宣传片:复用产品自己的界面组件和配色,界面操作动效保留、镜头适当推拉,再加一段按镜头逐句生成的旁白,配上原创音乐和动作音效,最后交付 MP4 和可复现的工程目录。", "description": { "zh": "制作代码驱动的软件版本更新宣传片(release notes video、changelog promo)。默认先确认风格、界面操作动效与镜头推拉、是否要 Gemini 画外音,再从真实更新提炼卖点,复用产品组件和设计语言,完成分镜、代码原创配乐、动作音效、逐句旁白、渲染与验收。", @@ -14,7 +14,7 @@ } }, "storage": { - "packageKey": "skills/guizang-product-video-skill/guizang-product-video-skill_1.1.0.zip" + "packageKey": "skills/guizang-product-video-skill/guizang-product-video-skill_1.1.1.zip" }, "media": { "icon": { diff --git a/skills/guizang-product-video-skill/references/audio-sourcing.md b/skills/guizang-product-video-skill/references/audio-sourcing.md index bf66e9e..0db9c2b 100644 --- a/skills/guizang-product-video-skill/references/audio-sourcing.md +++ b/skills/guizang-product-video-skill/references/audio-sourcing.md @@ -91,10 +91,25 @@ python3 /scripts/make_voiceover.py --plan plan.json --output --force`),读 `evidence/voiceover.json` 里 `lines[].duration`,算 `字数 ÷ 秒数`。 +- **用这部片子自己的稿子量。** 音色的自然语速随句子变化很大(实测同一个音色在不同句子上差 20% 以上),拿一句话的结果去编整片预算,会有近一半句子越界。 +- 量出来通常在 **4–6 字/秒**:要更慢就换音色,要更快就换音色,都还嫌长就删词。 +- 脚本对越界和重叠只报错、不自动修。报错就删词,或把 `at` 提前到镜头内第一个拍点。 + +助手通常听不了声音,能自动核对的只有时长是否越界、是否重叠、人声是否真的进了 master(比较人声轨与 master 在有人声/无人声窗口的 RMS 电平差)。**音色和语速快慢让用户听样片定**:同一句话、同一设置、逐条对齐电平拼成一个试听文件发过去。 用户不要旁白时,在 plan 里记 `voiceoverRequired: false` 和 `voiceoverExceptionReason`(写用户的依据),不要一声不响地跳过。 diff --git a/skills/guizang-product-video-skill/references/onboarding.md b/skills/guizang-product-video-skill/references/onboarding.md index 7d91b30..1cd8419 100644 --- a/skills/guizang-product-video-skill/references/onboarding.md +++ b/skills/guizang-product-video-skill/references/onboarding.md @@ -1,6 +1,6 @@ # 依赖安装(仅有缺项时读取) -先确认风格并初始化视频目录,再运行 `check_environment.py --project `。Python 不存在时先补 Python。检查脚本只用标准库,缺依赖也可运行并列出需要安装的项目。 +先确认风格并初始化视频目录,再运行 `check_environment.py --project --voiceover <第 1 步的旁白答案>`。Python 不存在时先补 Python。检查脚本只用标准库,缺依赖也可运行并列出需要安装的项目。 ## 安装范围与验证 @@ -88,12 +88,19 @@ npx hyperframes browser ensure ## 旁白(可选) -画外音不引入新的系统依赖,仍是 Python 与 FFmpeg,只多一个 EasyRouter key(在 https://ezr.sh/ 申请)。key 放环境变量 `EASYROUTER_API_KEY` 或视频工程的 `.env`,不要提交。缺 key 时不要把旁白写成已完成,说明情况并等用户提供;申请 key 不是必装步骤,用户不要旁白时整段可跳过。 +画外音不引入新的系统依赖,仍是 Python 与 FFmpeg,只多一个 EasyRouter key(在 https://ezr.sh/ 申请)。key 放环境变量 `EASYROUTER_API_KEY` 或视频工程的 `.env`,不要提交。 + +**用户在需求确认阶段选了要旁白时,就在那一刻索取 key。** 检查脚本只在传了 `--voiceover yes` 时才把它报成 `prereqs`——它是用户提供的凭据,装不了,只能索取;而它挡的是混音不是渲染,拖到最后才发现会让用户白等整轮制作。缺 key 时不要把旁白写成已完成,说明情况并等用户提供;用户不要旁白时整段可跳过,脚本也不提这件事。 + +两个实测过的环境问题,先按它们排查,不要误判成 key 或配额问题: + +- **macOS 上优先用 Homebrew 的 python3 跑旁白脚本。** 系统自带的 `/usr/bin/python3`(3.9 + LibreSSL)访问网关会报 `EOF occurred in violation of protocol`;`/opt/homebrew/bin/python3`(OpenSSL 3)正常。 +- **`/audio/speech` 偶发首次请求被对端断开**,报 `Remote end closed connection without response`。重试即可;批量生成时带一次重试。 ## 复查 ```sh -python3 /scripts/check_environment.py --project --engine browser --force +python3 /scripts/check_environment.py --project --engine browser --voiceover --force ``` HyperFrames 改为 `--engine hyperframes`。成功后继续原分镜工作;只有缺项才重新读取对应段落。平台安装语法来自上述官方来源,实机覆盖以验证记录为准。 diff --git a/skills/guizang-product-video-skill/references/starter.md b/skills/guizang-product-video-skill/references/starter.md index 2922ace..ecc6cbd 100644 --- a/skills/guizang-product-video-skill/references/starter.md +++ b/skills/guizang-product-video-skill/references/starter.md @@ -9,7 +9,7 @@ ```sh python3 /scripts/init_project.py --output --style repo --repo cd -python3 /scripts/check_environment.py --project . --engine browser +python3 /scripts/check_environment.py --project . --engine browser --voiceover # ready:true 跳过安装;缺项时按 references/onboarding.md 补齐后复查。 npm run build npm run render -- --still 4 --output evidence/frame-04.png diff --git a/skills/guizang-product-video-skill/scripts/check_environment.py b/skills/guizang-product-video-skill/scripts/check_environment.py index 955dd54..ade1332 100644 --- a/skills/guizang-product-video-skill/scripts/check_environment.py +++ b/skills/guizang-product-video-skill/scripts/check_environment.py @@ -19,6 +19,19 @@ def run(args, cwd=None, timeout=30): except (OSError,subprocess.TimeoutExpired) as e:return {'ok':False,'output':str(e)} +def dotenv_keys(project): + """Non-empty KEY=value pairs from /.env. Callers must not print values.""" + values={} + try:lines=(project/'.env').read_text().splitlines() + except OSError:return values + for line in lines: + line=line.strip() + if not line or line.startswith('#') or '=' not in line:continue + name,_,value=line.partition('=') + values[name.strip()]=value.strip().strip('"').strip("'") + return values + + def hyperframes_failures(payload): checks=payload.get('checks') if not isinstance(checks,list):return ['Invalid doctor checks'],[] @@ -41,6 +54,7 @@ def main(): p.add_argument('--project',type=Path,required=True) p.add_argument('--engine',choices=['browser','hyperframes'],default='browser') p.add_argument('--force',action='store_true',help='Refresh cached engine checks; browser launch is always verified') + p.add_argument('--voiceover',choices=['yes','no','undecided'],default='no',help='Narration decision from step 1. "yes" makes EASYROUTER_API_KEY a reported prerequisite; "no" stays silent; "undecided" warns. Never guessed from plan.json: at this point the plan is still the starter sample and its voiceoverRequired is stale.') a=p.parse_args();project=a.project.expanduser().resolve();missing=[];versions={};warnings=[] if not project.is_dir():p.error('Initialize/select an isolated video project first') versions['python']={'path':sys.executable,'version':platform.python_version()} @@ -62,6 +76,18 @@ def main(): if not re.search(r'\b'+name+r'\b',filters):missing.append('ffmpeg-filter:'+name) for name in ['libx264','aac']: if not re.search(r'\b'+name+r'\b',enc):missing.append('ffmpeg-encoder:'+name) + # Narration is the one prerequisite that depends on a step-1 answer rather than on + # the machine, so it is reported separately from engine gaps: a missing key must not + # read as "your toolchain is broken", and it must not skip the browser launch below. + prereqs=[];voiceover={'requested':a.voiceover=='yes','keyResolved':False,'keySource':None} + if a.voiceover!='no': + if os.environ.get('EASYROUTER_API_KEY','').strip():voiceover.update({'keyResolved':True,'keySource':'environment'}) + elif dotenv_keys(project).get('EASYROUTER_API_KEY',''):voiceover.update({'keyResolved':True,'keySource':'.env'}) + if a.voiceover=='yes' and not voiceover['keyResolved']: + prereqs.append('easyrouter-api-key') + warnings.append('Narration was chosen but EASYROUTER_API_KEY is not set as an environment variable or in /.env. Ask the user for it now: applying for the key takes them time, and it blocks mixing rather than rendering, so discovering it late wastes a whole build.') + elif a.voiceover=='undecided': + warnings.append('Narration is still undecided. If the user later says yes, EASYROUTER_API_KEY becomes a blocking prerequisite for the mixing step, so ask for it at that moment.') package_files={} for file in ['package.json','package-lock.json','pnpm-lock.yaml','yarn.lock','bun.lock']: path=project/file @@ -78,12 +104,12 @@ def main(): for name in names: if name not in modules or modules[name].get('missing'):missing.append('package:'+name) else:missing.append('node-package-resolution');warnings.append(result['output'][:800]) - details={'schema':2,'engine':a.engine,'platform':platform.platform(),'versions':versions,'packages':modules,'manifests':package_files,'browserEnv':{k:os.environ.get(k) for k in ['PLAYWRIGHT_BROWSERS_PATH','HYPERFRAMES_CHROME_PATH']}} + details={'schema':2,'engine':a.engine,'voiceover':voiceover,'platform':platform.platform(),'versions':versions,'packages':modules,'manifests':package_files,'browserEnv':{k:os.environ.get(k) for k in ['PLAYWRIGHT_BROWSERS_PATH','HYPERFRAMES_CHROME_PATH']}} fingerprint=hashlib.sha256(json.dumps(details,sort_keys=True).encode()).hexdigest() state=project/'evidence/environment.json';old={} try:old=json.loads(state.read_text()) except (OSError,ValueError):pass - cached=not missing and old.get('ready') and old.get('fingerprint')==fingerprint and not a.force + cached=not missing and not prereqs and old.get('ready') and old.get('fingerprint')==fingerprint and not a.force # Launch is the availability check: headless-shell can exist without full Chromium. # Always recheck browser execution, even when package metadata is cached. if not missing and (a.engine=='browser' or not cached): @@ -102,9 +128,17 @@ def main(): if failed:missing.append('hyperframes-doctor');warnings.extend(failed) if optional:warnings.append('Optional capabilities not needed for local rendering: '+', '.join(item.split(':')[0] for item in optional)) except ValueError:missing.append('hyperframes-doctor');warnings.append(result['output'][:1600]) - ready=not missing - report={'ready':ready,'cached':bool(cached),'engine':a.engine,'missing':missing,'warnings':warnings,'fingerprint':fingerprint,'details':details,'browser':browser, - 'next':'Continue; do not load onboarding.' if ready else 'Read references/onboarding.md for only the reported engine/platform gaps, install them, then rerun with --force.'} + ready=not missing and not prereqs + # Report every gap, not just the first kind: a missing key and a missing package are + # fixed by different people and neither should hide the other. + if ready:next_step='Continue; do not load onboarding.' + else: + steps=[] + if missing:steps.append('Read references/onboarding.md for only the reported engine/platform gaps, install them.') + if prereqs:steps.append('Ask the user for the reported prereqs; a credential cannot be installed by you.') + next_step=' '.join(steps)+' Then rerun with --force.' + report={'ready':ready,'cached':bool(cached),'engine':a.engine,'missing':missing,'prereqs':prereqs,'warnings':warnings,'voiceover':voiceover,'fingerprint':fingerprint,'details':details,'browser':browser, + 'next':next_step} state.parent.mkdir(exist_ok=True);state.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n') print(json.dumps({k:v for k,v in report.items() if k!='details'},ensure_ascii=False,indent=2)) sys.exit(0 if ready else 1) diff --git a/skills/guizang-product-video-skill/tests/test_regressions.py b/skills/guizang-product-video-skill/tests/test_regressions.py index aa92488..c7dc872 100644 --- a/skills/guizang-product-video-skill/tests/test_regressions.py +++ b/skills/guizang-product-video-skill/tests/test_regressions.py @@ -3,6 +3,7 @@ import importlib.util import io import json +import os from pathlib import Path import tempfile import subprocess @@ -153,7 +154,7 @@ def mix_with(gain): class Preflight(unittest.TestCase): - def probe(self,project,launch_ok=True): + def probe(self,project,launch_ok=True,extra=()): def fake_run(args,cwd=None,timeout=30): out='' if '--version' in args:out='v22.0.0' if 'node' in args[0] else '10.0.0' @@ -168,7 +169,7 @@ def fake_run(args,cwd=None,timeout=30): self.assertNotIn('executablePath()',js) out=json.dumps({k:{'version':'1.0','path':'/unavailable/'+k} for k in ['playwright','esbuild','react','react-dom']}) return {'ok':True,'stdout':out,'stderr':'','output':out} - with patch.object(environment,'run',side_effect=fake_run),patch.object(environment.shutil,'which',side_effect=lambda n:'/bin/'+n),patch('sys.argv',['check_environment','--project',str(project)]),contextlib.redirect_stdout(io.StringIO()) as output: + with patch.object(environment,'run',side_effect=fake_run),patch.object(environment.shutil,'which',side_effect=lambda n:'/bin/'+n),patch('sys.argv',['check_environment','--project',str(project),*extra]),contextlib.redirect_stdout(io.StringIO()) as output: with self.assertRaises(SystemExit) as caught:environment.main() return caught.exception.code,json.loads(output.getvalue()) def test_headless_only_and_cache_revalidation(self): @@ -176,6 +177,43 @@ def test_headless_only_and_cache_revalidation(self): code,result=self.probe(Path(d));self.assertEqual(code,0);self.assertTrue(result['ready']);self.assertTrue(result['browser']['launched']) code,result=self.probe(Path(d));self.assertTrue(result['cached']) code,result=self.probe(Path(d),False);self.assertEqual(code,1);self.assertFalse(result['cached']);self.assertIn('browser-launch',result['missing']) + def test_narration_decision_gates_the_key_and_nothing_else(self): + # The key depends on the step-1 answer, not on the machine, so it is reported + # separately and must never be read as a broken toolchain. + with tempfile.TemporaryDirectory() as d: + project=Path(d) + with patch.dict(os.environ,{},clear=False): + os.environ.pop('EASYROUTER_API_KEY',None) + code,result=self.probe(project) + self.assertEqual(code,0);self.assertEqual(result['prereqs'],[]) + self.assertEqual(result['voiceover'],{'requested':False,'keyResolved':False,'keySource':None}) + code,result=self.probe(project,extra=['--voiceover','no']) + self.assertEqual(result['prereqs'],[]);self.assertFalse(result['warnings']) + code,result=self.probe(project,extra=['--voiceover','undecided']) + self.assertEqual(code,0);self.assertEqual(result['prereqs'],[]) + self.assertTrue(any('undecided' in w for w in result['warnings'])) + code,result=self.probe(project,extra=['--voiceover','yes']) + self.assertEqual(code,1);self.assertEqual(result['prereqs'],['easyrouter-api-key']);self.assertFalse(result['ready']) + self.assertNotIn('easyrouter-api-key',result['missing']) + self.assertTrue(result['browser']['launched']) + self.assertIn('Ask the user',result['next']) + def test_narration_key_resolution_never_echoes_the_secret(self): + with tempfile.TemporaryDirectory() as d: + project=Path(d);secret='sk-must-not-appear-anywhere' + (project/'.env').write_text('# comment\nOTHER=1\nEASYROUTER_API_KEY="%s"\n'%secret) + with patch.dict(os.environ,{},clear=False): + os.environ.pop('EASYROUTER_API_KEY',None) + code,result=self.probe(project,extra=['--voiceover','yes']) + self.assertEqual(code,0) + self.assertEqual(result['voiceover'],{'requested':True,'keyResolved':True,'keySource':'.env'}) + self.assertNotIn(secret,json.dumps(result,ensure_ascii=False)) + self.assertNotIn(secret,(project/'evidence/environment.json').read_text()) + with patch.dict(os.environ,{'EASYROUTER_API_KEY':' '+secret+' '}): + code,result=self.probe(project,extra=['--voiceover','yes']) + self.assertEqual(result['voiceover']['keySource'],'environment') + self.assertNotIn(secret,json.dumps(result,ensure_ascii=False)) + + class Starter(unittest.TestCase): def test_default_repo_and_unedited_demo_promotion(self): with tempfile.TemporaryDirectory() as d: