akari-video 1.3.0-beta.9 → 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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/history-policy.mjs +5 -0
  3. package/vendor/docs/contract-2026-10-12-caption-look-v1.md +44 -1
  4. package/vendor/packages/akari-launcher/package.json +1 -1
  5. package/vendor/packages/akari-launcher/src/history-policy.mjs +5 -0
  6. package/vendor/packages/edit-lint/src/lint/captions.mjs +9 -2
  7. package/vendor/packages/edit-lint/src/lint/external.mjs +1 -0
  8. package/vendor/packages/edit-store/lib/caption-hash.d.ts +2 -0
  9. package/vendor/packages/edit-store/lib/caption-hash.js +6 -1
  10. package/vendor/packages/edit-store/lib/caption-look-reader.d.ts +2 -0
  11. package/vendor/packages/edit-store/lib/caption-look-reader.js +93 -28
  12. package/vendor/packages/edit-store/lib/caption-look-shape.d.ts +18 -0
  13. package/vendor/packages/edit-store/lib/caption-look-shape.js +134 -0
  14. package/vendor/packages/edit-store/lib/caption-look-v1.d.ts +1 -0
  15. package/vendor/packages/edit-store/lib/caption-look-v1.js +2 -1
  16. package/vendor/packages/edit-store/lib/caption-store.d.ts +4 -0
  17. package/vendor/packages/edit-store/lib/caption-store.js +10 -2
  18. package/vendor/packages/edit-store/lib/caption-style-command.d.ts +127 -0
  19. package/vendor/packages/edit-store/lib/caption-style-command.js +422 -0
  20. package/vendor/packages/edit-store/lib/caption-style-write.d.ts +57 -0
  21. package/vendor/packages/edit-store/lib/caption-style-write.js +116 -0
  22. package/vendor/packages/edit-store/lib/index.d.ts +2 -0
  23. package/vendor/packages/edit-store/lib/index.js +2 -0
  24. package/vendor/packages/edit-store/lib/write-gate.d.ts +48 -2
  25. package/vendor/packages/edit-store/lib/write-gate.js +659 -25
  26. package/vendor/packages/i18n/locales/en/annotations.json +3 -0
  27. package/vendor/packages/i18n/locales/en/annotationscommon.json +9 -1
  28. package/vendor/packages/i18n/locales/en/tools.json +2 -0
  29. package/vendor/packages/i18n/locales/ja/annotations.json +3 -0
  30. package/vendor/packages/i18n/locales/ja/annotationscommon.json +9 -1
  31. package/vendor/packages/i18n/locales/ja/tools.json +2 -0
  32. package/vendor/packages/schemas/test/caption-look-shape-parity.test.mjs +73 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.3.0-beta.9",
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": {
@@ -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/**',
@@ -5,7 +5,7 @@
5
5
 
6
6
  ## 移行と書き出し
7
7
 
8
- 移行は render-cut の書き出し結果(HTML と CSS 変数)を保つ。移行済みの強調範囲は、描画時に `migrated_emphasis` に保存した元の項目と移行前の配列内の位置から `emphasis_words` へ戻す。残った項目の順を保ち、移した項目を位置の昇順に差し込む。追加のプロパティと項目の順序も保つ。旧形式だけの入力はそのまま描画器へ渡す。
8
+ 移行は主経路(`resolveCaptionPlan` を通る書き出しとプレビュー)で、前後の HTML と CSS 変数を保つ。移行済みの強調範囲は、描画時に `migrated_emphasis` に保存した元の項目と移行前の配列内の位置から `emphasis_words` へ戻す。残った項目の順を保ち、移した項目を位置の昇順に差し込む。追加のプロパティと項目の順序も保つ。旧形式だけの入力はそのまま描画器へ渡す。
9
9
 
10
10
  | `style_hint`(省略時の `emotion`) | `motion.in.id` |
11
11
  |---|---|
@@ -52,3 +52,46 @@ source anchor は字幕が描かれる同じ素材の配置ごとに、速度と
52
52
  ## 検証
53
53
 
54
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-beta.9",
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, validateCaptionsSemantics, CAPTION_RUN_FIELDS } 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";
@@ -452,7 +452,14 @@ export function validateCaptions(captions, edit, analysis, findings, paths, cuts
452
452
  captionFinding(findings, "captions.display-policy", error instanceof Error ? error.message : String(error), captionPath);
453
453
  }
454
454
  }
455
- for (const diagnostic of validateCaptionsSemantics({ captions: captionsRoot, edit, timeline })) {
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);
456
463
  addFinding(findings, { severity: diagnostic.severity, check: diagnostic.code,
457
464
  message: diagnostic.message, path: diagnostic.path });
458
465
  }
@@ -17,6 +17,7 @@ export const {
17
17
  TRANSITION_TYPE_IDS,
18
18
  withoutItemAnchors,
19
19
  validateCaptionsSemantics,
20
+ validateCaptionLookV1Shape,
20
21
  } = createRequire(import.meta.url)("../../../edit-store/lib/index.js");
21
22
 
22
23
 
@@ -1,2 +1,4 @@
1
1
  /** 鍵を再帰的に整列した JSON を UTF-8 で SHA-256 にする。 */
