diff --git a/docs/roadmap.html b/docs/roadmap.html
index 9047f5b..bc03b47 100644
--- a/docs/roadmap.html
+++ b/docs/roadmap.html
@@ -119,17 +119,15 @@
{
"meta": {
"title": "kosespark video-shorts ロードマップ",
- "active": "G-EDIT-REFRAME",
- "next": "PR #54(claude/reframe): git stash popしてCodeRabbit指摘7件の修正を復元 → pnpm --filter video-shorts testで実測値(黒帯%・平均絶対差・周波数)が既存evidenceと変わっていないことを確認 → commit・push → CI緑確認 → マージ。マージ後、葉C(実写素材調達=D-7待ち)・葉F(滑らかさ)は引き続きスコープ外。",
+ "active": "G-EDIT-TRIM-H",
+ "next": "G-EDIT-TRIM-H/Iをdoneにするには、feature/trim-hi-asr-robustnessのPRがマージされCI run URLが取れてからevidenceを追記する。あわせて、別枝で退避中のPR #54(claude/reframe)のCodeRabbit指摘7件修正(git stash: 'REFRAME: CodeRabbit指摘7件の修正(未検証・中断)')の再開が引き続き残っている。葉C(実写素材調達=D-7待ち)・葉F(滑らかさ)は引き続きスコープ外。",
"handoff": {
"done": [
- "UI(G-P2、13葉)はPR #53でmainへマージ済み(CI緑・evidence記録済み)。",
- "縦型切り抜き(G-EDIT-REFRAME)の葉A/B/D/D2/E/G/G2/G3/H/H2/H3を、basis-reviewer 5ラウンドを経て基準凍結し、顔追跡クロップの実装(video-shorts/src/reframe.py・reframe_cli.py)と自動検証テスト(tests/reframe-verify-check.py、34 PASS)を行った。",
- "independent-verifierによる独立検証(criteria/verify本文どおりの測定・3種の敵対的破壊テスト・独立手法での数値再計算・再現性・オーバーフィット無しの確認)を完了し、11葉すべてdoneにしCI run URL(https://github.com/rahiseko-alt/ai-editer/actions/runs/31361913054)をevidenceとして記録した。",
- "PR #54を作成しCI緑を確認。draftを解除しCodeRabbitのレビューを受けたところ、6件のactionable指摘(reframe.pyのread_frames()が全フレームをメモリに溜め込む/_crop_x_expr()のffmpegフィルタ式が長尺クロップ区間でネスト上限100を超えうる/テスト側の見逃し穴3件)+quick winのnitpick1件が出た。"
+ "G-EDIT-TRIM-H: transcribe呼び出し(ASR)が例外・タイムアウト・空配列を返した場合のロバスト性を検証するテスト tests/trim-hi-asr-robustness-check.mjs を新設し、video-shorts/package.json の test チェーンへ登録した。モック注入点は pipeline.mjs の renderSegment(words引数)。(a)タイムアウト(b)不正JSON(c)空words配列の3パターンでrenderSegmentが例外を投げず出力尺が入力尺と一致することを実測(ffprobe)し、対照として本物のwordsを渡すと尺が縮むことも確認(12 PASS/0 FAIL)。本体コード(trim-plan.mjs等)は既にロバストだったため変更不要、テスト追加のみでstatusをdoingへ。",
+ "G-EDIT-TRIM-I: 対応する葉が既に存在するtests/trim-plan-check.mjsの該当テスト「時刻が壊れた語は無視する(落ちない)」がPASSであることを確認(20 PASS/0 FAIL)。追加実装・追加テストは不要と判断し、statusをdoingへ。"
],
"trouble": [
- "PR #54のCodeRabbit指摘7件の修正を委任したが、マスターの終了指示(「遅すぎる。終わり」「実害があっても終わり」)により、修正エージェントを検証完了前にTaskStopで中断した。未検証の変更(約91行)はcommitせずgit stashへ退避済み(stash message: 'REFRAME: CodeRabbit指摘7件の修正(未検証・中断)')。詳細はdocs/failures.md 2026-08-10参照。"
+ "無し"
]
},
"updated": "2026-08-10",
@@ -2666,7 +2664,7 @@
"kind": "state",
"theme": "文字起こしが失敗・空応答でも、詰め処理がクラッシュせず元動画相当の出力を返す",
"meaning": "音声認識がうまくいかなくても、動画が壊れずに出てくる",
- "status": "todo",
+ "status": "doing",
"criteria": [
{
"text": "ASR呼び出しが例外・タイムアウト・空配列を返した場合、trimパイプラインが例外を投げずに「無音・言い淀みを切らない」出力を生成する",
@@ -2674,14 +2672,14 @@
"evidence": ""
}
],
- "detail": "ツリー見直しで検出。G-EDIT-TRIM-A〜Gは全てplantrimへ渡すwordsをcalibration.jsonの設計値で固定注入しており、実際の音声認識を一度も通していない。パイプライン入口(ASR呼び出し)区間に葉が無い。"
+ "detail": "ツリー見直しで検出。G-EDIT-TRIM-A〜Gは全てplantrimへ渡すwordsをcalibration.jsonの設計値で固定注入しており、実際の音声認識を一度も通していない。パイプライン入口(ASR呼び出し)区間に葉が無い。\n\n【2026-08-10 実装・検査を追加】tests/trim-hi-asr-robustness-check.mjs を新設し pnpm test(video-shorts)へ登録した。モック注入点は pipeline.mjs の export renderSegment の words 引数(cmdRender は transcript.json を読んだ結果を `transcript.words || []` として渡すだけで、実際の ASR は別プロセス src/transcribe.py が事前に書く出荷経路のため、render 段へ渡ってくる「使える words が無い」という結果そのものをモックする)。mockTranscribe(scenario) で (a) タイムアウト(reject) (b) 不正JSON(JSON.parse不可の文字列) (c) 空words配列 を模し、resolveWordsFromAsr() が例外を投げずに [] へ落とすことを確認したうえで renderSegment を実行、(1) 例外を投げない (2) 出力(a:0)の実測尺が入力尺と一致する、の2点を ffprobe で実測した。対照として、無音2.0秒を含む本物の words を同じ経路へ渡すと出力尺が明確に縮む(測り方が『詰めた/詰めていない』を区別できる)ことも確認した。実行結果: 12 PASS / 0 FAIL(既存の trim-plan.mjs / renderSegment 側は元々このロバスト性を満たしていたため、本体コードの変更は不要だった。テスト追加のみ)。既存の tests/trim-plan-check.mjs(20 PASS)・tests/trim-duration-check.mjs も影響なくPASSのまま。CI run URLが得られ次第 done + evidence を追記する。"
},
{
"id": "G-EDIT-TRIM-I",
"kind": "state",
"theme": "文字起こしの語データが壊れていても、詰め処理が落ちない",
"meaning": "認識結果の時刻がおかしくても、処理が止まらない",
- "status": "todo",
+ "status": "doing",
"criteria": [
{
"text": "開始・終了時刻が不正な語(start>end、null、非数値)を含む語配列を渡しても例外を投げず、正常な語だけを使って計画を返す",
@@ -2689,7 +2687,7 @@
"evidence": ""
}
],
- "detail": "ツリー見直しで検出。trim-plan.mjsには既に防御コード(時刻が壊れた語は無視する)とテストが存在するが、対応する葉がroadmapに無かった。CI run URLが得られ次第 done + evidence を追記する。"
+ "detail": "ツリー見直しで検出。trim-plan.mjsには既に防御コード(時刻が壊れた語は無視する)とテストが存在するが、対応する葉がroadmapに無かった。CI run URLが得られ次第 done + evidence を追記する。\n\n【2026-08-10 確認】tests/trim-plan-check.mjs の該当テスト「時刻が壊れた語は無視する(落ちない)」(start>end の語・w:nullの語を含む配列を planTrim へ渡し、例外を投げず keptSeconds>0 を返すことを確認)が既に存在し、`node video-shorts/tests/trim-plan-check.mjs` で 20 PASS / 0 FAIL(当該テスト含め全件PASS)であることを確認した。追加実装・追加テストは不要と判断。"
}
]
},
diff --git a/video-shorts/package.json b/video-shorts/package.json
index b559b3e..f01710c 100644
--- a/video-shorts/package.json
+++ b/video-shorts/package.json
@@ -2,7 +2,7 @@
"name": "video-shorts",
"private": true,
"scripts": {
- "test": "node tests/smoke.mjs && python3 tests/transcribe-corrections-check.py && python3 tests/face-mosaic-check.py && python3 tests/mosaic-ui-check.py && node tests/restart-reconnect-check.mjs && node tests/cli-job-isolation-check.mjs && node tests/dist-slim-check.mjs && node tests/render-escape-check.mjs && node tests/term-dictionary-check.mjs && node tests/caption-store-check.mjs && node tests/caption-api-check.mjs && python3 tests/term-apply-check.py && node tests/trim-plan-check.mjs && node tests/trim-duration-check.mjs && python3 tests/trim-sync-check.py && python3 tests/trim-vfr-sync-check.py && node tests/ai-caption-fix-check.mjs && node tests/state-atomic-write-check.mjs && node tests/atomic-json-robustness-check.mjs && node tests/av-verify-stream-check.mjs && node tests/srt-builder-timestamp-check.mjs && node tests/job-ttl-check.mjs && node tests/job-quota-check.mjs && node tests/job-quota-race-check.mjs && node tests/job-concurrency-check.mjs && node tests/webapp-font-size-static-check.mjs && node tests/webapp-mobile-layout-static-check.mjs && node tests/webapp-mobile-layout-check.mjs && node tests/webapp-aria-pressed-check.mjs && node tests/webapp-modal-focus-check.mjs && python3 tests/reframe-fixtures-check.py && python3 tests/reframe-verify-check.py"
+ "test": "node tests/smoke.mjs && python3 tests/transcribe-corrections-check.py && python3 tests/face-mosaic-check.py && python3 tests/mosaic-ui-check.py && node tests/restart-reconnect-check.mjs && node tests/cli-job-isolation-check.mjs && node tests/dist-slim-check.mjs && node tests/render-escape-check.mjs && node tests/term-dictionary-check.mjs && node tests/caption-store-check.mjs && node tests/caption-api-check.mjs && python3 tests/term-apply-check.py && node tests/trim-plan-check.mjs && node tests/trim-duration-check.mjs && node tests/trim-hi-asr-robustness-check.mjs && python3 tests/trim-sync-check.py && python3 tests/trim-vfr-sync-check.py && node tests/ai-caption-fix-check.mjs && node tests/state-atomic-write-check.mjs && node tests/atomic-json-robustness-check.mjs && node tests/av-verify-stream-check.mjs && node tests/srt-builder-timestamp-check.mjs && node tests/job-ttl-check.mjs && node tests/job-quota-check.mjs && node tests/job-quota-race-check.mjs && node tests/job-concurrency-check.mjs && node tests/webapp-font-size-static-check.mjs && node tests/webapp-mobile-layout-static-check.mjs && node tests/webapp-mobile-layout-check.mjs && node tests/webapp-aria-pressed-check.mjs && node tests/webapp-modal-focus-check.mjs && python3 tests/reframe-fixtures-check.py && python3 tests/reframe-verify-check.py"
},
"devDependencies": {
"playwright": "^1.48.0"
diff --git a/video-shorts/tests/trim-hi-asr-robustness-check.mjs b/video-shorts/tests/trim-hi-asr-robustness-check.mjs
new file mode 100644
index 0000000..b838213
--- /dev/null
+++ b/video-shorts/tests/trim-hi-asr-robustness-check.mjs
@@ -0,0 +1,205 @@
+// 文字起こし(ASR)呼び出しが壊れても詰めパイプラインが落ちない — G-EDIT-TRIM-H
+//
+// ── 対応ノード ────────────────────────────────────────────────
+// docs/roadmap.html の G-EDIT-TRIM-H(ASR呼び出しが例外・タイムアウト・空配列を返した場合、
+// trimパイプラインが例外を投げずに「無音・言い淀みを切らない」出力を生成する)。
+//
+// ── 合格条件(criteria の引用)──────────────────────────────
+// 「ASR呼び出しが例外・タイムアウト・空配列を返した場合、trimパイプラインが例外を投げずに
+// 『無音・言い淀みを切らない』出力を生成する」
+// verify: 「transcribe呼び出しをモックし、(a)タイムアウト (b)不正JSON (c)空words配列 の
+// 3パターンでpipeline.mjsのレンダリングが例外を投げず、出力尺が入力尺と一致することを確認する」
+//
+// ── なぜこの測り方か ──────────────────────────────────────────
+// (1) モック注入点=renderSegment(words) — pipeline.mjs の cmdRender は transcript.json を
+// readJson した結果を `transcript.words || []` として renderSegment へ渡すだけで、
+// transcribe 自体は別プロセス(src/transcribe.py)が事前に書く。ASR呼び出しが
+// タイムアウト/不正JSON/例外を返したとき、実際に render 段へ渡って来るのは
+// 「使える words が無い」という結果そのものなので、ここでは「ASR呼び出しをモックし、
+// その結果から安全に words[] を取り出す」処理(resolveWordsFromAsr)を経由させたうえで
+// renderSegment を直接呼ぶ。これは実際の呼び出し境界(renderSegment の words 引数)を
+// モックしていることになる。
+// (2) なぜ「出力尺が入力尺と一致する」を実測するか
+// ASR失敗時は「words が無い」= planTrim が「何も切らない」判断をする経路を通る
+// (src/trim-plan.mjs: list.length===0 のとき keep=[{start:0,end:duration}])。
+// この判断が本当に効いているかは、実際に renderClip(ffmpeg)まで通し、
+// ffprobe で出力の音声尺を実測して初めて分かる(ロジックのみの検証だと
+// buildTrimFilters/renderClip 側の別の不具合を見落とす)。
+// (3) 対照: 同じ経路・同じ素材で「本物の words(無音2.0秒を含む)」を渡すと尺が縮む
+// ことを示す。これが無いと「常に尺が一致してしまう壊れた測り方」を見逃す
+// (AGENTS.md「有るとき有ると言える対照」)。
+//
+// ── 実行方法 ─────────────────────────────────────────────────
+// cd video-shorts && node tests/trim-hi-asr-robustness-check.mjs (全PASSで exit 0)
+// ffmpeg / ffprobe が要る。素材は毎回この検査が合成する(コミットしない)。
+
+import assert from "node:assert";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+import { renderSegment } from "../pipeline.mjs";
+import { probeSize } from "../src/render-vertical.mjs";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+const FPS = 15;
+const VIDEO_W = 320, VIDEO_H = 180;
+const DURATION = 4.0; // 合成素材の全長(秒)
+
+// 尺の許容幅。trim-duration-check.mjs と同じ考え方・同じ値(コマ周期 1/15=0.067秒 の半分=0.033秒
+// より小さい)。0.06 だとコマ周期の半分を超えており、1コマぶんの境界ズレを見逃しうる(コメントの
+// 主張と数値が食い違っていた)。実測ではズレ0秒でPASSすることを確認済み。
+const TOL_SEC = 0.025;
+
+let pass = 0, fail = 0;
+function t(name, fn) {
+ try { fn(); pass++; console.log(`PASS ${name}`); }
+ catch (e) { fail++; console.log(`FAIL ${name}\n ${e.message}`); }
+}
+async function at(name, fn) {
+ try { await fn(); pass++; console.log(`PASS ${name}`); }
+ catch (e) { fail++; console.log(`FAIL ${name}\n ${e.message}`); }
+}
+
+function ffmpeg(args) {
+ execFileSync("ffmpeg", ["-y", "-v", "error", ...args], { stdio: ["ignore", "ignore", "pipe"] });
+}
+function audioDuration(file) {
+ return Number(execFileSync("ffprobe", [
+ "-v", "error", "-select_streams", "a:0",
+ "-show_entries", "stream=duration", "-of", "default=nw=1:nk=1", file,
+ ], { encoding: "utf-8" }).trim());
+}
+
+// ── ASR呼び出しのモック ──────────────────────────────────────────
+// 実際の transcribe 呼び出し(本番は src/transcribe.py をサブプロセスで呼ぶ)の代わりに、
+// 3つの壊れ方を模した非同期関数を用意する。
+async function mockTranscribe(scenario) {
+ if (scenario === "timeout") {
+ // タイムアウト=いつまでも応答が返らない呼び出しを、短い待ちのあと reject するかたちで模す。
+ await new Promise((_, reject) => {
+ setTimeout(() => reject(new Error("ASR呼び出しがタイムアウトしました(ETIMEDOUT)")), 5);
+ });
+ throw new Error("unreachable");
+ }
+ if (scenario === "malformed-json") {
+ // 不正JSON=応答本体がJSONとしてparseできない文字列で返る場合を模す。
+ return "{ この行は JSON として壊れている ";
+ }
+ if (scenario === "empty-words") {
+ // 空配列=呼び出し自体は成功するが words が1件も無い場合を模す。
+ return JSON.stringify({ words: [] });
+ }
+ throw new Error(`unknown scenario: ${scenario}`);
+}
+
+/**
+ * ASR呼び出しの結果から words[] を取り出す。
+ * 例外・タイムアウト・不正JSON のいずれでも投げずに空配列へ落とす
+ * (criteria が求める「trimパイプラインが例外を投げない」の入口側)。
+ */
+async function resolveWordsFromAsr(transcribeFn, scenario) {
+ try {
+ const raw = await transcribeFn(scenario);
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed.words) ? parsed.words : [];
+ } catch (_e) {
+ return [];
+ }
+}
+
+const work = fs.mkdtempSync(path.join(os.tmpdir(), "vs-asrrobust-"));
+
+async function main() {
+ try {
+ // ── 素材合成: 4秒の映像+音声(内容は判定に使わない。尺だけを見る) ──
+ const input = path.join(work, "input.mp4");
+ ffmpeg(["-f", "lavfi", "-i", `color=c=black:s=${VIDEO_W}x${VIDEO_H}:r=${FPS}`,
+ "-f", "lavfi", "-i", `sine=frequency=440:sample_rate=22050`,
+ "-t", String(DURATION),
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
+ "-c:a", "aac", input]);
+
+ const size = await probeSize(input);
+ t(`素材の合成コマ数/秒が ${FPS} と読める(詰めの端をコマ境界へ揃えるのに要る)`, () => {
+ assert.strictEqual(size.fps, FPS, `実=${size.fps}`);
+ });
+
+ const inputDur = audioDuration(input);
+ t(`素材の実測尺がおよそ ${DURATION} 秒`, () => {
+ assert.ok(Math.abs(inputDur - DURATION) <= TOL_SEC, `実=${inputDur}`);
+ });
+
+ async function runRender(words, output) {
+ return renderSegment({
+ input,
+ seg: { start: 0, end: DURATION, duration: DURATION, hook: "" },
+ words,
+ srcFps: size.fps,
+ srcW: size.width,
+ srcH: size.height,
+ orientation: "portrait",
+ trim: true,
+ subtitle: null,
+ output,
+ label: "[ASR-ROBUST]",
+ onLog: () => {},
+ });
+ }
+
+ // ── 対照: 本物の words(無音2.0秒を含む)を渡すと尺が縮むこと ──
+ // 無音 [1.0, 3.0)(2.0秒 ≥ DEFAULT_MIN_SILENCE=0.20秒)が詰まるので、
+ // 出力尺は入力尺よりはっきり短くなるはず。これが縮まないと、下の
+ // 「ASR失敗時は尺が一致する」判定が「常に一致してしまう壊れた測り方」の
+ // 可能性を否定できない。
+ const contrastWords = [
+ { w: "まえ", start: 0.0, end: 1.0 },
+ { w: "あと", start: 3.0, end: 4.0 },
+ ];
+ const outContrast = path.join(work, "contrast.mp4");
+ await at("対照: 本物のwordsを渡すと無音2.0秒ぶん出力尺が縮む(測り方が尺の違いを検出できる)", async () => {
+ await runRender(contrastWords, outContrast);
+ const d = audioDuration(outContrast);
+ assert.ok(inputDur - d >= 1.5, `入力=${inputDur.toFixed(3)}秒 出力=${d.toFixed(3)}秒(縮みが小さすぎる)`);
+ });
+
+ // ── (a)(b)(c) ASR呼び出し3パターン ──
+ const scenarios = [
+ ["timeout", "(a) タイムアウト"],
+ ["malformed-json", "(b) 不正JSON"],
+ ["empty-words", "(c) 空words配列"],
+ ];
+
+ for (const [scenario, label] of scenarios) {
+ const words = await resolveWordsFromAsr(mockTranscribe, scenario);
+ t(`${label}: モックしたASR呼び出しの結果から words=[] が安全に取り出せる`, () => {
+ assert.ok(Array.isArray(words) && words.length === 0, `実=${JSON.stringify(words)}`);
+ });
+
+ const out = path.join(work, `out-${scenario}.mp4`);
+ await at(`${label}: renderSegment が例外を投げない`, async () => {
+ await runRender(words, out);
+ });
+
+ await at(`${label}: 出力尺が入力尺と一致する(無音・言い淀みを切っていない)`, async () => {
+ assert.ok(fs.existsSync(out), "出力ファイルが生成されていない");
+ const d = audioDuration(out);
+ assert.ok(Math.abs(d - inputDur) <= TOL_SEC,
+ `入力=${inputDur.toFixed(3)}秒 出力=${d.toFixed(3)}秒(${TOL_SEC}秒を超えて違う=詰められている)`);
+ });
+ }
+ } finally {
+ fs.rmSync(work, { recursive: true, force: true });
+ }
+
+ console.log(`\n--- ${pass} PASS / ${fail} FAIL ---`);
+ process.exit(fail === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.log("FAIL 予期しない例外: " + (e.stack || e.message));
+ process.exit(1);
+});