akari-video 1.3.0-beta.8 → 1.3.0
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/src/history-policy.mjs +5 -0
- package/vendor/.akari-capability-sources.json +1 -0
- package/vendor/docs/contract-2026-09-24-caption-runs-v0.md +2 -0
- package/vendor/docs/contract-2026-09-24-style-v0.md +2 -0
- package/vendor/docs/contract-2026-10-12-caption-look-v1.md +97 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/akari-launcher/src/history-policy.mjs +5 -0
- package/vendor/packages/edit-lint/src/lint/captions.mjs +19 -5
- package/vendor/packages/edit-lint/src/lint/external.mjs +5 -0
- package/vendor/packages/edit-store/lib/caption-anchor-map.d.ts +51 -0
- package/vendor/packages/edit-store/lib/caption-anchor-map.js +126 -0
- package/vendor/packages/edit-store/lib/caption-display.js +77 -9
- package/vendor/packages/edit-store/lib/caption-hash.d.ts +4 -0
- package/vendor/packages/edit-store/lib/caption-hash.js +69 -0
- package/vendor/packages/edit-store/lib/caption-look-reader.d.ts +48 -0
- package/vendor/packages/edit-store/lib/caption-look-reader.js +339 -0
- package/vendor/packages/edit-store/lib/caption-look-shape.d.ts +18 -0
- package/vendor/packages/edit-store/lib/caption-look-shape.js +134 -0
- package/vendor/packages/edit-store/lib/caption-look-v1.d.ts +37 -0
- package/vendor/packages/edit-store/lib/caption-look-v1.js +156 -0
- package/vendor/packages/edit-store/lib/caption-runs.d.ts +72 -2
- package/vendor/packages/edit-store/lib/caption-runs.js +200 -39
- package/vendor/packages/edit-store/lib/caption-semantics.d.ts +34 -0
- package/vendor/packages/edit-store/lib/caption-semantics.js +123 -0
- package/vendor/packages/edit-store/lib/caption-store.d.ts +5 -0
- package/vendor/packages/edit-store/lib/caption-store.js +36 -11
- package/vendor/packages/edit-store/lib/caption-style-command.d.ts +127 -0
- package/vendor/packages/edit-store/lib/caption-style-command.js +422 -0
- package/vendor/packages/edit-store/lib/caption-style-write.d.ts +57 -0
- package/vendor/packages/edit-store/lib/caption-style-write.js +116 -0
- package/vendor/packages/edit-store/lib/caption-window.js +13 -4
- package/vendor/packages/edit-store/lib/index.d.ts +7 -0
- package/vendor/packages/edit-store/lib/index.js +7 -0
- package/vendor/packages/edit-store/lib/webview-kernel.js +12 -5
- package/vendor/packages/edit-store/lib/write-gate.d.ts +48 -2
- package/vendor/packages/edit-store/lib/write-gate.js +659 -25
- package/vendor/packages/i18n/locales/en/annotations.json +3 -0
- package/vendor/packages/i18n/locales/en/annotationscommon.json +9 -1
- package/vendor/packages/i18n/locales/en/tools.json +2 -0
- package/vendor/packages/i18n/locales/ja/annotations.json +3 -0
- package/vendor/packages/i18n/locales/ja/annotationscommon.json +9 -1
- package/vendor/packages/i18n/locales/ja/tools.json +2 -0
- package/vendor/packages/schemas/bin/validate-captions.mjs +47 -11
- package/vendor/packages/schemas/captions.schema.json +92 -2
- package/vendor/packages/schemas/test/caption-look-shape-parity.test.mjs +73 -0
- package/vendor/packages/schemas/test/caption-look-v1.test.mjs +46 -0
- package/vendor/packages/schemas/test/validate-captions.test.mjs +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.3.0
|
|
3
|
+
"version": "1.3.0",
|
|
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": {
|
package/src/history-policy.mjs
CHANGED
|
@@ -60,6 +60,11 @@ const HISTORY_BLOCK_BODY = [
|
|
|
60
60
|
'.akari/cache/**',
|
|
61
61
|
'.akari/diffs/**',
|
|
62
62
|
'!.akari/diffs/.gitkeep',
|
|
63
|
+
'.akari/write.lock',
|
|
64
|
+
'.akari/write.lock.claim/',
|
|
65
|
+
'.akari/write.lock.wait',
|
|
66
|
+
'.akari/write-journal.json',
|
|
67
|
+
'.akari/work/journal/',
|
|
63
68
|
'',
|
|
64
69
|
'# 素材の分析結果は作り直すのに手間がかかるので、変更履歴に残します。',
|
|
65
70
|
'!.akari/sidecars/**',
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"docs/contract-2026-10-11-content-language.md",
|
|
90
90
|
"docs/contract-2026-10-11-speaker-split-v0.md",
|
|
91
91
|
"docs/contract-2026-10-11-ui-i18n.md",
|
|
92
|
+
"docs/contract-2026-10-12-caption-look-v1.md",
|
|
92
93
|
"packages/akari-ear/package.json",
|
|
93
94
|
"packages/akari-ear/README.md",
|
|
94
95
|
"packages/akari-launcher/package.json",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# 字幕の見た目 v1 — 保存・換算・範囲・時刻・検証
|
|
2
|
+
|
|
3
|
+
- 日付: 2026-10-12
|
|
4
|
+
- 状態: v1
|
|
5
|
+
|
|
6
|
+
## 移行と書き出し
|
|
7
|
+
|
|
8
|
+
移行は主経路(`resolveCaptionPlan` を通る書き出しとプレビュー)で、前後の HTML と CSS 変数を保つ。移行済みの強調範囲は、描画時に `migrated_emphasis` に保存した元の項目と移行前の配列内の位置から `emphasis_words` へ戻す。残った項目の順を保ち、移した項目を位置の昇順に差し込む。追加のプロパティと項目の順序も保つ。旧形式だけの入力はそのまま描画器へ渡す。
|
|
9
|
+
|
|
10
|
+
| `style_hint`(省略時の `emotion`) | `motion.in.id` |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `one-char-bang`(`pain`、`surprise`、`anger`) | `pop` |
|
|
13
|
+
| `one-char-jumble`(`disgust`) | `jitter` |
|
|
14
|
+
| `size-pulse`(`joy`、`emphasis`) | `zoom-pulse` |
|
|
15
|
+
|
|
16
|
+
この対応に無い hint、別の hint と同じ motion になるもの、項目ごとの `style_preset` を持つもの、語が行の表示文字列に一意に現れないもの、時刻が行の外に出るものは `emphasis_words` に残し、通知と未解決一覧に出す。移せたものだけ `fire: "spoken"` と source domain の `anchor` を持つ run にする。
|
|
17
|
+
|
|
18
|
+
v1 の `look` を持つ run は px 系の値を `scale: 1` の値として保存し、描画時に run の `scale` を掛ける。旧 `stroke.width_px` を移す際は `scale` で割った値を丸めずに保存し、描画時に掛けた後だけ px 値を丸める。旧 runs v0 の `style` は保存値を従来どおり直接描く。旧 `animation` は `dormant_animation` に保管し、描画しない。
|
|
19
|
+
|
|
20
|
+
`lookForCaptionRow` は `size: "keep"` なら当て先の実効 `size_px` を明示して保ち、`k = 当て先の size_px ÷ スタイルの size_px` で px 系の値を換算する。当て先の `reference_height_px` と `layout` も保つ。`size: "style"` で `layout` と `reference_height_px` が衝突するときは値を書かず、理由を返す。範囲への適用も行の実効サイズを使い、許可外の項目を落として通知する。
|
|
21
|
+
|
|
22
|
+
## 保存形と互換
|
|
23
|
+
|
|
24
|
+
字幕全体の見た目は `text_style` に値として保存する。新しい書き込みでは `style_preset` を使わない。旧形式の `style_preset` は現在の textstyle カタログで解決して読む。出所は描画に使わず、`style_sources: { look?, motion? }` に記録する。各 Source は `kind` (`builtin` / `library` / `my`)、`id`、`original_hash`、`applied_hash` を持ち、必要に応じて `uid`、`revision`、`applied_at`、`requires` を持つ。元の値と保存後の値を別々に照合できる。当て直しは部品全体の置換である。
|
|
25
|
+
|
|
26
|
+
`runs[]` は表示文字列(`display_text` があればそれ、なければ `text`)の書記素による半開区間 `from` / `to` を持つ。v1 の id は `r-` と 4 桁以上の数字で、ファイル全体で一意にする。新しい id は `unresolved_runs[].run` も含むファイル内の最大番号に 1 を足して作る。任意の `role`、`emotion`、`look`、`motion`、`anchor`、`fire`、`style_sources`、`needs_review`、`split_from`、`migrated_from`、`migrated_emphasis`、`fire_was`、`dormant_animation` を持てる。`migrated_emphasis` は元の `emphasis_words` の `index` と `item` を持つ。範囲の効果音と `effect` は v1 では保存しない。重なる範囲では後の run の項目が勝つ。
|
|
27
|
+
|
|
28
|
+
| 移行で新しく作る run のキー順 | 内容 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `id, from, to, role, emotion, migrated_from, migrated_emphasis, fire, anchor, motion` | 元の項目は `migrated_emphasis.item` にそのまま保存し、`index` で配列内の順序を復元する。 |
|
|
31
|
+
|
|
32
|
+
旧 runs の `style` は読むときに `look` に投影する。ただし表示文字列の書記素数に対して範囲外または空の旧 run は投影も移行もせず、id を付けずに元の形で残し、文書全体の件数を通知する。旧 `animation` は描画されていなかったため `dormant_animation` に保存し、明示的に有効化するまで `motion` にしない。旧 `emphasis_words` は素材・時刻・語から行と文字範囲を一意に決められる場合だけ投影する。移行した項目は `migrated_from` と `migrated_emphasis` を付け、元の `emphasis_words` から除く。複数回の移行でも `migrated_emphasis.index` は最初の配列での位置を使い、既存の移行済み run と重複させない。一意に決まらない項目は残して通知する。未移行の `style_preset` は描画時に解決するため、見た目の固定は移行後から保証される。出所の `original_hash` と `applied_hash` は鍵を再帰的に整列した JSON を UTF-8 のバイト列にして SHA-256 で計算し、`sha256:` と 64 桁の十六進数で記録する。
|
|
33
|
+
|
|
34
|
+
## look と大きさ
|
|
35
|
+
|
|
36
|
+
行の許可項目は `color`、`size_px`、`reference_height_px`、`font_family`、`font_weight`、`weight`、`line_height`、`letter_spacing_em`、`text_transform`、`fill`、`strokes`、`stroke`、`background`、`shadow`、`glow`。範囲の許可項目は `color`、`font_weight`、`italic`、`underline`、`letter_spacing_em`、`scale`、`baseline_shift_em`、`rotate_deg`、`stroke`、`shadow`、`glow`、`background`。範囲に `size_px` と `font_family` は保存しない。許可外の項目は除く。`size_px` と `reference_height_px` は範囲に元から無いため通知せず、`font_family` などは通知する。
|
|
37
|
+
|
|
38
|
+
大きさを保って当てる場合、保存値に `k = 当て先の size_px ÷ スタイルの size_px` を掛ける。換算対象は `stroke.width_px`、`strokes[].width_px / offset_x / offset_y`、`shadow.blur_px / distance_px`、`glow.spread / offset_x / offset_y`、`background.padding_px / radius_px`、`fill.pattern.scale`。色、透明度、角度、em 値は換算しない。当て先の `size_px` と `reference_height_px` を保ち、`size_px` が未指定なら実効値を明示する。`layout` のときも基準の `size_px` の比で計算し、描画の最終段で layout 倍率を一度掛ける。範囲の px 値は行の座標系で `scale: 1` の値として保存し、描画時に範囲の `scale` を掛ける。出力高さによる `output.height / reference_height_px` も一度だけ掛ける。
|
|
39
|
+
|
|
40
|
+
## 文字範囲の寿命
|
|
41
|
+
|
|
42
|
+
文字の変更では書記素単位の複数差分を対応付ける。共通の先頭と末尾を確定してから中央を対応付ける。通常の文字編集では、置換・削除の内側に丸ごと入る run を外す。表示文字列と原文の座標変換および単語帳の置換では、置換の内側に丸ごと入る run を置換後の区間へ写して `needs_review` を付け、挿入の無い削除の内側だけを外す。境界をまたぐ run は伸縮して `needs_review` を付ける。同点の対応も `needs_review` を付ける。行を分割して境界をまたぐ場合、左は元の id、右は新 id と `split_from` を持つ。anchor は両側の行の時刻へ切り詰め、空になった `spoken` 側は `shown`、`fire_was: "spoken"`、`needs_review: true` にする。隣接 run は自動結合しない。行が消えたか対応先が決まらない run はルートの `unresolved_runs[]` に `{ run, old_caption_id, old_text, old_src, old_start, old_end, reason }` として保持し、描画対象から外す。選んだ文字を行の見た目に戻す操作は、重なる各 run からその部分を切り取り、必要なら右側に新 id を与える。
|
|
43
|
+
|
|
44
|
+
## anchor と発火
|
|
45
|
+
|
|
46
|
+
`anchor` は `{ src?, start, end, domain }`。`domain` は `source` または `output`。`fire` は `shown`(既定)、`spoken`、`whole`。`spoken` には anchor が要る。`words[].text` の連結が行の `text` と一致するときだけ文字オフセットから実測 words に対応付ける。`display_text` が異なる場合は単語帳の文字対応を使い、対応がなければ anchor を付けず `captions.run.anchor-unmapped` を通知する。実測されていない時刻は作らない。
|
|
47
|
+
|
|
48
|
+
source anchor は字幕が描かれる同じ素材の配置ごとに、速度とカットを反映して出力時刻へ写す。同じ素材の複数配置も別々に扱う。表示窓の始まりは行の開始時刻と配置の `in` の遅い方を写す。区間がすべてカットされた場合は `shown` に落とし `captions.run.anchor-cut` を通知する。output anchor は素材配置を参照せず、字幕の表示窓へ切り詰める。発火が窓より前なら窓の始まり、窓より後なら `fire: "none"` として動かさず静止の look だけを表示する。窓の外は warning で知らせる。
|
|
49
|
+
|
|
50
|
+
動きの in は発火から始まり、out は字幕の終わりに終える。残る時間が in と out の合計より短ければ両者を同じ比で縮め、loop は回さない。
|
|
51
|
+
|
|
52
|
+
## 検証
|
|
53
|
+
|
|
54
|
+
構造は captions schema が判定する。文書の意味では v1 run の範囲外、ファイル内の run id 重複、anchor のない `spoken` を error として保存を拒む。`validate-captions` も文書の意味の層を呼び、error があれば非 0 で終了する。旧 runs v0 の範囲外・空の区間は従来どおり `captions.run-range` の warning とする。意味の error は診断を出す。描画経路での除外は後の版で行う(除外に使う純関数 `renderableCaptionRuns` は用意済み)。プロジェクトの意味では anchor の写像不能や表示窓の外を warning とし、経路の対応では選んだ描画経路が motion を描けるか判定する。診断はコード・重大度・パス・日本語メッセージを共有する。
|
|
55
|
+
|
|
56
|
+
## 書き込みの境界
|
|
57
|
+
|
|
58
|
+
`writeProjectFilesTransactional(projectRoot, { files, label })` は、プロジェクト内の相対パスごとに `expectedHash` と `next` を受け取る。`expectedHash` は生のバイト列の SHA-256 を `sha256:<64 桁の十六進数>` で指定する。`null` はファイルが無いことを期待し、省略時は比較しない。`next: null` は比較だけを行う。読んだだけの `edit.json` なども比較へ含められる。1 件でも違えば何も書かず、`ConflictError` の `files` に競合した相対パスを返す。
|
|
59
|
+
|
|
60
|
+
書き込みは `.akari/write.lock` の排他作成でプロセス間を直列化する。lock の持ち主が同じホストで終了した場合、または作成から 30 秒を超えた場合は取得し直せる。同じプロセス内でもプロジェクトごとに順番を保つ。比較のない書き込みは lock の下でファイルごとに一時ファイルから rename し、途中で失敗しても先に書いたファイルを戻さず、journal と `.akari/work/` は作らない。比較つきの書き込みは、変更前に `.akari/write-journal.json` と `.akari/work/journal/<id>/` の元バイト列を保存する。途中で失敗したら元のバイト列に戻す。すべての rename が済んだら journal に `committed: true` を記録し、片付けに失敗しても保存は成功とする。
|
|
61
|
+
|
|
62
|
+
lock を待つ側は `.akari/write.lock.wait` に印を置く。放す側は lock を消した後でその印を消し、待ち手に短く取得を譲る。
|
|
63
|
+
|
|
64
|
+
書き込み先の相対パスは正規化し、同じファイルを指す鍵の重複、代替データストリームの `:`、lock・journal・退避先を拒む。`./edit.json` は `edit.json` として扱う。比較する hash は生バイトを基準にするが、UTF-8 BOM 付きのファイルでは BOM を除いたバイト列の hash も一致として受け入れる。元の BOM は取り消し用の変更前全文に残す。
|
|
65
|
+
|
|
66
|
+
比較がすべて通った後、実際に書く各ファイルの rename 直前に `onWillWrite(destination, name)` を呼び、rename 完了後に同じファイルの `onDidWrite` を呼ぶ。競合や内容が同じため書かないファイルにはどちらも通知しない。
|
|
67
|
+
|
|
68
|
+
次の書き込みの前に journal が残っていれば回復する。`committed: true` なら戻さず片付ける。印がなければ現在の内容が止まった書き込みの値と一致するファイルだけ元に戻し、元の値と一致するものはそのままにする。どちらでもないファイルや退避が読めないファイルは触らず通知する。journal が壊れていれば退避し、ファイルは触らない。回復通知は保存結果の `recovered` と `onRecovered` で受け取れる。journal が無ければ起動時の回復は lock を取らず直ちに終わる。
|
|
69
|
+
|
|
70
|
+
## 見た目の編集コマンド
|
|
71
|
+
|
|
72
|
+
`planCaptionStyleEdit` は `captions`、`targets`、`parts`、`source` を受け、副作用なしに `next`、`changed`、`notices` を返す。対象は `{caption_id}`(行)、`{caption_id, range}`(文字範囲)、`{default:true}`(既定スタイル)。対象ごとに `parts` を上書きできる。`range` は書記素の半開区間 `{from,to}`、または `{quote,occurrence}` で指定する。引用の候補は NFC にそろえ、左から重ならないように数える。一致後はその末尾から次を探す(「ああああ」の「ああ」は 2 件)。返す位置は元の文字列の書記素単位。同じ引用が複数あれば 1 始まりの `occurrence` が必要。引用が見つからない場合や範囲が空・範囲外の場合は失敗する。範囲の効果音と効果は使えず、行の効果音と効果は従来の attach 経路で扱う。
|
|
73
|
+
|
|
74
|
+
行の look は許可項目を部品ごとに置換し、動きは `animation` 全体を置換する。範囲は `runs[].look` と `runs[].motion` を置換し、許可外の look 項目を外して通知する。同じ区間の v1 run は更新し、新しい区間にはファイル全体で一意の id を付ける。実測 words に対応付けられる場合だけ anchor を付ける。行の旧 `style_preset` は現在のカタログで値に解き、出所を記録してから外す。カタログに無ければ元の指定を保って通知する。配列ルートに `{default:true}` を当てた場合は `{default_text_style,captions}` にする。
|
|
75
|
+
|
|
76
|
+
スタイルを当てても大きさは変えない。行の既存の `size_px` と `reference_height_px` を保ち、`size_px` が無ければ実効値を明示する。px 系の値は当て先の大きさに換算する。`style_sources.look` の hash は look の許可項目から `size_px` と `reference_height_px` を除いた値に付ける。`original_hash` は出所の値、`applied_hash` は換算後に保存した値である。`applied_at` には適用時刻を記録する。
|
|
77
|
+
|
|
78
|
+
`planCaptionStyleClear` は run の指定全体を消す `mode: "run"`、選んだ文字を重なる run から切り取る `mode: "range"`、範囲指定のない `{caption_id}` で行の look・動き・出所・旧 preset を消す操作を扱う。行の大きさと配置は保つ。`planCaptionStyleReapply` は出所の現在の値を引き、部品ごとに `same`、`updated`、`missing` と手直しの有無を返す。`apply` に指定した部品だけ値を当て直す。出所が無ければ値を保って通知する。
|
|
79
|
+
|
|
80
|
+
`planCaptionSizeEdit({captions,targets,size_px})` は行の大きさと、その行の look および範囲の look の px 系の値を、`新しい大きさ ÷ 変更前の実効の大きさ` で変える。小数は 6 桁に丸め、`reference_height_px` を保つ。比が 1 なら変更しない。元の `applied_hash` と値が一致していた部品は換算後の hash に更新し、大きさの変更だけで手直しとは判定しない。
|
|
81
|
+
|
|
82
|
+
Node 側の `applyCaptionStyle`、`clearCaptionStyle`、`reapplyCaptionStyle`、`setCaptionSize` は読んだ全文の hash を比較してから保存し、書く前と後の全文・hash を返す。対象ファイルは既定の `captions.json` / `edit.json` に加え、プロジェクト直下の対応する `captions.<slug>.json` / `edit.<slug>.json` を指定できる。期待 hash は役割名(`captions` / `edit`)または実ファイル名で受け、前後の全文と `written` は実ファイル名を鍵にする。読んだだけのファイルも競合の比較に含める。保存前に v1 の項目の形と文書の意味を検査し、error があれば `AKARI_CAPTIONS_INVALID` と診断を返して書かない。形の検査は旧い行の他の項目や v0 の run の形を理由に拒まない。edit-lint も同じ v1 の形の診断を表示する。
|
|
83
|
+
|
|
84
|
+
書き込み lock の奪取に使う `.akari/write.lock.claim` は、5 秒を超えて残ったものを取り除いてから再取得する。書き込み先のフォルダで作成権限が無いと確かめたときは、待ち上限を待たずに権限エラーを返す。
|
|
85
|
+
`.akari/write.lock.claim` とその下、および試し書き名 `.akari/write.lock.probe-*` は書き込み先に指定できない。
|
|
86
|
+
|
|
87
|
+
## 移行と CLI
|
|
88
|
+
|
|
89
|
+
移行と preset の解決には同梱とライブラリを合わせた現在の textstyle カタログを使う。移行は現在の edit.json も読んで計画し、書き込む前の字幕データを保存境界の期待 hash として扱う。出所を指定せず look または motion を当てた場合、その部品の古い `style_sources` を消す。両部品の出所が無くなれば `style_sources` 自体を消す。
|
|
90
|
+
|
|
91
|
+
CLI の `--caption <index>` は id の無い行や id が重複した行にも index で当てる。`--caption <id>` は id で指定する。CLI の JSON 出力の `changes[].before` と `changes[].after` は実際に計画または保存した文書の値を示す。
|
|
92
|
+
|
|
93
|
+
アプリの `applyCaptionStyle`、`clearCaptionStyle`、`reapplyCaptionStyle`、`setCaptionSize`、`migrateCaptionLook` は projectRootUri と対象 URI を検査し、`captionsUri` と対になる edit のあるフォルダを根として別名の組も扱い、任意の期待 hash を受ける。成功は実ファイル名で書いたファイル、通知、前後の全文と hash を返す。`writeEditSnapshot` の期待 hash も edit と captions の役割に対応する候補を比較する。競合は `conflict: true`、検証エラーは `invalid: true` と診断を返し、どちらも書かない。中断した書き込みは `recoverInterruptedWrite` で元に戻す。アプリはプロジェクトを開いたときに回復を試み、通知を表示する。字幕スタイルの undo と redo は前後の全文を各 1 件の履歴に持ち、保存後の hash と一致するときだけ逆向きに書く。競合したら読み直して取り消しややり直しを失敗として知らせる。
|
|
94
|
+
|
|
95
|
+
移行の保存はプロジェクト直下の `captions.json` と `captions.<slug>.json` を対象にし、対応する edit を比較する。強調語の id は移行済みと未解決の run に残る `migrated_from` も予約済みとして採番する。古い強調語と run の id が重なれば、強調語に新しい id を与えてから移す。主経路(`resolveCaptionPlan` を通る書き出しとプレビュー)では移行の前後で HTML と CSS 変数が同じ。取り出した字幕と GPU の適格判定の入口は未対応のため、移行の保存はまだ一般の利用に勧めない。
|
|
96
|
+
|
|
97
|
+
`akari-apply-textstyle` の既存の対象指定、`--dry-run`、JSON 出力と終了コードは保つ。保存には同じ書き込み境界を使う。行に当てた場合は `style_sources.look` を記録し、旧 `style_preset` を値に解いて外す。既存の CLI が行っていた深い合成は互換のため維持する。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "1.3.0
|
|
3
|
+
"version": "1.3.0",
|
|
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": [
|
|
@@ -60,6 +60,11 @@ const HISTORY_BLOCK_BODY = [
|
|
|
60
60
|
'.akari/cache/**',
|
|
61
61
|
'.akari/diffs/**',
|
|
62
62
|
'!.akari/diffs/.gitkeep',
|
|
63
|
+
'.akari/write.lock',
|
|
64
|
+
'.akari/write.lock.claim/',
|
|
65
|
+
'.akari/write.lock.wait',
|
|
66
|
+
'.akari/write-journal.json',
|
|
67
|
+
'.akari/work/journal/',
|
|
63
68
|
'',
|
|
64
69
|
'# 素材の分析結果は作り直すのに手間がかかるので、変更履歴に残します。',
|
|
65
70
|
'!.akari/sidecars/**',
|
|
@@ -3,7 +3,7 @@ import { lintText } from '../i18n.mjs';
|
|
|
3
3
|
import { buildMatcher, protectedTermsFrom } from "../../../word-book/src/index.mjs";
|
|
4
4
|
import { resolveWordBookSync, scanRecord } from "../../../word-book/src/index.mjs";
|
|
5
5
|
import { EPSILON, addFinding, captionFinding, isFiniteNumber, isNonEmptyString, isPositiveNumber, isRecord, relativePath } from "./shared.mjs";
|
|
6
|
-
import { resolveCaptionDisplay } from "./external.mjs";
|
|
6
|
+
import { resolveCaptionDisplay, validateCaptionsSemantics, validateCaptionLookV1Shape, CAPTION_RUN_FIELDS } from "./external.mjs";
|
|
7
7
|
import { validateCaptionAnimation, validateTextStyle } from "./caption-style.mjs";
|
|
8
8
|
import { captionLanguageDefaults, resolveContentLanguage } from "../../../content-language/src/index.mjs";
|
|
9
9
|
import { readContentLanguageSync } from "../../../content-language/src/node.mjs";
|
|
@@ -21,6 +21,8 @@ export function validateCaptionRunRanges(caption) {
|
|
|
21
21
|
export function validateCaptions(captions, edit, analysis, findings, paths, cutsEndSeconds, textstylePresetIds) {
|
|
22
22
|
const captionPath = relativePath(paths.projectRoot, paths.captionsPath);
|
|
23
23
|
const captionsRoot = captions;
|
|
24
|
+
// The caller supplies the v2 cut projection; never interpret raw tracks as cuts.
|
|
25
|
+
const timeline = Array.isArray(edit?.cuts) ? edit.cuts : undefined;
|
|
24
26
|
const wordBook = resolveWordBookSync({ projectRoot: paths.projectRoot, env: process.env });
|
|
25
27
|
const wordBookMatcher = buildMatcher(wordBook.entries);
|
|
26
28
|
for (const layer of wordBook.layers) {
|
|
@@ -55,7 +57,8 @@ export function validateCaptions(captions, edit, analysis, findings, paths, cuts
|
|
|
55
57
|
if (field !== "default_text_style"
|
|
56
58
|
&& field !== "display_policy"
|
|
57
59
|
&& field !== "emphasis_words"
|
|
58
|
-
&& field !== "captions"
|
|
60
|
+
&& field !== "captions"
|
|
61
|
+
&& field !== "unresolved_runs") {
|
|
59
62
|
captionFinding(
|
|
60
63
|
findings,
|
|
61
64
|
"captions.schema",
|
|
@@ -111,7 +114,7 @@ export function validateCaptions(captions, edit, analysis, findings, paths, cuts
|
|
|
111
114
|
continue;
|
|
112
115
|
}
|
|
113
116
|
const required = ["id", "start", "end", "text", "speaker", "sourceRef", "edited"];
|
|
114
|
-
const optional = ["src", "time_domain", "words", "unrecognized", "style", "display_text", "display_fragments", "display_timing", "style_preset", "text_style", "runs"];
|
|
117
|
+
const optional = ["src", "time_domain", "words", "unrecognized", "style", "display_text", "display_fragments", "display_timing", "style_preset", "text_style", "runs", "style_sources"];
|
|
115
118
|
for (const field of required) {
|
|
116
119
|
if (!Object.hasOwn(caption, field)) {
|
|
117
120
|
captionFinding(findings, "captions.schema", `${field} is required`, itemPath);
|
|
@@ -214,12 +217,12 @@ export function validateCaptions(captions, edit, analysis, findings, paths, cuts
|
|
|
214
217
|
|| (run.style !== undefined && (!isRecord(run.style)
|
|
215
218
|
|| Object.keys(run.style).some(key => !["color", "font_weight", "scale", "baseline_shift_em", "rotate_deg", "letter_spacing_em", "stroke", "italic", "underline"].includes(key))))
|
|
216
219
|
|| (run.animation !== undefined && !isRecord(run.animation))
|
|
217
|
-
|| Object.keys(run).some(key => !
|
|
220
|
+
|| Object.keys(run).some(key => !CAPTION_RUN_FIELDS.has(key))) {
|
|
218
221
|
captionFinding(findings, "captions.schema", "run has invalid fields or types", path);
|
|
219
222
|
return;
|
|
220
223
|
}
|
|
221
224
|
const range = outOfRange.get(runIndex);
|
|
222
|
-
if (range) {
|
|
225
|
+
if (range && !["id", "look", "motion", "anchor", "fire"].some(key => Object.hasOwn(run, key))) {
|
|
223
226
|
addFinding(findings, { severity: "warning", check: "captions.run-range",
|
|
224
227
|
message: `run range [${run.from}, ${run.to}) is outside ${range.count} displayed graphemes or empty; ignored`, path });
|
|
225
228
|
}
|
|
@@ -449,6 +452,17 @@ export function validateCaptions(captions, edit, analysis, findings, paths, cuts
|
|
|
449
452
|
captionFinding(findings, "captions.display-policy", error instanceof Error ? error.message : String(error), captionPath);
|
|
450
453
|
}
|
|
451
454
|
}
|
|
455
|
+
const seenDiagnostics = new Set();
|
|
456
|
+
for (const diagnostic of [...validateCaptionLookV1Shape(captionsRoot),
|
|
457
|
+
...validateCaptionsSemantics({ captions: captionsRoot, edit, timeline })]) {
|
|
458
|
+
const identity = JSON.stringify([diagnostic.code, diagnostic.path, diagnostic.message]);
|
|
459
|
+
if (seenDiagnostics.has(identity)) continue;
|
|
460
|
+
if (diagnostic.code === 'captions.look.shape' && findings.some(item => item.severity === 'error'
|
|
461
|
+
&& item.check === 'captions.schema' && item.path === diagnostic.path)) continue;
|
|
462
|
+
seenDiagnostics.add(identity);
|
|
463
|
+
addFinding(findings, { severity: diagnostic.severity, check: diagnostic.code,
|
|
464
|
+
message: diagnostic.message, path: diagnostic.path });
|
|
465
|
+
}
|
|
452
466
|
}
|
|
453
467
|
|
|
454
468
|
function validateEmphasisWords(emphasisWords, findings, captionPath) {
|
|
@@ -16,8 +16,11 @@ export const {
|
|
|
16
16
|
toAnchorCaptions,
|
|
17
17
|
TRANSITION_TYPE_IDS,
|
|
18
18
|
withoutItemAnchors,
|
|
19
|
+
validateCaptionsSemantics,
|
|
20
|
+
validateCaptionLookV1Shape,
|
|
19
21
|
} = createRequire(import.meta.url)("../../../edit-store/lib/index.js");
|
|
20
22
|
|
|
23
|
+
|
|
21
24
|
export const { captionsHaveRenderableCues, collectFitBasisCandidates } = createRequire(import.meta.url)(
|
|
22
25
|
"../../../edit-store/lib/migrate/index.js",
|
|
23
26
|
);
|
|
@@ -27,6 +30,8 @@ const CAPTIONS_SCHEMA = JSON.parse(readFileSync(
|
|
|
27
30
|
"utf8",
|
|
28
31
|
));
|
|
29
32
|
|
|
33
|
+
export const CAPTION_RUN_FIELDS = new Set(Object.keys(CAPTIONS_SCHEMA.$defs.captionRun.properties));
|
|
34
|
+
|
|
30
35
|
export const CAPTION_TEXT_STYLE_FIELDS = new Set(
|
|
31
36
|
Object.keys(CAPTIONS_SCHEMA.$defs.textStyle.properties),
|
|
32
37
|
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type TimelineMapResult } from './timeline-map';
|
|
2
|
+
import type { EditCut } from './edit-store';
|
|
3
|
+
import type { CaptionRun } from './caption-runs';
|
|
4
|
+
export interface RunAnchor {
|
|
5
|
+
src?: string;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
domain: 'source' | 'output';
|
|
9
|
+
}
|
|
10
|
+
export interface CaptionAnchorRow {
|
|
11
|
+
src?: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
time_domain?: 'source' | 'output';
|
|
15
|
+
}
|
|
16
|
+
export interface AnchorOutput {
|
|
17
|
+
start?: number;
|
|
18
|
+
end?: number;
|
|
19
|
+
fire: 'shown' | 'spoken' | 'whole' | 'none';
|
|
20
|
+
placement?: number;
|
|
21
|
+
diagnostics: Array<{
|
|
22
|
+
code: string;
|
|
23
|
+
severity: 'warning' | 'info';
|
|
24
|
+
message: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export declare function anchorToOutput(anchor: RunAnchor | undefined, caption: CaptionAnchorRow, timeline: TimelineMapResult | readonly EditCut[], fire?: CaptionRun['fire']): AnchorOutput[];
|
|
28
|
+
export declare function anchorForCaptionRange(caption: CaptionAnchorRow & {
|
|
29
|
+
text: string;
|
|
30
|
+
display_text?: string;
|
|
31
|
+
words?: Array<{
|
|
32
|
+
text: string;
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
}>;
|
|
36
|
+
}, from: number, to: number, displayToSource?: readonly number[]): {
|
|
37
|
+
anchor?: RunAnchor;
|
|
38
|
+
diagnostics: Array<{
|
|
39
|
+
code: string;
|
|
40
|
+
severity: 'info';
|
|
41
|
+
message: string;
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
44
|
+
/** 表示と原文の一致箇所から、置換の両端を含む境界対応を作る。 */
|
|
45
|
+
export declare function inferCaptionDisplayToSource(display: string, source: string): Array<number | undefined>;
|
|
46
|
+
export declare function fitCaptionMotionWindow(fireAt: number, endAt: number, inSeconds: number, outSeconds: number): {
|
|
47
|
+
inSeconds: number;
|
|
48
|
+
outSeconds: number;
|
|
49
|
+
loopSeconds: number;
|
|
50
|
+
active: boolean;
|
|
51
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.anchorToOutput = anchorToOutput;
|
|
4
|
+
exports.anchorForCaptionRange = anchorForCaptionRange;
|
|
5
|
+
exports.inferCaptionDisplayToSource = inferCaptionDisplayToSource;
|
|
6
|
+
exports.fitCaptionMotionWindow = fitCaptionMotionWindow;
|
|
7
|
+
const timeline_map_1 = require("./timeline-map");
|
|
8
|
+
const caption_runs_1 = require("./caption-runs");
|
|
9
|
+
function anchorToOutput(anchor, caption, timeline, fire = 'shown') {
|
|
10
|
+
const map = Array.isArray(timeline) ? (0, timeline_map_1.buildTimelineMap)(timeline) : timeline;
|
|
11
|
+
const warning = (code, message) => ({ fire: 'shown', diagnostics: [{ code, severity: 'warning', message }] });
|
|
12
|
+
if (!anchor)
|
|
13
|
+
return [{ fire: 'shown', diagnostics: [] }];
|
|
14
|
+
const clip = (start, end, windowStart, windowEnd, placement) => {
|
|
15
|
+
if (start >= windowEnd)
|
|
16
|
+
return { fire: 'none', placement,
|
|
17
|
+
diagnostics: [{ code: 'captions.run.anchor-window', severity: 'warning', message: '発火が表示窓の後にあります。' }] };
|
|
18
|
+
const before = start < windowStart;
|
|
19
|
+
return { start: Math.max(start, windowStart), end: Math.min(Math.max(end, windowStart), windowEnd),
|
|
20
|
+
fire: fire ?? 'shown', placement,
|
|
21
|
+
diagnostics: before ? [{ code: 'captions.run.anchor-window', severity: 'warning', message: '発火を表示窓の始まりへ寄せました。' }] : [] };
|
|
22
|
+
};
|
|
23
|
+
if (anchor.domain === 'output')
|
|
24
|
+
return [clip(anchor.start, anchor.end, caption.start, caption.end)];
|
|
25
|
+
const src = anchor.src ?? caption.src;
|
|
26
|
+
const placements = map.segments.filter(segment => segment.kind === 'src' && segment.src === src
|
|
27
|
+
&& segment.in !== undefined && segment.out !== undefined && segment.in < caption.end && segment.out > caption.start);
|
|
28
|
+
if (anchor.end <= caption.start && placements.length)
|
|
29
|
+
return placements.map(segment => ({
|
|
30
|
+
start: segment.outStart + (Math.max(caption.start, segment.in) - segment.in)
|
|
31
|
+
/ (segment.speed && segment.speed > 0 ? segment.speed : 1),
|
|
32
|
+
fire: fire ?? 'shown', placement: segment.cutIndex ?? undefined,
|
|
33
|
+
diagnostics: [{ code: 'captions.run.anchor-window', severity: 'warning',
|
|
34
|
+
message: '発火を表示窓の始まりへ寄せました。' }]
|
|
35
|
+
}));
|
|
36
|
+
if (anchor.start >= caption.end && placements.length)
|
|
37
|
+
return placements.map(segment => ({
|
|
38
|
+
fire: 'none', placement: segment.cutIndex ?? undefined,
|
|
39
|
+
diagnostics: [{ code: 'captions.run.anchor-window', severity: 'warning',
|
|
40
|
+
message: '発火が表示窓の後にあります。' }]
|
|
41
|
+
}));
|
|
42
|
+
const results = [];
|
|
43
|
+
for (const segment of placements) {
|
|
44
|
+
const speed = segment.speed && segment.speed > 0 ? segment.speed : 1;
|
|
45
|
+
const windowStart = segment.outStart + (Math.max(caption.start, segment.in) - segment.in) / speed;
|
|
46
|
+
const windowEnd = segment.outStart + (Math.min(caption.end, segment.out) - segment.in) / speed;
|
|
47
|
+
const sourceStart = Math.max(anchor.start, segment.in);
|
|
48
|
+
const sourceEnd = Math.min(anchor.end, segment.out);
|
|
49
|
+
if (!(sourceStart < sourceEnd))
|
|
50
|
+
continue;
|
|
51
|
+
const result = clip(segment.outStart + (sourceStart - segment.in) / speed, segment.outStart + (sourceEnd - segment.in) / speed, windowStart, windowEnd, segment.cutIndex ?? undefined);
|
|
52
|
+
if (anchor.start < Math.max(caption.start, segment.in) && !result.diagnostics.length) {
|
|
53
|
+
result.diagnostics.push({ code: 'captions.run.anchor-window', severity: 'warning',
|
|
54
|
+
message: '発火を表示窓の始まりへ寄せました。' });
|
|
55
|
+
}
|
|
56
|
+
results.push(result);
|
|
57
|
+
}
|
|
58
|
+
return results.length ? results : [warning('captions.run.anchor-cut', '発火する素材の区間がカットで消えました。')];
|
|
59
|
+
}
|
|
60
|
+
function anchorForCaptionRange(caption, from, to, displayToSource) {
|
|
61
|
+
const unmapped = { diagnostics: [{ code: 'captions.run.anchor-unmapped', severity: 'info',
|
|
62
|
+
message: '選んだ文字を実測の単語に対応付けられません。' }] };
|
|
63
|
+
const words = caption.words ?? [];
|
|
64
|
+
if (words.map(word => word.text).join('') !== caption.text)
|
|
65
|
+
return unmapped;
|
|
66
|
+
const display = caption.display_text ?? caption.text;
|
|
67
|
+
const shown = (0, caption_runs_1.captionGraphemes)(display);
|
|
68
|
+
const source = (0, caption_runs_1.captionGraphemes)(caption.text);
|
|
69
|
+
if (from < 0 || to > shown.length || from >= to)
|
|
70
|
+
return unmapped;
|
|
71
|
+
const mapping = displayToSource ?? (display === caption.text ? undefined
|
|
72
|
+
: inferCaptionDisplayToSource(display, caption.text));
|
|
73
|
+
const start = display === caption.text ? from : mapping?.[from];
|
|
74
|
+
const end = display === caption.text ? to : mapping?.[to];
|
|
75
|
+
if (start === undefined || end === undefined || start < 0 || end > source.length || start >= end)
|
|
76
|
+
return unmapped;
|
|
77
|
+
let offset = 0;
|
|
78
|
+
const overlapping = words.filter(word => {
|
|
79
|
+
const wordStart = offset;
|
|
80
|
+
offset += (0, caption_runs_1.captionGraphemes)(word.text).length;
|
|
81
|
+
return wordStart < end && offset > start;
|
|
82
|
+
});
|
|
83
|
+
if (!overlapping.length)
|
|
84
|
+
return unmapped;
|
|
85
|
+
return { anchor: { ...(caption.src ? { src: caption.src } : {}),
|
|
86
|
+
start: overlapping[0].start, end: overlapping[overlapping.length - 1].end,
|
|
87
|
+
domain: caption.time_domain === 'output' ? 'output' : 'source' }, diagnostics: [] };
|
|
88
|
+
}
|
|
89
|
+
/** 表示と原文の一致箇所から、置換の両端を含む境界対応を作る。 */
|
|
90
|
+
function inferCaptionDisplayToSource(display, source) {
|
|
91
|
+
const shown = (0, caption_runs_1.captionGraphemes)(display);
|
|
92
|
+
const original = (0, caption_runs_1.captionGraphemes)(source);
|
|
93
|
+
const dp = Array.from({ length: shown.length + 1 }, () => new Uint32Array(original.length + 1));
|
|
94
|
+
for (let i = shown.length - 1; i >= 0; i--)
|
|
95
|
+
for (let j = original.length - 1; j >= 0; j--) {
|
|
96
|
+
dp[i][j] = shown[i] === original[j] ? dp[i + 1][j + 1] + 1
|
|
97
|
+
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
98
|
+
}
|
|
99
|
+
const map = Array(shown.length + 1).fill(undefined);
|
|
100
|
+
map[0] = 0;
|
|
101
|
+
map[shown.length] = original.length;
|
|
102
|
+
let i = 0;
|
|
103
|
+
let j = 0;
|
|
104
|
+
while (i < shown.length && j < original.length) {
|
|
105
|
+
if (shown[i] === original[j]) {
|
|
106
|
+
map[i] = j;
|
|
107
|
+
map[i + 1] = j + 1;
|
|
108
|
+
i++;
|
|
109
|
+
j++;
|
|
110
|
+
}
|
|
111
|
+
else if (dp[i + 1][j] >= dp[i][j + 1])
|
|
112
|
+
i++;
|
|
113
|
+
else
|
|
114
|
+
j++;
|
|
115
|
+
}
|
|
116
|
+
return map;
|
|
117
|
+
}
|
|
118
|
+
function fitCaptionMotionWindow(fireAt, endAt, inSeconds, outSeconds) {
|
|
119
|
+
const available = Math.max(0, endAt - fireAt);
|
|
120
|
+
if (!available)
|
|
121
|
+
return { inSeconds: 0, outSeconds: 0, loopSeconds: 0, active: false };
|
|
122
|
+
const total = Math.max(0, inSeconds) + Math.max(0, outSeconds);
|
|
123
|
+
const ratio = total > available ? available / total : 1;
|
|
124
|
+
return { inSeconds: inSeconds * ratio, outSeconds: outSeconds * ratio,
|
|
125
|
+
loopSeconds: total > available ? 0 : available - total, active: true };
|
|
126
|
+
}
|
|
@@ -412,6 +412,19 @@ function resolveCaptionDisplay(captionsRoot, edit, options = {}) {
|
|
|
412
412
|
const cueRuns = Array.isArray(sourceRuns)
|
|
413
413
|
? (0, caption_runs_1.sliceCaptionRuns)(projectedCaptions[occurrence.caption_input_index].displayText, sourceRuns, group.charStart, group.charEnd)
|
|
414
414
|
: undefined;
|
|
415
|
+
const karaokeOffset = karaoke
|
|
416
|
+
? captionDisplayGraphemes(projectedCaptions[occurrence.caption_input_index].displayText.slice(0, group.charStart)).length
|
|
417
|
+
: undefined;
|
|
418
|
+
const display = resolved.manual && !resolved.overflow
|
|
419
|
+
? trimManualCueDisplay(group.lines, policy.locale, cueRuns, wordDisplay, karaokeOffset)
|
|
420
|
+
: {
|
|
421
|
+
text,
|
|
422
|
+
lines: group.lines,
|
|
423
|
+
runs: cueRuns,
|
|
424
|
+
words: wordDisplay?.words,
|
|
425
|
+
wordStyles: wordDisplay?.wordStyles,
|
|
426
|
+
karaokeOffset
|
|
427
|
+
};
|
|
415
428
|
displayCues.push({
|
|
416
429
|
id: `${occurrence.source_cue_id}-occ-${String(occurrence.occurrence_index).padStart(4, '0')}-part-${index + 1}`,
|
|
417
430
|
source_cue_id: occurrence.source_cue_id,
|
|
@@ -422,20 +435,20 @@ function resolveCaptionDisplay(captionsRoot, edit, options = {}) {
|
|
|
422
435
|
fragment_count: groups.length,
|
|
423
436
|
start: group.start,
|
|
424
437
|
end: group.end,
|
|
425
|
-
text,
|
|
426
|
-
...(
|
|
427
|
-
...(
|
|
428
|
-
units: measureCaptionUnits(text),
|
|
438
|
+
text: display.text,
|
|
439
|
+
...(display.runs ? { runs: display.runs } : {}),
|
|
440
|
+
...(display.lines.length >= 2 ? { display_lines: display.lines } : {}),
|
|
441
|
+
units: measureCaptionUnits(display.text),
|
|
429
442
|
line_override: resolved.manual,
|
|
430
443
|
...(resolved.overflow ? { overflow: resolved.overflow } : {}),
|
|
431
444
|
...(resolvedStyle ? { text_style: resolvedStyle } : {}),
|
|
432
445
|
...(cueStyleVars ? { style_vars: cueStyleVars } : {}),
|
|
433
446
|
...(styleResolution?.layout ? { layout: styleResolution.layout } : {}),
|
|
434
|
-
...(
|
|
435
|
-
...(
|
|
447
|
+
...(display.words ? { words: display.words } : {}),
|
|
448
|
+
...(display.wordStyles?.length ? { word_styles: display.wordStyles } : {}),
|
|
436
449
|
...(karaoke ? {
|
|
437
450
|
style: 'karaoke',
|
|
438
|
-
karaoke_offset:
|
|
451
|
+
karaoke_offset: display.karaokeOffset
|
|
439
452
|
} : {})
|
|
440
453
|
});
|
|
441
454
|
});
|
|
@@ -766,6 +779,58 @@ function buildCueWordDisplay(sourceWords, occurrence, charStart, charEnd, lines,
|
|
|
766
779
|
wordStyles
|
|
767
780
|
};
|
|
768
781
|
}
|
|
782
|
+
function trimManualCueDisplay(lines, locale, runs, wordDisplay, karaokeOffset) {
|
|
783
|
+
const kept = lines.map((line, index) => ({
|
|
784
|
+
start: index > 0 ? 0 : line.length - line.trimStart().length,
|
|
785
|
+
end: index < lines.length - 1 ? line.length : line.trimEnd().length
|
|
786
|
+
}));
|
|
787
|
+
const trimmedLines = lines.map((line, index) => line.slice(kept[index].start, kept[index].end));
|
|
788
|
+
if (lines.every((line, index) => line === trimmedLines[index])) {
|
|
789
|
+
return { text: joinCaptionLines(lines, locale), lines, runs,
|
|
790
|
+
words: wordDisplay?.words, wordStyles: wordDisplay?.wordStyles, karaokeOffset };
|
|
791
|
+
}
|
|
792
|
+
const graphemeBoundaries = [0];
|
|
793
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
794
|
+
let position = 0;
|
|
795
|
+
for (const grapheme of captionDisplayGraphemes(lines[lineIndex])) {
|
|
796
|
+
const visible = position >= kept[lineIndex].start
|
|
797
|
+
&& position + grapheme.length <= kept[lineIndex].end;
|
|
798
|
+
graphemeBoundaries.push(graphemeBoundaries[graphemeBoundaries.length - 1] + (visible ? 1 : 0));
|
|
799
|
+
position += grapheme.length;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const trimmedRuns = runs?.flatMap(run => {
|
|
803
|
+
const from = graphemeBoundaries[run.from];
|
|
804
|
+
const to = graphemeBoundaries[run.to];
|
|
805
|
+
return from < to ? [{ ...run, from, to }] : [];
|
|
806
|
+
});
|
|
807
|
+
const wordPositions = lines.map(() => 0);
|
|
808
|
+
const retainedWords = [];
|
|
809
|
+
const wordBoundaries = [0];
|
|
810
|
+
for (const word of wordDisplay?.words ?? []) {
|
|
811
|
+
const position = wordPositions[word.line];
|
|
812
|
+
wordPositions[word.line] += word.text.length;
|
|
813
|
+
const start = Math.max(0, kept[word.line].start - position);
|
|
814
|
+
const end = Math.min(word.text.length, kept[word.line].end - position);
|
|
815
|
+
if (start < end)
|
|
816
|
+
retainedWords.push({ ...word, text: word.text.slice(start, end) });
|
|
817
|
+
wordBoundaries.push(retainedWords.length);
|
|
818
|
+
}
|
|
819
|
+
const wordStyles = wordDisplay?.wordStyles.flatMap(style => {
|
|
820
|
+
const from = wordBoundaries[style.from];
|
|
821
|
+
const to = wordBoundaries[style.to];
|
|
822
|
+
return from < to ? [{ ...style, from, to }] : [];
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
text: joinCaptionLines(trimmedLines, locale),
|
|
826
|
+
lines: trimmedLines,
|
|
827
|
+
runs: trimmedRuns?.length ? trimmedRuns : undefined,
|
|
828
|
+
words: wordDisplay ? retainedWords : undefined,
|
|
829
|
+
wordStyles,
|
|
830
|
+
karaokeOffset: karaokeOffset === undefined ? undefined
|
|
831
|
+
: karaokeOffset + captionDisplayGraphemes(lines[0].slice(0, kept[0].start)).length
|
|
832
|
+
};
|
|
833
|
+
}
|
|
769
834
|
function roundOutputSecond(value) {
|
|
770
835
|
return Number(value.toFixed(6));
|
|
771
836
|
}
|
|
@@ -1501,14 +1566,14 @@ function validateManualFragments(caption, text, policy) {
|
|
|
1501
1566
|
if (!Array.isArray(caption.display_fragments) || caption.display_fragments.length < 1 || caption.display_fragments.length > 6) {
|
|
1502
1567
|
return overflow();
|
|
1503
1568
|
}
|
|
1504
|
-
if (caption.display_fragments.some((fragment) => !
|
|
1569
|
+
if (caption.display_fragments.some((fragment) => !manualFragmentText(fragment))) {
|
|
1505
1570
|
return overflow();
|
|
1506
1571
|
}
|
|
1507
1572
|
if (caption.display_fragments.join('') !== text) {
|
|
1508
1573
|
return overflow();
|
|
1509
1574
|
}
|
|
1510
1575
|
for (const fragment of caption.display_fragments) {
|
|
1511
|
-
if (measureCaptionUnits(fragment) > policy.max_line_units) {
|
|
1576
|
+
if (measureCaptionUnits(fragment.trim()) > policy.max_line_units) {
|
|
1512
1577
|
return overflow();
|
|
1513
1578
|
}
|
|
1514
1579
|
}
|
|
@@ -2616,6 +2681,9 @@ function compareDisplayCue(left, right) {
|
|
|
2616
2681
|
function strictText(value) {
|
|
2617
2682
|
return typeof value === 'string' && value.length > 0 && value.trim() === value && value.normalize('NFC') === value;
|
|
2618
2683
|
}
|
|
2684
|
+
function manualFragmentText(value) {
|
|
2685
|
+
return typeof value === 'string' && /\S/u.test(value) && value.normalize('NFC') === value;
|
|
2686
|
+
}
|
|
2619
2687
|
function finiteNumber(value) {
|
|
2620
2688
|
return typeof value === 'number' && Number.isFinite(value);
|
|
2621
2689
|
}
|