akari-video 0.1.62 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  | 2026-08-28 | v2 | `packages/frame-engine` の意味論へ統合し、検収をゴールデンフレームへ一本化。出口を OSR と GPU 直結の 2 本に固定し、互換経路を退役節へ移動 |
13
13
  | 2026-08-31 | v2.1 | §5.2 に断片 CSS の `vw` / `vh` 系単位の出力サイズ基準化(`viewport-units.js`。プレビューがウィンドウ幅基準で解いていた実機報告の修正)を追記 |
14
14
  | 2026-09-02 | v2.2 | §2.8 に字幕時計の規約(active cue の判定は両プレビューとも出力秒。共有カーネル `caption-clock`)を追記。Web UI を shell に揃えた 4 点(字幕時計・字幕フォント名・`slot-params.js` の差し込み・最下段 cut の track 規則)の記録 |
15
+ | 2026-09-10 | v2.3 | §5.8 に shell 合成面の内部解像度(auto / 手動倍率・停止時等倍)と構図一致の許容差を追加 |
15
16
 
16
17
  ## 1. 役割分担
17
18
 
@@ -346,3 +347,38 @@ frame-engine が MP4 を全体ストリームとして読むか、`ftyp` / `moov
346
347
  Range として読むかは、完成画の意味論に影響しない。どちらの読み込み経路も同じ presentation 時刻の
347
348
  VideoFrame を §4.1 の評価点へ供給し、`elst.media_time`、B フレームの並べ替え、メディア終端を含めて
348
349
  golden の `diff 0` を満たさなければならない。ソース取得方法の変更をパリティ差の許容理由にしてはならない。
350
+
351
+ ### 5.8 プレビュー内部解像度(render scale)
352
+
353
+ shell の frame-engine 合成面は、辺あたり倍率 `s ∈ {1, 0.5, 0.25}` を使える。
354
+ 設定 `akari.preview.renderScale` の語彙は `'auto' | '1' | '0.5' | '0.25'`、既定は `'auto'`。
355
+ 環境変数 `AKARI_FRAME_ENGINE_RENDER_SCALE` は設定より優先し、不正値は `auto` とする。
356
+ 設定項目のみとし、専用ボタンやポップアップは設けない。
357
+
358
+ `auto` は canvas の `getBoundingClientRect()` に `devicePixelRatio` を掛けた表示寸法に対し、
359
+ `output.width × s ≥ 表示幅` かつ `output.height × s ≥ 表示高` を満たす最小の倍率を選ぶ。
360
+ 上限は `1`、表示寸法が 0 の場合も `1` とする。canvas の `ResizeObserver` と
361
+ `matchMedia('(resolution: <dpr>dppx)')` の change で再評価し、100 ms デバウンスする。
362
+ 内部寸法は各辺を `roundEven(output寸法 × s)`(最寄りの偶数へ丸め、中間は上へ)とし、下限は 2 px。
363
+
364
+ `auto` では再生停止・スクラブ終了から 250 ms 後に `s = 1` で 1 コマ描き直し、
365
+ 再生・スクラブの再開で表示寸法に応じた倍率へ戻す。手動倍率は停止中も固定し、停止による追加描画はしない。
366
+ 倍率変更は scheduler・評価 plan・compose が共有する `renderOutput` の width / height を in-place で更新する。
367
+ scheduler を作り直して warmup を捨てず、停止中の寸法更新は 1 コマの再描画へ反映する。
368
+ developerMode の計測パネルに倍率・内部寸法・等倍寸法・モードを 1 行表示する。
369
+
370
+ エンジンのレイヤー / layer-style cut の幾何は出力 px 基準(box = crop × 素材 px × scale、
371
+ 出力中心 + (x, y))なので、shell は評価 plan の px 基準の transform(x / y、および px 基準の scale)を
372
+ `s` で射影してから合成する。fit 基準の通常 cut の scale と正規化座標は不変とし、入力 plan を変更せず、
373
+ `plan.output` は共有参照を保つ。エンジン・ゴールデン検収・書き出しは不変。
374
+
375
+ 一致条件は**構図一致 = 位置・大きさ・時刻・ツマミ値の一致**であり、内部画素数は異なってよい。
376
+ [vgpu レイヤー契約 §4](./contract-2026-09-06-vgpu-layer-v0.md#4-api解像度ツマミ) の例外を
377
+ shell の合成面全体へ拡張する。等倍の完成画を縮小した画像と縮小倍率で描いた画像を比較し、
378
+ `packages/frame-engine/src/metrics/frame-diff.ts` の MAD で **≤ 2.0 / 255** を要求する。
379
+ 粒状ノイズ FX と dissolve 区間はこの比較から除外する。DOM 層(字幕 / overlay / 3D / ペン)、
380
+ `summary.output`、`frameScale`、当たり判定、インスペクター、書き込み経路の規約は不変。
381
+
382
+ **書き出しとゴールデン検収は常に等倍で、本設定の対象外**。GPU / OSR の page-runtime、
383
+ 書き出し receipt、§4.1・§4.2 のゴールデンと許容差、`packages/frame-engine` とその生成バンドルは変更しない。
384
+ Web UI の合成面、デコード解像度、vgpu 固有の `previewScale`、3D の `PREVIEW_3D_MAX_RENDER_SIZE` も対象外とする。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -42,15 +42,26 @@ item. Each source is probed once, and media finding paths include the source ID.
42
42
  warnings unless `--silence-error-seconds` promotes long intervals to errors.
43
43
  - `media.volume`: reports mean and maximum volume. Findings are warnings unless
44
44
  `--max-volume-error-db` promotes an over-limit maximum to an error.
45
+ - `media.source-range`: errors when a media item asks for a span the source does not have. Every
46
+ lane is covered (visual cuts, `bgm` / `narration` / `sfx` items, nested bag items) plus the v1
47
+ `audio.*` declarations, and the container duration is used so sources without an audio stream are
48
+ checked too. `source.in` at or beyond the duration and an effective `source.out` past the duration
49
+ are both errors; sources whose container duration is unavailable are skipped with a reason.
45
50
  - `media.audio-shorter-than-out`: warns for each visual media item whose effective source `out`
46
- exceeds that source's audio-stream duration. The message includes the missing duration.
51
+ exceeds that source's audio-stream duration while staying inside the container duration (the
52
+ source plays but its audio ends early). The message includes the missing duration. Items that run
53
+ past the container duration are reported by `media.source-range` instead.
47
54
  - `media.caption-silence-coverage`: warns when more than 30% of caption display time overlaps a
48
55
  silence interval of at least one second. `--caption-silence-warn-percent` changes the threshold.
49
56
  - `audio.narration.trim`: warns when a narration media item's `in` is at or beyond its audio-stream
50
- duration. Invalid or reversed narration `in`/`out` values are errors even without `--media`.
57
+ duration but still inside the container duration. Invalid or reversed narration `in`/`out` values
58
+ are errors even without `--media`; an `in` past the container duration is a `media.source-range`
59
+ error.
51
60
 
52
61
  Silence and volume checks are skipped for a source without an audio stream or when ffprobe cannot
53
62
  inspect it. Duration-dependent checks are skipped when the audio-stream duration is unavailable.
63
+ One ffprobe invocation per source path reads both the container duration and the first audio
64
+ stream's duration.
54
65
  Caption/silence coverage runs only when the captions can be associated with exactly one referenced
55
66
  visual source: either every caption declares the same `src`, or the timeline references a single
56
67
  visual source and captions do not contradict it. Missing, empty, or multi-source/ambiguous captions
@@ -380,6 +380,7 @@ export async function lintProject(input, options = {}) {
380
380
  validateIntake(intakeState.value, findings, paths);
381
381
  }
382
382
  await lintDecisionLogPredict({ projectRoot: paths.projectRoot, intake: intakeState.value, edit: rawEdit, findings });
383
+ runSourceVfrChecks(rawEdit, edit, findings, paths);
383
384
 
384
385
  if (options.media) {
385
386
  runReferencedMediaChecks(
@@ -5638,10 +5639,27 @@ function runReferencedMediaChecks(rawEdit, projectedEdit, findings, skipped, pat
5638
5639
  const visualSourceIds = new Set();
5639
5640
  const visualCuts = [];
5640
5641
  const narrationItems = [];
5642
+ // media.source-range はレーンを問わず(袋・グループの入れ子も含めて)全 media item を見る。
5643
+ // 既存 3 検査の入力(referencedSourceIds / visualCuts / narrationItems)は変えない。
5644
+ const rangeItems = [];
5645
+ const rangeSourceIds = new Set();
5641
5646
 
5642
5647
  if (rawEdit?.version === 2 && Array.isArray(rawEdit.tracks)) {
5643
5648
  for (const [trackIndex, track] of rawEdit.tracks.entries()) {
5644
5649
  if (!isRecord(track) || !Array.isArray(track.items)) continue;
5650
+ const collectRange = (items, pathPrefix) => {
5651
+ for (const [itemIndex, item] of items.entries()) {
5652
+ if (!isRecord(item)) continue;
5653
+ const itemPath = `${pathPrefix}[${itemIndex}]`;
5654
+ if (isRecord(item.source) && item.source.kind === "media"
5655
+ && isNonEmptyString(item.source.src)) {
5656
+ rangeItems.push({ item, sourceId: item.source.src, itemPath });
5657
+ rangeSourceIds.add(item.source.src);
5658
+ }
5659
+ if (Array.isArray(item.items)) collectRange(item.items, `${itemPath}.items`);
5660
+ }
5661
+ };
5662
+ collectRange(track.items, `edit.json#tracks[${trackIndex}].items`);
5645
5663
  for (const [itemIndex, item] of track.items.entries()) {
5646
5664
  if (!isRecord(item) || !isRecord(item.source) || item.source.kind !== "media"
5647
5665
  || !isNonEmptyString(item.source.src)) continue;
@@ -5661,11 +5679,11 @@ function runReferencedMediaChecks(rawEdit, projectedEdit, findings, skipped, pat
5661
5679
  if (!isRecord(cut) || !isNonEmptyString(cut.src)) continue;
5662
5680
  referencedSourceIds.add(cut.src);
5663
5681
  visualSourceIds.add(cut.src);
5664
- visualCuts.push({
5665
- item: { id: cut.id ?? `cut-${index + 1}`, source: cut },
5666
- sourceId: cut.src,
5667
- itemPath: `edit.json#cuts[${index}]`,
5668
- });
5682
+ const itemPath = `edit.json#cuts[${index}]`;
5683
+ const projectedItem = { id: cut.id ?? `cut-${index + 1}`, source: cut };
5684
+ visualCuts.push({ item: projectedItem, sourceId: cut.src, itemPath });
5685
+ rangeItems.push({ item: projectedItem, sourceId: cut.src, itemPath });
5686
+ rangeSourceIds.add(cut.src);
5669
5687
  }
5670
5688
  }