2
2
  export declare function captionValueHash(value: unknown): string;
3
+ /** UTF-8 の生バイト列をブラウザでも同じ SHA-256 にする。 */
4
+ export declare function projectFileContentHash(text: string): string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.captionValueHash = captionValueHash;
4
+ exports.projectFileContentHash = projectFileContentHash;
4
5
  /** 鍵を再帰的に整列した JSON を UTF-8 で SHA-256 にする。 */
5
6
  function captionValueHash(value) {
6
7
  const sorted = (entry) => {
@@ -10,7 +11,11 @@ function captionValueHash(value) {
10
11
  return Object.fromEntries(Object.keys(entry).sort().map(key => [key, sorted(entry[key])]));
11
12
  return entry;
12
13
  };
13
- const bytes = new TextEncoder().encode(JSON.stringify(sorted(value)));
14
+ return projectFileContentHash(JSON.stringify(sorted(value)));
15
+ }
16
+ /** UTF-8 の生バイト列をブラウザでも同じ SHA-256 にする。 */
17
+ function projectFileContentHash(text) {
18
+ const bytes = new TextEncoder().encode(text);
14
19
  const length = Math.ceil((bytes.length + 9) / 64) * 64;
15
20
  const padded = new Uint8Array(length);
16
21
  padded.set(bytes);
@@ -26,6 +26,8 @@ type CaptionsRoot = {
26
26
  emphasis_words?: Emphasis[];
27
27
  [key: string]: unknown;
28
28
  };
29
+ /** 旧い強調の ID は移行済み run に残った出所 ID も予約済みとして扱う。 */
30
+ export declare function nextEmphasisWordId(root: CaptionsRoot | CaptionRow[]): string;
29
31
  export declare const CAPTION_EMPHASIS_MOTION: Readonly<Record<string, string>>;
30
32
  export declare function readCaptionLookV1(root: CaptionsRoot | CaptionRow[], catalog: TextstyleCatalog): {
31
33
  captions: CaptionRow[];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CAPTION_EMPHASIS_MOTION = void 0;
4
+ exports.nextEmphasisWordId = nextEmphasisWordId;
4
5
  exports.readCaptionLookV1 = readCaptionLookV1;
5
6
  exports.planCaptionLookMigration = planCaptionLookMigration;
6
7
  exports.captionRenderInputFromV1 = captionRenderInputFromV1;
@@ -8,6 +9,30 @@ const caption_runs_1 = require("./caption-runs");
8
9
  const caption_hash_1 = require("./caption-hash");
9
10
  const caption_look_v1_1 = require("./caption-look-v1");
10
11
  const caption_style_preset_1 = require("./caption-style-preset");
12
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ const captionRows = (root) => Array.isArray(root) ? root : Array.isArray(root?.captions) ? root.captions : [];
14
+ const emphasisWords = (root) => Array.isArray(root) || !Array.isArray(root?.emphasis_words) ? [] : root.emphasis_words;
15
+ const unresolvedRuns = (root) => Array.isArray(root) || !Array.isArray(root?.unresolved_runs) ? []
16
+ : root.unresolved_runs.flatMap(item => isRecord(item) && isRecord(item.run)
17
+ ? [item.run] : []);
18
+ /** 旧い強調の ID は移行済み run に残った出所 ID も予約済みとして扱う。 */
19
+ function nextEmphasisWordId(root) {
20
+ const used = new Set(emphasisWords(root).flatMap(item => isRecord(item) && typeof item.id === 'string'
21
+ ? [item.id] : []));
22
+ for (const row of captionRows(root))
23
+ for (const run of rowRuns(row))
24
+ if (isRecord(run) && typeof run.migrated_from === 'string')
25
+ used.add(run.migrated_from);
26
+ for (const run of unresolvedRuns(root))
27
+ if (typeof run.migrated_from === 'string')
28
+ used.add(run.migrated_from);
29
+ let number = 1;
30
+ while (used.has(`e-${String(number).padStart(4, '0')}`))
31
+ number++;
32
+ if (number > 9999)
33
+ throw new Error('強調語の id に空きがありません。');
34
+ return `e-${String(number).padStart(4, '0')}`;
35
+ }
11
36
  exports.CAPTION_EMPHASIS_MOTION = {
12
37
  'one-char-bang': 'pop', 'one-char-jumble': 'jitter', 'size-pulse': 'zoom-pulse'
13
38
  };
@@ -41,6 +66,8 @@ function projectEmphasis(rows, item) {
41
66
  if (!motion)
42
67
  return undefined;
43
68
  const candidates = rows.flatMap(row => {
69
+ if (!isRecord(row))
70
+ return [];
44
71
  if (row.runs !== undefined && (!Array.isArray(row.runs)
45
72
  || row.runs.some(run => !run || typeof run !== 'object' || Array.isArray(run))))
46
73
  return [];
@@ -50,7 +77,7 @@ function projectEmphasis(rows, item) {
50
77
  return candidates.length === 1 ? candidates[0] : undefined;
51
78
  }
52
79
  function rowRuns(row) {
53
- return Array.isArray(row.runs) ? row.runs : [];
80
+ return isRecord(row) && Array.isArray(row.runs) ? row.runs : [];
54
81
  }
55
82
  function invalidLegacyRun(run, row) {
56
83
  if (['id', 'look', 'motion', 'anchor', 'fire'].some(key => Object.prototype.hasOwnProperty.call(run, key))) {
@@ -61,15 +88,16 @@ function invalidLegacyRun(run, row) {
61
88
  || run.from < 0 || run.to > length || run.from >= run.to;
62
89
  }
63
90
  function readCaptionLookV1(root, catalog) {
64
- const rows = Array.isArray(root) ? root : root.captions;
65
- const emphasis = Array.isArray(root) ? [] : root.emphasis_words ?? [];
91
+ const rows = captionRows(root);
92
+ const emphasis = emphasisWords(root);
66
93
  const projected = new Map();
67
- const allRuns = [...rows.flatMap(rowRuns), ...(Array.isArray(root) ? [] :
68
- (Array.isArray(root.unresolved_runs) ? root.unresolved_runs.flatMap(item => item.run ? [item.run] : []) : []))];
94
+ const allRuns = [...rows.flatMap(rowRuns), ...unresolvedRuns(root)];
69
95
  const occupiedIndices = new Set(allRuns.flatMap(run => run && typeof run === 'object'
70
96
  && Number.isInteger(run.migrated_emphasis?.index) ? [run.migrated_emphasis.index] : []));
71
97
  let originalIndex = 0;
72
98
  for (const item of emphasis) {
99
+ if (!isRecord(item))
100
+ continue;
73
101
  while (occupiedIndices.has(originalIndex))
74
102
  originalIndex++;
75
103
  const index = originalIndex++;
@@ -80,12 +108,16 @@ function readCaptionLookV1(root, catalog) {
80
108
  }
81
109
  const notices = [];
82
110
  const captions = rows.map(row => {
111
+ if (!isRecord(row))
112
+ return row;
83
113
  const resolved = (0, caption_style_preset_1.resolveCaptionStylePreset)(row, catalog);
84
114
  const runs = rowRuns(row).map(run => !run || typeof run !== 'object' || Array.isArray(run)
85
115
  || invalidLegacyRun(run, row) ? run : { ...run,
86
116
  ...(run.style && !run.look ? { look: { ...run.style } } : {}),
87
117
  ...(run.animation && !run.dormant_animation ? { dormant_animation: run.animation } : {}) });
88
118
  for (const item of emphasis) {
119
+ if (!isRecord(item))
120
+ continue;
89
121
  if (runs.some(run => run && typeof run === 'object' && run.migrated_from === item.id))
90
122
  continue;
91
123
  const interval = projected.get(item.id);
@@ -109,15 +141,15 @@ function readCaptionLookV1(root, catalog) {
109
141
  }
110
142
  function planCaptionLookMigration(captions, catalog, _edit) {
111
143
  const source = structuredClone(captions);
112
- const rows = Array.isArray(source) ? source : source.captions;
113
- const emphasis = Array.isArray(source) ? [] : source.emphasis_words ?? [];
144
+ const rows = captionRows(source);
145
+ const emphasis = emphasisWords(source);
114
146
  const unresolved = [];
115
147
  const notices = [];
116
- const allRuns = [...rows.flatMap(rowRuns),
117
- ...(Array.isArray(source) ? [] : (Array.isArray(source.unresolved_runs)
118
- ? source.unresolved_runs.flatMap(item => item.run ? [item.run] : []) : []))];
148
+ const allRuns = [...rows.flatMap(rowRuns), ...unresolvedRuns(source)];
119
149
  let invalidLegacyCount = 0;
120
150
  for (const row of rows) {
151
+ if (!isRecord(row))
152
+ continue;
121
153
  if (row.style_preset) {
122
154
  const preset = (0, caption_style_preset_1.resolveCaptionStylePreset)(row, catalog);
123
155
  if (preset.resolved) {
@@ -174,13 +206,15 @@ function planCaptionLookMigration(captions, catalog, _edit) {
174
206
  const migrated = new Set();
175
207
  let originalIndex = 0;
176
208
  for (const item of emphasis) {
209
+ if (!isRecord(item))
210
+ continue;
177
211
  while (occupiedIndices.has(originalIndex))
178
212
  originalIndex++;
179
213
  const index = originalIndex++;
180
- if (rows.some(row => rowRuns(row).some(run => run && typeof run === 'object'
181
- && run.migrated_from === item.id))) {
182
- migrated.add(item.id);
183
- continue;
214
+ if (allRuns.some(run => run && typeof run === 'object' && run.migrated_from === item.id)) {
215
+ const old = item.id;
216
+ item.id = nextEmphasisWordId(source);
217
+ notices.push(`強調語 ${old} は id が重なっていたので ${item.id} に振り直しました`);
184
218
  }
185
219
  const candidate = projectEmphasis(rows, item);
186
220
  if (!candidate) {
@@ -199,13 +233,15 @@ function planCaptionLookMigration(captions, catalog, _edit) {
199
233
  migrated.add(item.id);
200
234
  occupiedIndices.add(index);
201
235
  }
202
- if (!Array.isArray(source) && Object.prototype.hasOwnProperty.call(source, 'emphasis_words')) {
203
- source.emphasis_words = emphasis.filter(item => !migrated.has(item.id));
236
+ if (!Array.isArray(source) && Array.isArray(source.emphasis_words)) {
237
+ source.emphasis_words = emphasis.filter(item => !isRecord(item) || !migrated.has(item.id));
204
238
  }
205
- const originalRows = Array.isArray(captions) ? captions : captions.captions;
239
+ const originalRows = captionRows(captions);
206
240
  const diff = [];
207
241
  rows.forEach((row, index) => {
208
- const before = originalRows[index] ?? {};
242
+ if (!isRecord(row))
243
+ return;
244
+ const before = isRecord(originalRows[index]) ? originalRows[index] : {};
209
245
  for (const key of new Set([...Object.keys(before), ...Object.keys(row)])) {
210
246
  if (JSON.stringify(before[key]) !== JSON.stringify(row[key]))
211
247
  diff.push({
@@ -218,19 +254,36 @@ function planCaptionLookMigration(captions, catalog, _edit) {
218
254
  diff.push({ path: 'emphasis_words', before: captions.emphasis_words, after: source.emphasis_words });
219
255
  }
220
256
  return { next: source, diff,
221
- unresolved, notices };
257
+ unresolved, notices: [...new Set(notices)] };
222
258
  }
223
259
  /** Restore the renderer's legacy input without changing a legacy-only document. */
224
260
  function captionRenderInputFromV1(root) {
225
- const rows = Array.isArray(root) ? root : root.captions;
261
+ const rows = captionRows(root);
262
+ const legacyIds = emphasisWords(root).flatMap(item => isRecord(item) ? [item.id] : []);
226
263
  if (!rows.some(row => rowRuns(row).some(run => run && typeof run === 'object'
227
- && (run.look || run.dormant_animation || run.migrated_from))))
264
+ && (run.look || run.dormant_animation || run.migrated_from)))
265
+ && new Set(legacyIds).size === legacyIds.length)
228
266
  return root;
229
267
  const copy = structuredClone(root);
230
- const nextRows = Array.isArray(copy) ? copy : copy.captions;
231
- const emphasis = Array.isArray(copy) ? [] : [...(copy.emphasis_words ?? [])];
268
+ const nextRows = captionRows(copy);
269
+ const emphasis = [...emphasisWords(copy)];
270
+ const migratedIds = new Set();
271
+ for (const row of nextRows)
272
+ for (const run of rowRuns(row))
273
+ if (typeof run?.migrated_from === 'string')
274
+ migratedIds.add(run.migrated_from);
275
+ const ids = new Set();
276
+ for (const item of emphasis) {
277
+ if (!isRecord(item))
278
+ continue;
279
+ if (ids.has(item.id) || migratedIds.has(item.id))
280
+ item.id = nextEmphasisWordId({ captions: nextRows, emphasis_words: emphasis });
281
+ ids.add(item.id);
282
+ }
232
283
  const migrated = [];
233
284
  for (const row of nextRows) {
285
+ if (!isRecord(row))
286
+ continue;
234
287
  if (!Array.isArray(row.runs))
235
288
  continue;
236
289
  row.runs = row.runs.flatMap((run) => {
@@ -240,13 +293,25 @@ function captionRenderInputFromV1(root) {
240
293
  const hint = Object.keys(exports.CAPTION_EMPHASIS_MOTION)
241
294
  .find(key => exports.CAPTION_EMPHASIS_MOTION[key] === run.motion?.in?.id);
242
295
  const restored = run.migrated_emphasis?.item;
243
- if (restored)
244
- migrated.push({ index: run.migrated_emphasis.index, item: structuredClone(restored) });
245
- else if (hint)
246
- emphasis.push({ id: run.migrated_from,
296
+ if (restored) {
297
+ const item = structuredClone(restored);
298
+ if (ids.has(item.id))
299
+ item.id = nextEmphasisWordId({ captions: nextRows,
300
+ emphasis_words: [...emphasis, ...migrated.map(entry => entry.item)] });
301
+ ids.add(item.id);
302
+ migrated.push({ index: run.migrated_emphasis.index, item });
303
+ }
304
+ else if (hint) {
305
+ let id = run.migrated_from;
306
+ if (ids.has(id))
307
+ id = nextEmphasisWordId({ captions: nextRows,
308
+ emphasis_words: [...emphasis, ...migrated.map(entry => entry.item)] });
309
+ ids.add(id);
310
+ emphasis.push({ id,
247
311
  word: (0, caption_runs_1.captionGraphemes)(row.display_text ?? row.text ?? '').slice(run.from, run.to).join(''),
248
312
  t_start: run.anchor.start, t_end: run.anchor.end, emotion: run.emotion ?? 'emphasis',
249
313
  style_hint: hint, ...(run.anchor.src ? { src: run.anchor.src } : {}) });
314
+ }
250
315
  return [];
251
316
  }
252
317
  const next = { ...run };
@@ -263,7 +328,7 @@ function captionRenderInputFromV1(root) {
263
328
  return [next];
264
329
  });
265
330
  }
266
- if (!Array.isArray(copy) && (Object.prototype.hasOwnProperty.call(copy, 'emphasis_words') || migrated.length)) {
331
+ if (!Array.isArray(copy) && (Array.isArray(copy.emphasis_words) || migrated.length)) {
267
332
  const restored = [...emphasis];
268
333
  for (const entry of migrated.sort((left, right) => left.index - right.index)) {
269
334
  restored.splice(Math.min(entry.index, restored.length), 0, entry.item);
@@ -0,0 +1,18 @@
1
+ import { type CaptionSemanticDiagnostic, type CaptionSemanticsContext } from './caption-semantics';
2
+ export declare const CAPTION_LOOK_SHAPE_ANIMATION_IDS: string[];
3
+ export declare const CAPTION_LOOK_SHAPE_KEYS: {
4
+ run: string[];
5
+ look: string[];
6
+ anchor: string[];
7
+ styleSources: string[];
8
+ source: string[];
9
+ unresolvedRun: string[];
10
+ };
11
+ /** v1 の追加項目だけを検査する。旧い行の他の項目と runs v0 の形は許す。 */
12
+ export declare function validateCaptionLookV1Shape(captionsRoot: unknown): CaptionSemanticDiagnostic[];
13
+ export declare class CaptionsInvalidError extends Error {
14
+ readonly diagnostics: CaptionSemanticDiagnostic[];
15
+ readonly code = "AKARI_CAPTIONS_INVALID";
16
+ constructor(diagnostics: CaptionSemanticDiagnostic[]);
17
+ }
18
+ export declare function assertCaptionsWritable(captionsRoot: CaptionSemanticsContext['captions'], ctx?: Omit<CaptionSemanticsContext, 'captions'>): void;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CaptionsInvalidError = exports.CAPTION_LOOK_SHAPE_KEYS = exports.CAPTION_LOOK_SHAPE_ANIMATION_IDS = void 0;
4
+ exports.validateCaptionLookV1Shape = validateCaptionLookV1Shape;
5
+ exports.assertCaptionsWritable = assertCaptionsWritable;
6
+ const caption_semantics_1 = require("./caption-semantics");
7
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ const has = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
9
+ const number = (value) => typeof value === 'number' && Number.isFinite(value);
10
+ const positive = (value) => number(value) && value > 0;
11
+ const nonnegative = (value) => number(value) && value >= 0;
12
+ const integer = (value) => Number.isInteger(value);
13
+ const nonempty = (value) => typeof value === 'string' && value.length > 0;
14
+ const hex = (value) => typeof value === 'string' && /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6}|[\da-fA-F]{8})$/.test(value);
15
+ const runId = (value) => typeof value === 'string' && /^r-\d{4,}$/.test(value);
16
+ const fire = (value) => ['shown', 'spoken', 'whole'].includes(String(value));
17
+ const enumOf = (...values) => (value) => typeof value === 'string' && values.includes(value);
18
+ function objectRule(fields, required = [], minProperties = 0) {
19
+ return value => isObject(value) && Object.keys(value).length >= minProperties
20
+ && required.every(key => has(value, key))
21
+ && Object.entries(value).every(([key, entry]) => fields[key] !== undefined && fields[key](entry));
22
+ }
23
+ const stroke = objectRule({ method: enumOf('webkit-outline'), color: hex, width_px: nonnegative });
24
+ const shadow = objectRule({ color: hex, opacity: value => number(value) && value >= 0 && value <= 1,
25
+ blur_px: nonnegative, distance_px: nonnegative, angle_deg: number }, ['color']);
26
+ const glow = objectRule({ color: hex, density: nonnegative, spread: nonnegative, offset_x: number, offset_y: number }, ['color']);
27
+ const background = objectRule({ color: hex, opacity: value => number(value) && value >= 0 && value <= 1,
28
+ radius_px: nonnegative, padding_px: nonnegative, width_pct: nonnegative, height_pct: nonnegative,
29
+ fit: enumOf('text', 'frame'), offset_x: number, offset_y: number, mode: enumOf('per-line', 'block') });
30
+ const lookFields = { color: hex, font_weight: value => integer(value) && value >= 1 && value <= 1000,
31
+ italic: value => typeof value === 'boolean', underline: value => typeof value === 'boolean', letter_spacing_em: number,
32
+ scale: positive, baseline_shift_em: number, rotate_deg: value => number(value) && Math.abs(value) <= 180,
33
+ stroke, shadow, glow, background };
34
+ const look = objectRule(lookFields);
35
+ exports.CAPTION_LOOK_SHAPE_ANIMATION_IDS = ['fade-in-out', 'soft-fade', 'fade-up', 'fade-down', 'cinematic-fade',
36
+ 'slide-left', 'slide-right', 'slide-up', 'slide-down', 'push-left', 'push-right', 'push-up', 'push-down',
37
+ 'rise-soft', 'drop-in', 'zoom-in-out', 'zoom-pop', 'zoom-pulse', 'pop', 'bounce', 'squash-pop',
38
+ 'stretch-in', 'stomp', 'snap', 'rotate-in', 'spin-in', 'roll-in', 'spiral-in', 'swing', 'shake',
39
+ 'jitter', 'glitch', 'flash', 'heartbeat', 'typewriter', 'wipe-left', 'wipe-right', 'wobble',
40
+ 'float', 'breath', 'neon-flicker', 'hologram', 'retro-flicker', 'caption-rise', 'news-ticker',
41
+ 'marquee-left', 'crawl-up'];
42
+ const animationIds = new Set(exports.CAPTION_LOOK_SHAPE_ANIMATION_IDS);
43
+ const slot = objectRule({ id: value => typeof value === 'string' && animationIds.has(value), duration_sec: positive,
44
+ ease: value => value === null || typeof value === 'string' && /\S/.test(value),
45
+ amp: value => value === null || positive(value) }, ['id']);
46
+ const motion = objectRule({ in: slot, loop: slot, out: slot }, [], 1);
47
+ const anchorFields = { src: nonempty, start: nonnegative, end: nonnegative,
48
+ domain: enumOf('source', 'output') };
49
+ const anchor = objectRule(anchorFields, ['start', 'end', 'domain']);
50
+ const sourceFields = { kind: enumOf('builtin', 'library', 'my'), id: nonempty, uid: nonempty,
51
+ revision: value => integer(value) && value >= 0, original_hash: nonempty,
52
+ applied_hash: nonempty, applied_at: nonempty,
53
+ requires: value => Array.isArray(value) && value.every(objectRule({ id: nonempty,
54
+ version: value => typeof value === 'string', hash: nonempty }, ['id', 'version', 'hash'])) };
55
+ const source = objectRule(sourceFields, ['kind', 'id', 'original_hash', 'applied_hash']);
56
+ const styleSourcesFields = { look: source, motion: source };
57
+ const styleSources = objectRule(styleSourcesFields);
58
+ const migratedEmphasis = objectRule({ index: nonnegative, item: value => isObject(value) }, ['index', 'item']);
59
+ const v1Fields = {
60
+ id: runId, from: integer, to: integer, role: nonempty, look, motion, emotion: nonempty, anchor,
61
+ fire, fire_was: fire, style_sources: styleSources, needs_review: value => typeof value === 'boolean',
62
+ split_from: runId, migrated_from: value => typeof value === 'string' && /^e-\d{4,}$/.test(value),
63
+ dormant_animation: motion, migrated_emphasis: migratedEmphasis
64
+ };
65
+ const unresolvedFields = ['run', 'old_caption_id', 'old_text', 'old_src', 'old_start', 'old_end', 'reason'];
66
+ exports.CAPTION_LOOK_SHAPE_KEYS = {
67
+ run: [...Object.keys(v1Fields), 'style', 'animation'], look: Object.keys(lookFields), anchor: Object.keys(anchorFields),
68
+ styleSources: Object.keys(styleSourcesFields), source: Object.keys(sourceFields), unresolvedRun: unresolvedFields
69
+ };
70
+ const idRequired = ['look', 'motion', 'anchor', 'fire', 'style_sources', 'migrated_from'];
71
+ /** v1 の追加項目だけを検査する。旧い行の他の項目と runs v0 の形は許す。 */
72
+ function validateCaptionLookV1Shape(captionsRoot) {
73
+ const diagnostics = [];
74
+ const add = (path, field) => diagnostics.push({ code: 'captions.look.shape', severity: 'error',
75
+ path, message: `${field} の形が字幕の規則に合いません。`, layer: 'document' });
76
+ const rows = Array.isArray(captionsRoot) ? captionsRoot : isObject(captionsRoot) ? captionsRoot.captions : undefined;
77
+ const checkRun = (run, path) => {
78
+ if (!isObject(run)) {
79
+ add(path, 'run');
80
+ return;
81
+ }
82
+ const v1 = Object.keys(v1Fields).some(key => has(run, key));
83
+ if (v1)
84
+ for (const key of ['from', 'to'])
85
+ if (!has(run, key))
86
+ add(path, key);
87
+ if (idRequired.some(key => has(run, key)) && !has(run, 'id'))
88
+ add(path, 'id');
89
+ for (const [key, rule] of Object.entries(v1Fields))
90
+ if (has(run, key) && !rule(run[key]))
91
+ add(`${path}.${key}`, key);
92
+ for (const key of Object.keys(run))
93
+ if (!has(v1Fields, key) && !['style', 'animation'].includes(key))
94
+ add(`${path}.${key}`, key);
95
+ };
96
+ if (Array.isArray(rows))
97
+ rows.forEach((row, index) => {
98
+ if (!isObject(row))
99
+ return;
100
+ if (has(row, 'style_sources') && !styleSources(row.style_sources))
101
+ add(`captions.json#captions[${index}].style_sources`, 'style_sources');
102
+ if (Array.isArray(row.runs))
103
+ row.runs.forEach((run, runIndex) => checkRun(run, `captions.json#captions[${index}].runs[${runIndex}]`));
104
+ });
105
+ if (isObject(captionsRoot) && has(captionsRoot, 'unresolved_runs')) {
106
+ const value = captionsRoot.unresolved_runs;
107
+ const valid = Array.isArray(value) && value.every((item, index) => {
108
+ if (!isObject(item))
109
+ return false;
110
+ checkRun(item.run, `captions.json#unresolved_runs[${index}].run`);
111
+ return unresolvedFields.every(key => has(item, key)) && Object.keys(item).every(key => unresolvedFields.includes(key))
112
+ && isObject(item.run) && typeof item.old_caption_id === 'string'
113
+ && typeof item.old_text === 'string' && (item.old_src === null || typeof item.old_src === 'string')
114
+ && number(item.old_start) && number(item.old_end) && nonempty(item.reason);
115
+ });
116
+ if (!valid)
117
+ add('captions.json#unresolved_runs', 'unresolved_runs');
118
+ }
119
+ return diagnostics;
120
+ }
121
+ class CaptionsInvalidError extends Error {
122
+ constructor(diagnostics) {
123
+ super(`字幕の内容を保存できません: ${diagnostics.map(item => item.message).join('、')}`);
124
+ this.diagnostics = diagnostics;
125
+ this.code = 'AKARI_CAPTIONS_INVALID';
126
+ this.name = 'CaptionsInvalidError';
127
+ }
128
+ }
129
+ exports.CaptionsInvalidError = CaptionsInvalidError;
130
+ function assertCaptionsWritable(captionsRoot, ctx = {}) {
131
+ const diagnostics = [...validateCaptionLookV1Shape(captionsRoot), ...(0, caption_semantics_1.validateCaptionsSemantics)({ ...ctx, captions: captionsRoot })];
132
+ if (diagnostics.some(item => item.severity === 'error'))
133
+ throw new CaptionsInvalidError(diagnostics);
134
+ }
@@ -27,6 +27,7 @@ export declare function lookForCaptionRow(row: Record<string, unknown>, defaultS
27
27
  text_style?: Record<string, unknown>;
28
28
  notices: string[];
29
29
  reason?: string;
30
+ reasonCode?: 'AKARI_CAPTION_LAYOUT_REFERENCE_CONFLICT';
30
31
  };
31
32
  export declare function lookForCaptionRun(row: Record<string, unknown>, defaultStyle: Record<string, unknown>, styleLook: Record<string, unknown>, catalog?: Record<string, {
32
33
  style: Record<string, unknown>;
@@ -146,7 +146,8 @@ function lookForCaptionRow(row, defaultStyle, styleLook, options) {
146
146
  }
147
147
  }
148
148
  if (textStyle.layout !== undefined && textStyle.reference_height_px !== undefined) {
149
- return { notices: [], reason: 'layout と reference_height_px を同時に指定できません' };
149
+ return { notices: [], reason: 'layout と reference_height_px を同時に指定できません',
150
+ reasonCode: 'AKARI_CAPTION_LAYOUT_REFERENCE_CONFLICT' };
150
151
  }
151
152
  return { text_style: textStyle, notices: [] };
152
153
  }
@@ -301,6 +301,10 @@ export declare function captionEditNotices(result: {
301
301
  removedEmphasis: readonly CaptionEmphasis[];
302
302
  }, oldDisplayText: string): string[];
303
303
  export declare function applyWordBookToCaptionsInSource(source: string, changes: WordBookCaptionChange[]): string;
304
+ export declare function applyWordBookToCaptionsInSourceWithNotices(source: string, changes: WordBookCaptionChange[]): {
305
+ source: string;
306
+ notices: string[];
307
+ };
304
308
  export declare function updateCaptionTextStyleInSource(source: string, captionId: string, updates: CaptionTextStylePatch): string;
305
309
  export declare function updateCaptionStylePresetInSource(source: string, captionIds: readonly string[], presetId: string | null, options?: {
306
310
  catalog?: TextstyleCatalog;