akari-video 1.3.0-beta.8 → 1.3.0-beta.9

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 (31) hide show
  1. package/package.json +1 -1
  2. package/vendor/.akari-capability-sources.json +1 -0
  3. package/vendor/docs/contract-2026-09-24-caption-runs-v0.md +2 -0
  4. package/vendor/docs/contract-2026-09-24-style-v0.md +2 -0
  5. package/vendor/docs/contract-2026-10-12-caption-look-v1.md +54 -0
  6. package/vendor/packages/akari-launcher/package.json +1 -1
  7. package/vendor/packages/edit-lint/src/lint/captions.mjs +12 -5
  8. package/vendor/packages/edit-lint/src/lint/external.mjs +4 -0
  9. package/vendor/packages/edit-store/lib/caption-anchor-map.d.ts +51 -0
  10. package/vendor/packages/edit-store/lib/caption-anchor-map.js +126 -0
  11. package/vendor/packages/edit-store/lib/caption-display.js +77 -9
  12. package/vendor/packages/edit-store/lib/caption-hash.d.ts +2 -0
  13. package/vendor/packages/edit-store/lib/caption-hash.js +64 -0
  14. package/vendor/packages/edit-store/lib/caption-look-reader.d.ts +46 -0
  15. package/vendor/packages/edit-store/lib/caption-look-reader.js +274 -0
  16. package/vendor/packages/edit-store/lib/caption-look-v1.d.ts +36 -0
  17. package/vendor/packages/edit-store/lib/caption-look-v1.js +155 -0
  18. package/vendor/packages/edit-store/lib/caption-runs.d.ts +72 -2
  19. package/vendor/packages/edit-store/lib/caption-runs.js +200 -39
  20. package/vendor/packages/edit-store/lib/caption-semantics.d.ts +34 -0
  21. package/vendor/packages/edit-store/lib/caption-semantics.js +123 -0
  22. package/vendor/packages/edit-store/lib/caption-store.d.ts +1 -0
  23. package/vendor/packages/edit-store/lib/caption-store.js +26 -9
  24. package/vendor/packages/edit-store/lib/caption-window.js +13 -4
  25. package/vendor/packages/edit-store/lib/index.d.ts +5 -0
  26. package/vendor/packages/edit-store/lib/index.js +5 -0
  27. package/vendor/packages/edit-store/lib/webview-kernel.js +12 -5
  28. package/vendor/packages/schemas/bin/validate-captions.mjs +47 -11
  29. package/vendor/packages/schemas/captions.schema.json +92 -2
  30. package/vendor/packages/schemas/test/caption-look-v1.test.mjs +46 -0
  31. 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-beta.8",
3
+ "version": "1.3.0-beta.9",
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": {
@@ -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",
@@ -1,5 +1,7 @@
1
1
  # 字幕の文字範囲(runs)v0
2
2
 
3
+ v1 で拡張: [contract-2026-10-12-caption-look-v1.md](./contract-2026-10-12-caption-look-v1.md)
4
+
3
5
  - 日付: 2026-09-24
4
6
  - 状態: v0
5
7
 
@@ -1,5 +1,7 @@
1
1
  # マイスタイル v0 — 保存形と適用契約
2
2
 
3
+ v1 で拡張: [contract-2026-10-12-caption-look-v1.md](./contract-2026-10-12-caption-look-v1.md)
4
+
3
5
  - 日付: 2026-09-24
4
6
  - 状態: v0
5
7
 