5671
5689
 
@@ -5706,13 +5724,24 @@ function runReferencedMediaChecks(rawEdit, projectedEdit, findings, skipped, pat
5706
5724
  );
5707
5725
  }
5708
5726
 
5727
+ // range 検査だけが参照する source(入れ子・音声レーン)も同じ path キャッシュで probe する。
5728
+ // 既存検査(silence / volume)の対象集合は referencedSourceIds のままで変えない。
5729
+ for (const sourceId of rangeSourceIds) {
5730
+ if (probeBySourceId.has(sourceId)) continue;
5731
+ const source = sourcesById.get(sourceId);
5732
+ if (!source || !isNonEmptyString(source.path)) continue;
5733
+ probeBySourceId.set(sourceId, probeForPath(resolveReference(paths.editPath, source.path, paths)));
5734
+ }
5735
+
5736
+ const fps = projectedEdit?.output?.fps;
5709
5737
  validateVisualAudioDuration(
5710
5738
  visualCuts,
5711
5739
  probeBySourceId,
5712
5740
  findings,
5713
5741
  skipped,
5714
- projectedEdit?.output?.fps,
5742
+ fps,
5715
5743
  );
5744
+ validateMediaSourceRange(rangeItems, probeBySourceId, sourcesById, findings, skipped, fps);
5716
5745
  validateNarrationMediaStart(narrationItems, probeBySourceId, findings, skipped);
