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 +1 -1
- package/vendor/docs/contract-2026-08-02-preview-parity.md +36 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-lint/README.md +13 -2
- package/vendor/packages/edit-lint/src/edit-lint.mjs +231 -15
- package/vendor/skills/analyze-footage/bin/transcribe-sa.mjs +62 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
222
|
+
const helperResult = runHelper(options.helperBin, options.input);
|
|
223
|
+
|
|
167
224
|
let helperJson = null;
|
|
168
225
|
try {
|
|
169
|
-
helperJson = JSON.parse(
|
|
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
|
-
|
|
244
|
+
writeJson(options.output, { backend: "speechanalyzer", available: true, segments: normalized.segments });
|
|
187
245
|
}
|
|
188
246
|
|
|
189
247
|
main();
|