@@ -0,0 +1,54 @@
1
+ # 字幕の見た目 v1 — 保存・換算・範囲・時刻・検証
2
+
3
+ - 日付: 2026-10-12
4
+ - 状態: v1
5
+
6
+ ## 移行と書き出し
7
+
8
+ 移行は render-cut の書き出し結果(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 を描けるか判定する。診断はコード・重大度・パス・日本語メッセージを共有する。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "1.3.0-beta.8",
3
+ "version": "1.3.0-beta.9",
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": [
@@ -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, 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 => !["from", "to", "role", "style", "animation"].includes(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,10 @@ 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
+ for (const diagnostic of validateCaptionsSemantics({ captions: captionsRoot, edit, timeline })) {
456
+ addFinding(findings, { severity: diagnostic.severity, check: diagnostic.code,
457
+ message: diagnostic.message, path: diagnostic.path });
458
+ }
452
459
  }
453
460
 
454
461
  function validateEmphasisWords(emphasisWords, findings, captionPath) {
@@ -16,8 +16,10 @@ export const {
16
16
  toAnchorCaptions,
17
17
  TRANSITION_TYPE_IDS,
18
18
  withoutItemAnchors,
19
+ validateCaptionsSemantics,
19
20
  } = createRequire(import.meta.url)("../../../edit-store/lib/index.js");
20
21
 
22
+
21
23
  export const { captionsHaveRenderableCues, collectFitBasisCandidates } = createRequire(import.meta.url)(
22
24
  "../../../edit-store/lib/migrate/index.js",
23
25
  );
@@ -27,6 +29,8 @@ const CAPTIONS_SCHEMA = JSON.parse(readFileSync(
27
29
  "utf8",
28
30
  ));
29
31
 
32
+ export const CAPTION_RUN_FIELDS = new Set(Object.keys(CAPTIONS_SCHEMA.$defs.captionRun.properties));
33
+
30
34
  export const CAPTION_TEXT_STYLE_FIELDS = new Set(
31
35
  Object.keys(CAPTIONS_SCHEMA.$defs.textStyle.properties),
32
36
  );
@@ -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
- ...(cueRuns ? { runs: cueRuns } : {}),
427
- ...(group.lines.length >= 2 ? { display_lines: group.lines } : {}),
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
- ...(wordDisplay ? { words: wordDisplay.words } : {}),
435
- ...(wordDisplay?.wordStyles.length ? { word_styles: wordDisplay.wordStyles } : {}),
447
+ ...(display.words ? { words: display.words } : {}),
448
+ ...(display.wordStyles?.length ? { word_styles: display.wordStyles } : {}),
436
449
  ...(karaoke ? {
437
450
  style: 'karaoke',
438
- karaoke_offset: captionDisplayGraphemes(projectedCaptions[occurrence.caption_input_index].displayText.slice(0, group.charStart)).length
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) => !strictText(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
  }
@@ -0,0 +1,2 @@
1
+ /** 鍵を再帰的に整列した JSON を UTF-8 で SHA-256 にする。 */
2
+ export declare function captionValueHash(value: unknown): string;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.captionValueHash = captionValueHash;
4
+ /** 鍵を再帰的に整列した JSON を UTF-8 で SHA-256 にする。 */
5
+ function captionValueHash(value) {
6
+ const sorted = (entry) => {
7
+ if (Array.isArray(entry))
8
+ return entry.map(sorted);
9
+ if (entry && typeof entry === 'object')
10
+ return Object.fromEntries(Object.keys(entry).sort().map(key => [key, sorted(entry[key])]));
11
+ return entry;
12
+ };
13
+ const bytes = new TextEncoder().encode(JSON.stringify(sorted(value)));
14
+ const length = Math.ceil((bytes.length + 9) / 64) * 64;
15
+ const padded = new Uint8Array(length);
16
+ padded.set(bytes);
17
+ padded[bytes.length] = 0x80;
18
+ const bitLength = BigInt(bytes.length) * 8n;
19
+ const view = new DataView(padded.buffer);
20
+ view.setUint32(length - 8, Number(bitLength >> 32n));
21
+ view.setUint32(length - 4, Number(bitLength & 0xffffffffn));
22
+ const constants = [
23
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
24
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
25
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
26
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
27
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
28
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
29
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
30
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
31
+ ];
32
+ const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
33
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
34
+ const rotate = (n, bits) => (n >>> bits) | (n << (32 - bits));
35
+ const words = new Uint32Array(64);
36
+ for (let offset = 0; offset < length; offset += 64) {
37
+ for (let i = 0; i < 16; i++)
38
+ words[i] = view.getUint32(offset + i * 4);
39
+ for (let i = 16; i < 64; i++) {
40
+ const a = words[i - 15];
41
+ const b = words[i - 2];
42
+ words[i] = (words[i - 16] + (rotate(a, 7) ^ rotate(a, 18) ^ (a >>> 3))
43
+ + words[i - 7] + (rotate(b, 17) ^ rotate(b, 19) ^ (b >>> 10))) >>> 0;
44
+ }
45
+ let [a, b, c, d, e, f, g, h] = state;
46
+ for (let i = 0; i < 64; i++) {
47
+ const first = (h + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25))
48
+ + ((e & f) ^ (~e & g)) + constants[i] + words[i]) >>> 0;
49
+ const second = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22))
50
+ + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
51
+ h = g;
52
+ g = f;
53
+ f = e;
54
+ e = (d + first) >>> 0;
55
+ d = c;
56
+ c = b;
57
+ b = a;
58
+ a = (first + second) >>> 0;
59
+ }
60
+ for (const [index, value] of [a, b, c, d, e, f, g, h].entries())
61
+ state[index] = (state[index] + value) >>> 0;
62
+ }
63
+ return `sha256:${state.map(word => word.toString(16).padStart(8, '0')).join('')}`;
64
+ }
@@ -0,0 +1,46 @@
1
+ import { type CaptionRun } from './caption-runs';
2
+ import { type TextstyleCatalog } from './caption-style-preset';
3
+ type CaptionRow = Record<string, unknown> & {
4
+ id?: string;
5
+ start?: number;
6
+ end?: number;
7
+ text?: string;
8
+ display_text?: string;
9
+ src?: string;
10
+ runs?: CaptionRun[];
11
+ style_preset?: string;
12
+ };
13
+ type Emphasis = {
14
+ id: string;
15
+ src?: string;
16
+ t_start: number;
17
+ t_end: number;
18
+ word: string;
19
+ emotion: string;
20
+ style_hint?: string;
21
+ style_preset?: string;
22
+ [key: string]: unknown;
23
+ };
24
+ type CaptionsRoot = {
25
+ captions: CaptionRow[];
26
+ emphasis_words?: Emphasis[];
27
+ [key: string]: unknown;
28
+ };
29
+ export declare const CAPTION_EMPHASIS_MOTION: Readonly<Record<string, string>>;
30
+ export declare function readCaptionLookV1(root: CaptionsRoot | CaptionRow[], catalog: TextstyleCatalog): {
31
+ captions: CaptionRow[];
32
+ notices: string[];
33
+ };
34
+ export declare function planCaptionLookMigration(captions: CaptionsRoot | CaptionRow[], catalog: TextstyleCatalog, _edit?: unknown): {
35
+ next: CaptionsRoot | CaptionRow[];
36
+ diff: Array<{
37
+ path: string;
38
+ before: unknown;
39
+ after: unknown;
40
+ }>;
41
+ unresolved: Emphasis[];
42
+ notices: string[];
43
+ };
44
+ /** Restore the renderer's legacy input without changing a legacy-only document. */
45
+ export declare function captionRenderInputFromV1<T extends CaptionsRoot | CaptionRow[]>(root: T): T;
46
+ export {};