5717
5746
 
5718
5747
  if (Array.isArray(rawEdit?.audio?.narration)) {
@@ -5730,6 +5759,115 @@ function runReferencedMediaChecks(rawEdit, projectedEdit, findings, skipped, pat
5730
5759
  );
5731
5760
  }
5732
5761
  }
5762
+
5763
+ // v1 形の audio 宣言(path 直指定)も同じ実尺で見る。bgm は schema に out が無いので in だけ。
5764
+ for (const key of ["narration", "sfx"]) {
5765
+ const entries = rawEdit?.audio?.[key];
5766
+ if (!Array.isArray(entries)) continue;
5767
+ for (const [index, item] of entries.entries()) {
5768
+ if (!isRecord(item) || !isNonEmptyString(item.path)) continue;
5769
+ const probe = probeForPath(resolveReference(paths.editPath, item.path, paths));
5770
+ if (!isPositiveNumber(probe?.containerDuration)) continue;
5771
+ addSourceRangeFindings({
5772
+ label: isNonEmptyString(item.id) ? item.id : `${key}-${index + 1}`,
5773
+ duration: probe.containerDuration,
5774
+ inSeconds: isFiniteNumber(item.in) ? item.in : 0,
5775
+ outSeconds: isFiniteNumber(item.out) ? item.out : null,
5776
+ pathPrefix: `edit.json#audio.${key}[${index}]`,
5777
+ pathSuffix: "",
5778
+ findings,
5779
+ });
5780
+ }
5781
+ }
5782
+ const declaredBgm = rawEdit?.audio?.bgm;
5783
+ if (isRecord(declaredBgm) && isNonEmptyString(declaredBgm.path)) {
5784
+ const probe = probeForPath(resolveReference(paths.editPath, declaredBgm.path, paths));
5785
+ if (isPositiveNumber(probe?.containerDuration)) {
5786
+ addSourceRangeFindings({
5787
+ label: "bgm",
5788
+ duration: probe.containerDuration,
5789
+ inSeconds: isFiniteNumber(declaredBgm.in) ? declaredBgm.in : 0,
5790
+ outSeconds: isFiniteNumber(declaredBgm.out) ? declaredBgm.out : null,
5791
+ pathPrefix: "edit.json#audio.bgm",
5792
+ pathSuffix: "",
5793
+ findings,
5794
+ });
5795
+ }
5796
+ }
5797
+ }
5798
+
5799
+ function runSourceVfrChecks(rawEdit, projectedEdit, findings, paths) {
5800
+ const declaredSources = Array.isArray(rawEdit?.sources)
5801
+ ? rawEdit.sources
5802
+ : Array.isArray(projectedEdit?.sources) ? projectedEdit.sources : [];
5803
+ const sourcesById = new Map(declaredSources
5804
+ .filter((source) => isRecord(source) && isNonEmptyString(source.id))
5805
+ .map((source) => [source.id, source]));
5806
+ const referencedSourceIds = new Set();
5807
+ if (rawEdit?.version === 2 && Array.isArray(rawEdit.tracks)) {
5808
+ for (const track of rawEdit.tracks) {
5809
+ if (!isRecord(track) || !Array.isArray(track.items)) continue;
5810
+ for (const item of track.items) {
5811
+ if (isRecord(item) && isRecord(item.source) && item.source.kind === "media"
5812
+ && isNonEmptyString(item.source.src)) {
5813
+ referencedSourceIds.add(item.source.src);
5814
+ }
5815
+ }
5816
+ }
5817
+ } else if (Array.isArray(projectedEdit?.cuts)) {
5818
+ for (const cut of projectedEdit.cuts) {
5819
+ if (isRecord(cut) && isNonEmptyString(cut.src)) referencedSourceIds.add(cut.src);
5820
+ }
5821
+ }
5822
+ for (const sourceId of referencedSourceIds) {
5823
+ const source = sourcesById.get(sourceId);
5824
+ if (source && isNonEmptyString(source.path)) {
5825
+ addSourceVfrFinding(source, declaredSources.indexOf(source), findings, paths);
5826
+ }
5827
+ }
5828
+ }
5829
+
5830
+ function addSourceVfrFinding(source, sourceIndex, findings, paths) {
5831
+ const analysisPath = sourceAnalysisPath(paths.projectRoot, source.path);
5832
+ if (analysisPath === null) return;
5833
+ let timing;
5834
+ try {
5835
+ timing = JSON.parse(readFileSync(analysisPath, "utf8"))?.probe?.video?.frame_timing;
5836
+ } catch {
5837
+ return;
5838
+ }
5839
+ if (!isRecord(timing) || timing.mode !== "vfr") return;
5840
+ const irregularDeltas = Number.isFinite(timing.irregular_deltas) ? timing.irregular_deltas : 0;
5841
+ const maxDeviationMs = Number.isFinite(timing.max_deviation_ms) ? timing.max_deviation_ms : 0;
5842
+ const cumulativeDriftMs = Number.isFinite(timing.cumulative_drift_ms)
5843
+ ? timing.cumulative_drift_ms
5844
+ : 0;
5845
+ const nominalFrameMs = Number.isFinite(timing.nominal_frame_ms) ? timing.nominal_frame_ms : 0;
5846
+ let message = `この素材は可変フレームレートです(ぶれ ${formatNumber(irregularDeltas)} 回・最大 ${formatNumber(maxDeviationMs)} ms)。最近傍で写像しています`;
5847
+ if (nominalFrameMs > 0 && Math.abs(cumulativeDriftMs) > nominalFrameMs / 2) {
5848
+ message += "。固定フレームレートに変換すると音ズレを防げます(任意)";
5849
+ }
5850
+ addFinding(findings, {
5851
+ severity: "warning",
5852
+ check: "source.vfr",
5853
+ message,
5854
+ path: `edit.json#sources[${sourceIndex}].path`,
5855
+ });
5856
+ }
5857
+
5858
+ function sourceAnalysisPath(projectRoot, sourcePath) {
5859
+ if (!isNonEmptyString(sourcePath) || isAbsolute(sourcePath)) return null;
5860
+ const resolvedSource = resolve(projectRoot, sourcePath);
5861
+ const projectRelative = relative(projectRoot, resolvedSource);
5862
+ if (projectRelative.startsWith("..") || isAbsolute(projectRelative)) return null;
5863
+ const posixRelative = projectRelative.split("\\").join("/");
5864
+ return join(
5865
+ projectRoot,
5866
+ ".akari",
5867
+ "sidecars",
5868
+ `${posixRelative}.analysis`,
5869
+ "analysis.json",
5870
+ );
5733
5871
  }
5734
5872
 
5735
5873
  function bindCaptionsToVisualSource(captionsRoot, visualSourceIds) {
@@ -5769,6 +5907,17 @@ function bindCaptionsToVisualSource(captionsRoot, visualSourceIds) {
5769
5907
  };
5770
5908
  }
5771
5909
 
5910
+ /** item が要求する素材終端(秒)。out が無ければフレーム尺と speed から導く。 */
5911
+ function effectiveSourceOut(item, fps) {
5912
+ const source = item?.source;
5913
+ const inSeconds = isFiniteNumber(source?.in) ? source.in : 0;
5914
+ if (isFiniteNumber(source?.out)) return source.out;
5915
+ if (Number.isInteger(item?.duration) && isPositiveNumber(fps)) {
5916
+ return inSeconds + (item.duration / fps) * (isPositiveNumber(source?.speed) ? source.speed : 1);
5917
+ }
5918
+ return null;
5919
+ }
5920
+
5772
5921
  function validateVisualAudioDuration(visualCuts, probeBySourceId, findings, skipped, fps) {
5773
5922
  const unavailableSourceIds = new Set();
5774
5923
  for (const { item, sourceId, itemPath } of visualCuts) {
@@ -5777,14 +5926,12 @@ function validateVisualAudioDuration(visualCuts, probeBySourceId, findings, skip
5777
5926
  unavailableSourceIds.add(sourceId);
5778
5927
  continue;
5779
5928
  }
5780
- const source = item.source;
5781
- const inSeconds = isFiniteNumber(source?.in) ? source.in : 0;
5782
- const effectiveOut = isFiniteNumber(source?.out)
5783
- ? source.out
5784
- : Number.isInteger(item.duration) && isPositiveNumber(fps)
5785
- ? inSeconds + (item.duration / fps) * (isPositiveNumber(source?.speed) ? source.speed : 1)
5786
- : null;
5929
+ const effectiveOut = effectiveSourceOut(item, fps);
5787
5930
  if (!isFiniteNumber(effectiveOut) || effectiveOut <= probe.duration + EPSILON) continue;
5931
+ // 素材そのものの実尺を超えているぶんは media.source-range が error で出す。
5932
+ // この検査が見るのは「コンテナ内で音声ストリームだけが先に終わる」素材。
5933
+ if (isPositiveNumber(probe.containerDuration)
5934
+ && effectiveOut > probe.containerDuration + EPSILON) continue;
5788
5935
  const itemId = isNonEmptyString(item.id) ? item.id : "media item";
5789
5936
  addFinding(findings, {
5790
5937
  severity: "warning",
@@ -5802,6 +5949,61 @@ function validateVisualAudioDuration(visualCuts, probeBySourceId, findings, skip
5802
5949
  }
5803
5950
  }
5804
5951
 
5952
+ /**
5953
+ * 実在しない区間の要求を素材のコンテナ実尺で弾く(issue #68)。
5954
+ * media.audio-shorter-than-out は音声ストリーム基準のため、音声を持たない映像と
5955
+ * audio レーンの item(bgm / narration / sfx)が無検査のまま PASS していた。
5956
+ * レーンも入れ子も問わず、in / out を実尺と突き合わせて error にする。
5957
+ */
5958
+ function validateMediaSourceRange(rangeItems, probeBySourceId, sourcesById, findings, skipped, fps) {
5959
+ const unavailableSourceIds = new Set();
5960
+ for (const { item, sourceId, itemPath } of rangeItems) {
5961
+ const probe = probeBySourceId.get(sourceId);
5962
+ if (!probe || !isPositiveNumber(probe.containerDuration)) {
5963
+ if (sourcesById.has(sourceId)) unavailableSourceIds.add(sourceId);
5964
+ continue;
5965
+ }
5966
+ addSourceRangeFindings({
5967
+ label: isNonEmptyString(item.id) ? item.id : "media item",
5968
+ duration: probe.containerDuration,
5969
+ inSeconds: isFiniteNumber(item.source?.in) ? item.source.in : 0,
5970
+ outSeconds: effectiveSourceOut(item, fps),
5971
+ pathPrefix: `${itemPath}.source`,
5972
+ pathSuffix: `[src=${sourceId}]`,
5973
+ findings,
5974
+ });
5975
+ }
5976
+ for (const sourceId of unavailableSourceIds) {
5977
+ addSkipped(
5978
+ skipped,
5979
+ "media.source-range",
5980
+ `source ${sourceId}: container duration is unavailable`,
5981
+ );
5982
+ }
5983
+ }
5984
+
5985
+ function addSourceRangeFindings(
5986
+ { label, duration, inSeconds, outSeconds, pathPrefix, pathSuffix, findings },
5987
+ ) {
5988
+ if (isFiniteNumber(inSeconds) && inSeconds >= duration - EPSILON) {
5989
+ addFinding(findings, {
5990
+ severity: "error",
5991
+ check: "media.source-range",
5992
+ message: `${label}: in=${inSeconds.toFixed(3)}s は素材の実尺 ${duration.toFixed(3)}s 以上です(その先に素材がありません)`,
5993
+ path: `${pathPrefix}.in${pathSuffix}`,
5994
+ });
5995
+ return;
5996
+ }
5997
+ if (isFiniteNumber(outSeconds) && outSeconds > duration + EPSILON) {
5998
+ addFinding(findings, {
5999
+ severity: "error",
6000
+ check: "media.source-range",
6001
+ message: `${label}: out=${outSeconds.toFixed(3)}s が素材の実尺 ${duration.toFixed(3)}s を ${(outSeconds - duration).toFixed(3)}s 超えています(存在しない区間の要求)`,
6002
+ path: `${pathPrefix}.out${pathSuffix}`,
6003
+ });
6004
+ }
6005
+ }
6006
+
5805
6007
  function validateNarrationMediaStart(narrationItems, probeBySourceId, findings, skipped) {
5806
6008
  const unavailableSourceIds = new Set();
5807
6009
  for (const { item, sourceId, itemPath } of narrationItems) {
@@ -5831,6 +6033,9 @@ function validateNarrationMediaStart(narrationItems, probeBySourceId, findings,
5831
6033
  function addNarrationStartWarning(itemId, inSeconds, probe, path, findings) {
5832
6034
  if (probe?.hasAudio !== true || !isPositiveNumber(probe.duration)
5833
6035
  || inSeconds < probe.duration - EPSILON) return;
6036
+ // 素材の実尺そのものを超えているぶんは media.source-range が error で出す。
6037
+ if (isPositiveNumber(probe.containerDuration)
6038
+ && inSeconds >= probe.containerDuration - EPSILON) return;
5834
6039
  addFinding(findings, {
5835
6040
  severity: "warning",
5836
6041
  check: "audio.narration.trim",
@@ -5978,6 +6183,7 @@ function probeMediaAudio(sourcePath, configuredCommand) {
5978
6183
  } catch (error) {
5979
6184
  return {
5980
6185
  hasAudio: null,
6186
+ containerDuration: null,
5981
6187
  reason: `audio stream detection unavailable: ${messageOf(error)}`,
5982
6188
  };
5983
6189
  }
@@ -5989,7 +6195,7 @@ function probeMediaAudio(sourcePath, configuredCommand) {
5989
6195
  "-select_streams",
5990
6196
  "a:0",
5991
6197
  "-show_entries",
5992
- "stream=index,duration",
6198
+ "format=duration:stream=index,duration",
5993
6199
  "-of",
5994
6200
  "json",
5995
6201
  sourcePath,
@@ -5999,6 +6205,7 @@ function probeMediaAudio(sourcePath, configuredCommand) {
5999
6205
  if (result.error) {
6000
6206
  return {
6001
6207
  hasAudio: null,
6208
+ containerDuration: null,
6002
6209
  reason: `audio stream detection unavailable: ${messageOf(result.error)}`,
6003
6210
  };
6004
6211
  }
@@ -6006,6 +6213,7 @@ function probeMediaAudio(sourcePath, configuredCommand) {
6006
6213
  const detail = String(result.stderr || result.stdout || "").trim().split("\n").at(-1);
6007
6214
  return {
6008
6215
  hasAudio: null,
6216
+ containerDuration: null,
6009
6217
  reason: `audio stream detection unavailable: ${detail || `ffprobe exited with status ${result.status}`}`,
6010
6218
  };
6011
6219
  }
@@ -6016,18 +6224,26 @@ function probeMediaAudio(sourcePath, configuredCommand) {
6016
6224
  return {
6017
6225
  hasAudio: null,
6018
6226
  duration: null,
6227
+ containerDuration: null,
6019
6228
  reason: `audio stream detection unavailable: invalid ffprobe JSON (${messageOf(error)})`,
6020
6229
  };
6021
6230
  }
6231
+ // コンテナ実尺は音声ストリームの有無に関わらず取れる(音声なし映像でも format.duration は出る)。
6232
+ // media.source-range はこちらを基準にする。
6233
+ const containerDurationValue = Number(parsed?.format?.duration);
6234
+ const container = isPositiveNumber(containerDurationValue)
6235
+ ? { containerDuration: containerDurationValue }
6236
+ : { containerDuration: null };
6022
6237
  const stream = Array.isArray(parsed?.streams) ? parsed.streams[0] : undefined;
6023
6238
  if (!stream) {
6024
- return { hasAudio: false, reason: "source has no audio stream" };
6239
+ return { hasAudio: false, reason: "source has no audio stream", ...container };
6025
6240
  }
6026
6241
  const duration = Number(stream.duration);
6027
6242
  return {
6028
6243
  hasAudio: true,
6029
6244
  duration: isPositiveNumber(duration) ? duration : null,
6030
6245
  reason: isPositiveNumber(duration) ? null : "audio stream duration is unavailable",
6246
+ ...container,
6031
6247
  };
6032
6248
  }
6033
6249
 
@@ -14,6 +14,15 @@ function printJson(value) {
14
14
  process.stdout.write(`${JSON.stringify(value)}\n`);
15
15
  }
16
16
 
17
+ function writeJson(outputPath, value) {
18
+ const text = `${JSON.stringify(value)}\n`;
19
+ if (outputPath) {
20
+ fs.writeFileSync(outputPath, text, "utf8");
21
+ return;
22
+ }
23
+ process.stdout.write(text);
24
+ }
25
+
17
26
  function summarize(value, fallback) {
18
27
  const text = String(value ?? "").replace(/\s+/g, " ").trim();
19
28
  return text ? text.slice(0, 500) : fallback;
@@ -27,6 +36,48 @@ function spawn(command, args) {
27
36
  }
28
37
  }
29
38
 
39
+ function runHelper(helperBin, inputPath) {
40
+ const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "akari-speech-analyzer-output-"));
41
+ const helperOutputPath = path.join(temporaryDirectory, "stdout.json");
42
+ const helperFd = fs.openSync(helperOutputPath, "w");
43
+ let helperResult = {};
44
+ let helperError = null;
45
+ try {
46
+ helperResult = spawnSync(helperBin, [inputPath], { stdio: ["ignore", helperFd, "pipe"], encoding: "utf8" });
47
+ } catch (error) {
48
+ helperError = error;
49
+ } finally {
50
+ try {
51
+ fs.closeSync(helperFd);
52
+ } catch {
53
+ // noop
54
+ }
55
+ }
56
+
57
+ let stdoutText = "";
58
+ try {
59
+ if (!helperError) {
60
+ stdoutText = fs.readFileSync(helperOutputPath, "utf8");
61
+ }
62
+ } finally {
63
+ try {
64
+ fs.rmSync(temporaryDirectory, { recursive: true, force: true });
65
+ } catch {
66
+ // noop
67
+ }
68
+ }
69
+
70
+ if (helperError) {
71
+ return { error: helperError };
72
+ }
73
+ return {
74
+ status: helperResult.status,
75
+ stderr: helperResult.stderr,
76
+ stdoutText,
77
+ error: helperResult.error,
78
+ };
79
+ }
80
+
30
81
  function checkAvailability() {
31
82
  if (os.platform() !== "darwin") {
32
83
  return { available: false, reason: `macOS ではありません(${os.platform()})` };
@@ -59,7 +110,7 @@ function checkAvailability() {
59
110
  }
60
111
 
61
112
  function parseArguments(argv) {
62
- const result = { check: false, input: null, helperBin: defaultHelperBin };
113
+ const result = { check: false, input: null, helperBin: defaultHelperBin, output: null };
63
114
  for (let index = 0; index < argv.length; index += 1) {
64
115
  const argument = argv[index];
65
116
  if (argument === "--check") {
@@ -70,6 +121,11 @@ function parseArguments(argv) {
70
121
  if (argument === "--input") result.input = path.resolve(value);
71
122
  else result.helperBin = path.resolve(value);
72
123
  index += 1;
124
+ } else if (argument === "--output") {
125
+ const value = argv[index + 1];
126
+ if (!value || value.startsWith("--")) throw new Error(`${argument} の値がありません`);
127
+ result.output = path.resolve(value);
128
+ index += 1;
73
129
  } else {
74
130
  throw new Error(`不明な引数です: ${argument}`);
75
131
  }
@@ -163,13 +219,15 @@ function main() {
163
219
  return;
164
220
  }
165
221
 
166
- const helperResult = spawn(options.helperBin, [options.input]);
222
+ const helperResult = runHelper(options.helperBin, options.input);
223
+
167
224
  let helperJson = null;
168
225
  try {
169
- helperJson = JSON.parse(String(helperResult.stdout ?? ""));
226
+ helperJson = JSON.parse(helperResult.stdoutText || "");
170
227
  } catch {
171
228
  // 非 JSON の stdout は採用しない。
172
229
  }
230
+
173
231
  if (helperResult.error || helperResult.status !== 0 || helperJson === null) {
174
232
  const detail = typeof helperJson?.error === "string"
175
233
  ? helperJson.error
@@ -183,7 +241,7 @@ function main() {
183
241
  if (normalized.droppedSegments > 0 || normalized.droppedWords > 0) {
184
242
  process.stderr.write(`SpeechAnalyzer の無効時刻を除外: segments=${normalized.droppedSegments}, words=${normalized.droppedWords}\n`);
185
243
  }
186
- printJson({ backend: "speechanalyzer", available: true, segments: normalized.segments });
244
+ writeJson(options.output, { backend: "speechanalyzer", available: true, segments: normalized.segments });
187
245
  }
188
246
 
189
247
  main();