akari-video 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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": {
@@ -99,3 +99,31 @@ perspective のレイヤー分割フォールバック — perspective は `crop
99
99
  "per-frame 評価に対応しない" 制約を持つが、ffmpeg 側に時刻変数自体が無いためこの技法すら
100
100
  使えず、レイヤー分割へフォールバックする点が crop/framing と異なる)・プレビュー再現の詳細は
101
101
  すべて同契約 §2.4.7 に記載する(本ファイルでの重複記載はしない — SSOT は 1 箇所)。
102
+
103
+ - **レイヤー拡大・crop の固定キャンバス化(2026-08-24 追記)**: normal blend かつ
104
+ perspective/rotate 非併用の `layers[].keyframes` では、`transform.scale` に応じて素材の整数
105
+ bitmap 寸法を毎フレーム変え、その可変 `overlay_w/overlay_h` を中央配置する方式を廃止した。
106
+ 素材ネイティブ寸法を 2 倍した固定グリッド(`LAYER_KEYFRAME_SUPERSAMPLE=2`)内で scale/crop
107
+ を補間し、最大 footprint の固定透明キャンバスへ偶数座標で配置・固定 crop した後、Lanczos で
108
+ 実寸へ縮小する。これにより overlay の外形寸法は全フレーム不変となり、拡大後サイズの偶奇変化
109
+ による中央座標の ±1px 往復を除去しつつ、PiP の footprint 自体は固定グリッド内で連続的に
110
+ 拡縮する。倍率値は `cut-framing.mjs` の `SUPERSAMPLE=2` と同値だが、両者は出力相対の framing
111
+ と素材ネイティブ相対の layer という独立した filter builder であり、実装モジュール間の逆依存を
112
+ 作らないため定数は共有しない。キーフレーム無しの filter 文字列は不変。perspective は bitmap
113
+ 外形を四隅の基準にし、rotate と非 normal blend は固定外形化による既存意味の変化を排除できない
114
+ ため、この組み合わせだけは従来互換経路を維持する。
115
+
116
+ ## 5. macOS の字幕レンダ用 Chrome 起動(2026-08-24 追記)
117
+
118
+ - macOS では `.app` 内の Chrome 実行ファイルを子プロセスとして直接起動しない。書き出し専用の
119
+ 一意な `user-data-dir` を作り、`/usr/bin/open -na <Chrome.app> --args` で LaunchServices
120
+ 経由の新規インスタンスを起動する。
121
+ - `--remote-debugging-port=0` で起動し、専用プロファイルの `DevToolsActivePort` を
122
+ タイムアウト付きで待ってから `puppeteer.connect()` する。通常の連番 PNG 経路と静止画経路は
123
+ 同じ起動・接続・終了層を使う。Linux / Windows は共通層内の `puppeteer.launch()` で
124
+ 実行ファイルを直接起動する。
125
+ - 正常・異常を問わず、接続済み Chrome は CDP の `Browser.close` 相当で終了し、専用プロファイルを
126
+ 削除する。macOS の終了設計は PID の推測や広域 `kill` に依存しない。
127
+ - Chrome 不在、`.app` でない実行ファイル、`DevToolsActivePort` 待機または接続の失敗は、
128
+ 「字幕レンダ用ブラウザの起動に失敗した」ことと Chrome の確認を日本語で示し、書き出しを
129
+ 非 0 で停止する。別スタイルの簡易字幕へは切り替えない。
@@ -560,8 +560,8 @@ ffmpeg の `perspective` フィルタの制約(式に時刻変数を持たな
560
560
  | `cuts[].fx`(2026-08-07 実装・近似あり) | 🟡(§2.4.5。5 種対応、3 種は近似バッジ付き) | ❌(未実装) |
561
561
  | `layers[].keyframes`(2026-08-09 実装) | ✅(§2.4.7。transform/crop は連続補間。perspective は blend:"normal" のみ・書き出しの段階保持とサンプル点で一致) | ✅(§2.4.7。同左) |
562
562
  | `cuts[].static-image-source`(2026-08-12 実装。正本: `contract-2026-08-12-still-image-cut-source-v0.md`) | 🟡(`<img>`/`<video>` 出し分け + preview-engine ClipSession/Timeline の image 対応を実装。framing/freeze/transform は流用。実ブラウザでの対話的スクラブ・複数区間切替の実機検証は未実施。2026-08-17: 静止画が stylesheet の display:none に隠れたまま永久に出ない実機バグ(`img.style.display=''`)を是正) | 🟡(2026-08-17 実装 — task/2026-08-17-shell-still-image-cut-preview。#preview-still + gap と同じ壁時計クロックで表示。cut transform/framing/freeze/選択ドラッグは video のスタイル鏡写しで流用。タイムラインの静止画フィルムストリップ/サムネも同時是正(probeForFilmstrip の duration 必須ガードが静止画分岐を dead code 化していた)。Electron 実機での対話検証は未実施) |
563
- | `output.look` | 🟡(WebGL rail 配線は T2 予定) | ✅(WebGL rail。chroma → LUT、intensity 対応) |
564
- | `chroma_key`(source / layer) | 🟡(WebGL rail 配線は T2 予定) | ✅(WebGL rail。source は背景合成、layer はアルファ抜き) |
563
+ | `output.look` | ✅(WebGL rail。chroma → LUT、intensity 対応) | ✅(WebGL rail。chroma → LUT、intensity 対応) |
564
+ | `chroma_key`(source / layer) | ✅(WebGL rail。source は背景合成、layer はアルファ抜き) | ✅(WebGL rail。source は背景合成、layer はアルファ抜き) |
565
565
  | `audio.master` | 🟡(未裁定・badge のみ) | 🟡(未裁定・badge のみ) |
566
566
 
567
567
  - `cuts[].framing`(静的クロップ / ズームキーフレーム)・`cuts[].freeze`(フリーズ)は
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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. Run them from a full AKARI Video app installation (normally ~/.akari/app) or a monorepo checkout.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -288,12 +288,17 @@ export async function lintProject(input, options = {}) {
288
288
  validateTrackTransitionOutCompatibility(edit, findings);
289
289
 
290
290
  if (captionsState.value !== undefined) {
291
+ const cutsEndSeconds = cutTrackSegments.reduce(
292
+ (maximum, segment) => Math.max(maximum, segment.end),
293
+ 0,
294
+ );
291
295
  validateCaptions(
292
296
  captionsState.value,
293
297
  edit,
294
298
  analysisState.value,
295
299
  findings,
296
300
  paths,
301
+ cutsEndSeconds,
297
302
  );
298
303
  }
299
304
 
@@ -683,6 +688,21 @@ function validateEditV2(edit, findings) {
683
688
  });
684
689
  }
685
690
 
691
+ if (kind === "html" && Object.hasOwn(item.source, "params")) {
692
+ const params = item.source.params;
693
+ const invalidEntry = isRecord(params)
694
+ ? Object.entries(params).find(([, value]) => typeof value !== "string")
695
+ : ["params", params];
696
+ if (invalidEntry) {
697
+ addFinding(findings, {
698
+ severity: "error",
699
+ check: "v2.html-params",
700
+ message: "HTML source params must be an object whose values are strings",
701
+ path: `${itemPath}.source.params${invalidEntry[0] === "params" ? "" : `.${invalidEntry[0]}`}`,
702
+ });
703
+ }
704
+ }
705
+
686
706
  if (track.lane === "audio") {
687
707
  const role = item.role ?? "sfx";
688
708
  if (Object.hasOwn(item, "gain_db")
@@ -2257,7 +2277,7 @@ async function validateReferences(edit, findings, paths, ignoredSourceIds = new
2257
2277
  return { sourceExists };
2258
2278
  }
2259
2279
 
2260
- function validateCaptions(captions, edit, analysis, findings, paths) {
2280
+ function validateCaptions(captions, edit, analysis, findings, paths, cutsEndSeconds) {
2261
2281
  const captionPath = relativePath(paths.projectRoot, paths.captionsPath);
2262
2282
  const captionsRoot = captions;
2263
2283
  let displayPolicy;
@@ -2321,7 +2341,7 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
2321
2341
  continue;
2322
2342
  }
2323
2343
  const required = ["id", "start", "end", "text", "speaker", "sourceRef", "edited"];
2324
- const optional = ["src", "words", "style", "display_text", "display_fragments", "text_style"];
2344
+ const optional = ["src", "time_domain", "words", "style", "display_text", "display_fragments", "text_style"];
2325
2345
  for (const field of required) {
2326
2346
  if (!Object.hasOwn(caption, field)) {
2327
2347
  captionFinding(findings, "captions.schema", `${field} is required`, itemPath);
@@ -2361,6 +2381,15 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
2361
2381
  }
2362
2382
  }
2363
2383
  }
2384
+ if (Object.hasOwn(caption, "time_domain")
2385
+ && caption.time_domain !== "source" && caption.time_domain !== "output") {
2386
+ captionFinding(
2387
+ findings,
2388
+ "captions.schema",
2389
+ 'time_domain must be "source" or "output" when present',
2390
+ itemPath,
2391
+ );
2392
+ }
2364
2393
  if (typeof caption.id !== "string" || !/^c-\d{4}$/.test(caption.id)) {
2365
2394
  captionFinding(
2366
2395
  findings,
@@ -2457,17 +2486,29 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
2457
2486
  range: { start: caption.start, end: caption.end },
2458
2487
  });
2459
2488
  }
2460
- const kept = keptOverlap(caption.start, caption.end, edit?.cuts, caption.src);
2461
- const ratio = kept / (caption.end - caption.start);
2462
- if (ratio < 0.5 - EPSILON) {
2489
+ if (caption.time_domain === "output" && caption.end > cutsEndSeconds + EPSILON) {
2463
2490
  addFinding(findings, {
2464
- severity: "error",
2465
- check: "captions.cut-visibility",
2466
- message: "less than 50% of the caption remains after cuts",
2491
+ severity: "warning",
2492
+ check: "captions.output-domain-exceeds-duration",
2493
+ message: `captions[${index}] は time_domain: output の宣言区間が動画総尺 ${cutsEndSeconds.toFixed(1)}s を超えています。書き出しでは ${cutsEndSeconds.toFixed(1)}s までにクランプして表示されます。`,
2467
2494
  path: itemPath,
2468
2495
  range: { start: caption.start, end: caption.end },
2469
2496
  });
2470
2497
  }
2498
+ // output-domain cue は既に最終出力軸にあり、source cut への keptOverlap 射影を行わない。
2499
+ if (caption.time_domain !== "output") {
2500
+ const kept = keptOverlap(caption.start, caption.end, edit?.cuts, caption.src);
2501
+ const ratio = kept / (caption.end - caption.start);
2502
+ if (ratio < 0.5 - EPSILON) {
2503
+ addFinding(findings, {
2504
+ severity: "error",
2505
+ check: "captions.cut-visibility",
2506
+ message: "less than 50% of the caption remains after cuts",
2507
+ path: itemPath,
2508
+ range: { start: caption.start, end: caption.end },
2509
+ });
2510
+ }
2511
+ }
2471
2512
  }
2472
2513
 
2473
2514
  const sourceSegment = sourceSegmentIndex(caption.sourceRef);
@@ -445,7 +445,7 @@ function validateSourceReferences(captions, cuts, edit) {
445
445
  }
446
446
  });
447
447
  captions.forEach((caption, index) => {
448
- if (edit.sources.length > 1 && caption.src === undefined) {
448
+ if (edit.sources.length > 1 && caption.time_domain !== 'output' && caption.src === undefined) {
449
449
  fail('MISSING_SOURCE', `captions[${index}].src is required for a multi-source edit`);
450
450
  }
451
451
  if (caption.src !== undefined && !sourceIds.has(caption.src)) {
@@ -475,8 +475,39 @@ function validateLinearCuts(cuts, edit) {
475
475
  }
476
476
  function projectOccurrences(captions, cuts, sourceCount) {
477
477
  const occurrences = [];
478
+ let cursor = 0;
479
+ const segments = cuts.map((cut, cutIndex) => {
480
+ const speed = finitePositive(cut.speed) ? cut.speed : 1;
481
+ const duration = (cut.out - cut.in) / speed;
482
+ const segment = { cut, cutIndex, speed, start: cursor, end: cursor + duration };
483
+ cursor += duration;
484
+ return segment;
485
+ });
486
+ const timelineEnd = cursor;
487
+ captions.forEach((caption, captionInputIndex) => {
488
+ if (!isRecord(caption) || caption.time_domain !== 'output')
489
+ return;
490
+ const clampedEnd = Math.min(caption.end, timelineEnd);
491
+ if (!(clampedEnd > caption.start))
492
+ return;
493
+ occurrences.push({
494
+ source_cue_id: caption.id,
495
+ src: strictText(caption.src) ? caption.src : null,
496
+ cut_index: -1,
497
+ caption_input_index: captionInputIndex,
498
+ source_start: caption.start,
499
+ source_end: clampedEnd,
500
+ start: caption.start,
501
+ end: clampedEnd,
502
+ text: caption.display_text ?? caption.text,
503
+ display_fragments: caption.display_fragments,
504
+ text_style: caption.text_style
505
+ });
506
+ });
478
507
  if (cuts.length === 0) {
479
508
  captions.forEach((caption, captionInputIndex) => {
509
+ if (caption?.time_domain === 'output')
510
+ return;
480
511
  const text = caption?.display_text ?? caption?.text;
481
512
  if (isRecord(caption) && finiteNonNegative(caption.start) && finitePositive(caption.end) && caption.end > caption.start && typeof text === 'string') {
482
513
  occurrences.push({
@@ -496,17 +527,11 @@ function projectOccurrences(captions, cuts, sourceCount) {
496
527
  });
497
528
  return occurrences;
498
529
  }
499
- let cursor = 0;
500
- const segments = cuts.map((cut, cutIndex) => {
501
- const speed = finitePositive(cut.speed) ? cut.speed : 1;
502
- const duration = (cut.out - cut.in) / speed;
503
- const segment = { cut, cutIndex, speed, start: cursor, end: cursor + duration };
504
- cursor += duration;
505
- return segment;
506
- });
507
530
  captions.forEach((caption, captionInputIndex) => {
508
531
  if (!isRecord(caption))
509
532
  return;
533
+ if (caption.time_domain === 'output')
534
+ return;
510
535
  const captionSource = strictText(caption.src) ? caption.src : null;
511
536
  if (sourceCount > 1 && captionSource === null) {
512
537
  fail('MISSING_SOURCE', `captions[${captionInputIndex}].src is required for a multi-source edit`);
@@ -544,6 +569,10 @@ function validateSourceCaption(caption, index, policy) {
544
569
  if (caption.src !== undefined && !strictText(caption.src)) {
545
570
  fail('INVALID_CAPTION', `captions[${index}].src must be a non-empty NFC trimmed string when present`);
546
571
  }
572
+ if (caption.time_domain !== undefined
573
+ && caption.time_domain !== 'source' && caption.time_domain !== 'output') {
574
+ fail('INVALID_CAPTION', `captions[${index}].time_domain must be source or output when present`);
575
+ }
547
576
  const text = caption.display_text ?? caption.text;
548
577
  if (!strictText(text))
549
578
  fail('INVALID_TEXT', `captions[${index}] display text must be non-empty, NFC, and trimmed`);
@@ -562,6 +591,8 @@ function validateEmphasisConflicts(captions, emphasisValue) {
562
591
  if (!Array.isArray(emphasisValue))
563
592
  return;
564
593
  captions.forEach((caption, index) => {
594
+ if (caption.time_domain === 'output')
595
+ return;
565
596
  const conflict = emphasisValue.some(value => isRecord(value)
566
597
  && (!strictText(value.src) || !strictText(caption.src) || value.src === caption.src)
567
598
  && finiteNonNegative(value.t_start) && finitePositive(value.t_end)
@@ -109,6 +109,8 @@ export interface CaptionRecord {
109
109
  segment: number;
110
110
  } | null;
111
111
  edited: boolean;
112
+ /** 省略時は source。output は edit.json の出力時間軸を直接参照する。 */
113
+ timeDomain?: 'source' | 'output';
112
114
  textStyle?: CaptionTextStyle;
113
115
  }
114
116
  export declare function parseCaptions(source: string): {
@@ -118,6 +120,8 @@ export declare function parseCaptions(source: string): {
118
120
  };
119
121
  export declare function mergeCaptionTextStyles(defaultStyle: CaptionTextStyle | undefined, captionStyle: CaptionTextStyle | undefined): CaptionTextStyle | undefined;
120
122
  export declare function shiftCaptionLine(source: string, captionId: string, deltaStart: number, deltaEnd: number): string;
123
+ /** 字幕の時刻と domain を絶対値で更新する。undo は元値をそのまま渡して完全復元できる。 */
124
+ export declare function setCaptionTimingLine(source: string, captionId: string, start: number, end: number, timeDomain: 'source' | 'output' | null | undefined, edited: boolean): string;
121
125
  export declare function updateCaptionFieldsInSource(source: string, captionId: string, updates: {
122
126
  text?: string;
123
127
  speaker?: string | null;
@@ -4,6 +4,7 @@ exports.CAPTION_ZONES = void 0;
4
4
  exports.parseCaptions = parseCaptions;
5
5
  exports.mergeCaptionTextStyles = mergeCaptionTextStyles;
6
6
  exports.shiftCaptionLine = shiftCaptionLine;
7
+ exports.setCaptionTimingLine = setCaptionTimingLine;
7
8
  exports.updateCaptionFieldsInSource = updateCaptionFieldsInSource;
8
9
  exports.updateCaptionTextStyleInSource = updateCaptionTextStyleInSource;
9
10
  exports.insertCaptionLine = insertCaptionLine;
@@ -129,6 +130,20 @@ function shiftCaptionLine(source, captionId, deltaStart, deltaEnd) {
129
130
  nextElement = replaceCaptionProperty(nextElement, 'edited', true, captionId);
130
131
  return replaceElement(source, array.openIndex + 1, element, nextElement);
131
132
  }
133
+ /** 字幕の時刻と domain を絶対値で更新する。undo は元値をそのまま渡して完全復元できる。 */
134
+ function setCaptionTimingLine(source, captionId, start, end, timeDomain, edited) {
135
+ if (!captionId || !Number.isFinite(start) || !Number.isFinite(end)
136
+ || start < 0 || end - start < 0.15) {
137
+ throw new Error('字幕が短すぎます(0.15 秒未満にはできません)');
138
+ }
139
+ const array = locateCaptionArray(source);
140
+ const element = findCaptionElement(array.elements, captionId);
141
+ let nextElement = replaceCaptionProperty(element.text, 'start', start, captionId);
142
+ nextElement = replaceCaptionProperty(nextElement, 'end', end, captionId);
143
+ nextElement = replaceCaptionProperty(nextElement, 'edited', edited, captionId);
144
+ nextElement = updateOptionalStyleProperty(nextElement, 'time_domain', timeDomain, `字幕 ${captionId}`);
145
+ return replaceElement(source, array.openIndex + 1, element, nextElement);
146
+ }
132
147
  function updateCaptionFieldsInSource(source, captionId, updates) {
133
148
  if (!captionId) {
134
149
  throw new Error('字幕 ID を指定してください。');
@@ -281,6 +296,8 @@ function normalizeCaption(value, onTextStyleUnknownKeys) {
281
296
  speaker: value.speaker,
282
297
  sourceRef,
283
298
  edited: value.edited,
299
+ ...(value.time_domain === 'source' || value.time_domain === 'output'
300
+ ? { timeDomain: value.time_domain } : {}),
284
301
  ...(textStyle !== undefined ? { textStyle } : {})
285
302
  };
286
303
  }
@@ -427,10 +444,13 @@ function insertIntoEmptyArray(inner, serialized, lineEnding) {
427
444
  return `${beforeClosingIndent}${closingIndent} ${serialized}${lineEnding}${closingIndent}`;
428
445
  }
429
446
  function serializeCaption(caption) {
447
+ const timeDomain = caption.timeDomain === undefined
448
+ ? ''
449
+ : `, "time_domain": ${JSON.stringify(caption.timeDomain)}`;
430
450
  const textStyle = caption.textStyle === undefined
431
451
  ? ''
432
452
  : `, "text_style": ${JSON.stringify(textStyleToJson(caption.textStyle))}`;
433
- return `{ "id": ${JSON.stringify(caption.id)}, "start": ${JSON.stringify(caption.start)}, "end": ${JSON.stringify(caption.end)}, "text": ${JSON.stringify(caption.text)}, "speaker": ${JSON.stringify(caption.speaker)}, "sourceRef": ${JSON.stringify(caption.sourceRef)}, "edited": ${JSON.stringify(caption.edited)}${textStyle} }`;
453
+ return `{ "id": ${JSON.stringify(caption.id)}, "start": ${JSON.stringify(caption.start)}, "end": ${JSON.stringify(caption.end)}, "text": ${JSON.stringify(caption.text)}, "speaker": ${JSON.stringify(caption.speaker)}, "sourceRef": ${JSON.stringify(caption.sourceRef)}, "edited": ${JSON.stringify(caption.edited)}${timeDomain}${textStyle} }`;
434
454
  }
435
455
  function isRecord(value) {
436
456
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -21,6 +21,7 @@ export type PreviewItemWriteCommand = {
21
21
  vars?: UnknownRecord;
22
22
  transform?: PreviewItemTransformPatch;
23
23
  html?: string;
24
+ params?: Record<string, string>;
24
25
  };
25
26
  } | {
26
27
  kind: 'layer';
@@ -49,6 +49,15 @@ function resolveV2Write(parsed, command) {
49
49
  if (typeof command.patch.html === 'string') {
50
50
  htmlPath = source.path;
51
51
  }
52
+ if (command.patch.params) {
53
+ for (const [name, value] of Object.entries(command.patch.params)) {
54
+ if (!name || typeof value !== 'string') {
55
+ throw new Error('HTML params は空でないキーと文字列値である必要があります');
56
+ }
57
+ }
58
+ source.params = { ...source.params, ...command.patch.params };
59
+ editChanged = true;
60
+ }
52
61
  if (command.patch.vars) {
53
62
  source.vars = { ...recordOf(source.vars), ...command.patch.vars };
54
63
  editChanged = true;
@@ -109,6 +118,9 @@ function resolveLegacyWrite(edit, command) {
109
118
  throw new Error(`overlays[].html がファイル参照ではありません: ${command.itemId}`);
110
119
  }
111
120
  let editChanged = false;
121
+ if (command.patch.params) {
122
+ throw new Error('HTML params の書き戻しには edit.json version 2 が必要です');
123
+ }
112
124
  if (command.patch.vars) {
113
125
  overlay.vars = { ...recordOf(overlay.vars), ...command.patch.vars };
114
126
  editChanged = true;
@@ -60,6 +60,7 @@ export interface HtmlSourceV2 {
60
60
  kind: 'html';
61
61
  path: string;
62
62
  vars?: Record<string, unknown>;
63
+ params?: Record<string, string>;
63
64
  }
64
65
  export interface TelopSourceV2 {
65
66
  kind: 'telop';
@@ -239,10 +239,17 @@ function validateItemSource(value, path, sourceIds) {
239
239
  requirePositiveNumber(value.speed, `${path}.speed`);
240
240
  return;
241
241
  case 'html':
242
- requireExactKeys(value, new Set(['kind', 'path', 'vars']), path);
242
+ requireExactKeys(value, new Set(['kind', 'path', 'vars', 'params']), path);
243
243
  requireText(value.path, `${path}.path`);
244
244
  if (hasOwn(value, 'vars'))
245
245
  requireRecord(value.vars, `${path}.vars`);
246
+ if (hasOwn(value, 'params')) {
247
+ requireRecord(value.params, `${path}.params`);
248
+ for (const [name, text] of Object.entries(value.params)) {
249
+ if (typeof text !== 'string')
250
+ throw invalid(`${path}.params.${name}`, '文字列である必要があります');
251
+ }
252
+ }
246
253
  return;
247
254
  case 'telop':
248
255
  requireExactKeys(value, new Set(['kind', 'preset', 'params', 'baked']), path);
@@ -19,6 +19,7 @@ export interface InternalHtmlSource {
19
19
  kind: 'html';
20
20
  /** 断片ファイルのパス、またはインライン HTML。 */
21
21
  html: string;
22
+ params?: Record<string, string>;
22
23
  }
23
24
  export interface InternalTelopSource {
24
25
  kind: 'telop';
@@ -466,7 +466,8 @@ function buildV2VisualItem(item, fps, ref, pathOf, chromaKeyOf, legacyIndexCount
466
466
  case 'html': {
467
467
  const declaration = {
468
468
  id: item.id, html: item.source.path, start: at, duration, track: ref,
469
- ...(item.source.vars !== undefined ? { vars: item.source.vars } : {}), ...common
469
+ ...(item.source.vars !== undefined ? { vars: item.source.vars } : {}),
470
+ ...(item.source.params !== undefined ? { params: item.source.params } : {}), ...common
470
471
  };
471
472
  const value = {
472
473
  id: item.id,
@@ -478,7 +479,10 @@ function buildV2VisualItem(item, fps, ref, pathOf, chromaKeyOf, legacyIndexCount
478
479
  return {
479
480
  item: {
480
481
  id: item.id, atFrames, durationFrames, at, duration,
481
- source: { kind: 'html', html: item.source.path },
482
+ source: {
483
+ kind: 'html', html: item.source.path,
484
+ ...(item.source.params !== undefined ? { params: item.source.params } : {})
485
+ },
482
486
  declaration,
483
487
  legacy: { collection: 'overlays', index: nextLegacyIndex(legacyIndexCounters, 'overlays'), value }
484
488
  }
@@ -10,7 +10,7 @@
10
10
  "README.md"
11
11
  ],
12
12
  "scripts": {
13
- "check": "node --check src/vendor/three-bundle.js && node --check src/vendor/vendor-3d-text-bundle.js && node --check src/three-runtime.js && node --check src/video-fx.js && node --check src/overlay-runtime.js && node --check src/interaction.js && node --check src/minimap.js",
13
+ "check": "node --check src/vendor/three-bundle.js && node --check src/vendor/vendor-3d-text-bundle.js && node --check src/three-runtime.js && node --check src/slot-params.js && node --check src/video-fx.js && node --check src/overlay-runtime.js && node --check src/interaction.js && node --check src/minimap.js",
14
14
  "test": "node --test test-harness/*.test.mjs"
15
15
  }
16
16
  }
@@ -42,6 +42,7 @@ const CAPTION_FIELDS = new Set([
42
42
  "sourceRef",
43
43
  "edited",
44
44
  "src",
45
+ "time_domain",
45
46
  "words",
46
47
  "style",
47
48
  "display_text",
@@ -219,6 +220,13 @@ function validateCaptionsArray(captions, optInDefaultTextStyle = null) {
219
220
  if (hasOwn(caption, "src") && !isNonEmptyString(caption.src)) {
220
221
  fail(`${label}.src は空でない文字列である必要があります`);
221
222
  }
223
+ if (
224
+ hasOwn(caption, "time_domain")
225
+ && caption.time_domain !== "source"
226
+ && caption.time_domain !== "output"
227
+ ) {
228
+ fail(`${label}.time_domain は source または output である必要があります`);
229
+ }
222
230
  if (hasOwn(caption, "words")) validateCaptionWords(caption.words, label);
223
231
  if (hasOwn(caption, "style") && !CAPTION_STYLES.has(caption.style)) {
224
232
  fail(`${label}.style は karaoke/pop/reveal/reveal-word のいずれかである必要があります`);
@@ -281,6 +281,11 @@
281
281
  "sourceRef": { "$ref": "#/$defs/sourceRef" },
282
282
  "edited": { "type": "boolean" },
283
283
  "src": { "type": "string", "minLength": 1, "pattern": "\\S" },
284
+ "time_domain": {
285
+ "enum": ["source", "output"],
286
+ "default": "source",
287
+ "$comment": "start/end と words[].start/end の時刻ドメイン。省略時は後方互換の source。output は edit.json の出力時間軸を直接参照し、src は任意の出所情報として保持できる。"
288
+ },
284
289
  "words": {
285
290
  "type": "array",
286
291
  "items": { "$ref": "#/$defs/captionWord" }
@@ -295,7 +300,7 @@
295
300
  },
296
301
  "text_style": { "$ref": "#/$defs/textStyle" }
297
302
  },
298
- "$comment": "end > start、ID 一意性、words[].end >= start は JSON Schema 標準だけでは兄弟値を比較できないため validate-captions.mjs でも検証する。style は既存の単語リビール演出、text_style は見た目のスタイルであり別契約。"
303
+ "$comment": "end > start、ID 一意性、words[].end >= start は JSON Schema 標準だけでは兄弟値を比較できないため validate-captions.mjs でも検証する。time_domain 省略時は source。style は既存の単語リビール演出、text_style は見た目のスタイルであり別契約。"
299
304
  },
300
305
  "emphasisWordItem": {
301
306
  "type": "object",
@@ -666,9 +666,14 @@
666
666
  "properties": {
667
667
  "kind": { "const": "html" },
668
668
  "path": { "type": "string", "minLength": 1, "pattern": "\\S" },
669
- "vars": { "type": "object" }
669
+ "vars": { "type": "object" },
670
+ "params": {
671
+ "type": "object",
672
+ "properties": {},
673
+ "additionalProperties": { "type": "string" }
674
+ }
670
675
  },
671
- "$comment": "notes-2026-08-18-timeline-latency-and-track-model.md §9。HTML はその場で描画する絵の出どころであり、素材トリムの in/out を持たない。"
676
+ "$comment": "notes-2026-08-18-timeline-latency-and-track-model.md §9 / contract-2026-08-22-overlay-html-slots.md §1。HTML はその場で描画する絵の出どころであり、素材トリムの in/out を持たない。params は任意のスロット名をキーにできる一方、値を string のみに閉じる。"
672
677
  },
673
678
  "itemSourceTelopV2": {
674
679
  "type": "object",
@@ -0,0 +1,23 @@
1
+ {
2
+ "version": 2,
3
+ "output": { "width": 1920, "height": 1080, "fps": 30 },
4
+ "sources": [],
5
+ "tracks": [
6
+ {
7
+ "id": "v-html",
8
+ "lane": "visual",
9
+ "items": [
10
+ {
11
+ "id": "chapter-1",
12
+ "at": 0,
13
+ "duration": 90,
14
+ "source": {
15
+ "kind": "html",
16
+ "path": "overlays/chapter.html",
17
+ "params": { "title": 1 }
18
+ }
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "version": 2,
3
+ "output": { "width": 1920, "height": 1080, "fps": 30 },
4
+ "sources": [],
5
+ "tracks": [
6
+ {
7
+ "id": "v-html",
8
+ "lane": "visual",
9
+ "items": [
10
+ {
11
+ "id": "chapter-1",
12
+ "at": 0,
13
+ "duration": 90,
14
+ "source": {
15
+ "kind": "html",
16
+ "path": "overlays/chapter.html",
17
+ "params": { "title": "第1章", "subtitle": "問題の本質" }
18
+ }
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ }
@@ -134,6 +134,17 @@ test("minimum v2 fixture is valid", () => {
134
134
  assert.equal(validate(fixture("edit-v2-minimal-valid")), true, JSON.stringify(validate.errors, null, 2));
135
135
  });
136
136
 
137
+ test("HTML source params accepts arbitrary slot names but only string values", () => {
138
+ const valid = fixture("edit-v2-html-params-valid");
139
+ assert.equal(validate(valid), true, JSON.stringify(validate.errors, null, 2));
140
+
141
+ const invalid = fixture("edit-v2-html-params-invalid");
142
+ assert.equal(validate(invalid), false);
143
+ assert.ok(validate.errors?.some(error =>
144
+ error.instancePath.endsWith("/source/params/title") && error.keyword === "type"
145
+ ), JSON.stringify(validate.errors, null, 2));
146
+ });
147
+
137
148
  test("editV2 rejects removed top-level vocabulary as additional properties", () => {
138
149
  for (const [key, extension] of [
139
150
  ["beats", []],
@@ -131,6 +131,18 @@ const displayPolicy = {
131
131
  },
132
132
  };
133
133
 
134
+ test("time_domain は source/output を受理し、省略時も後方互換で通る", () => {
135
+ for (const time_domain of [undefined, "source", "output"]) {
136
+ const value = { ...caption };
137
+ if (time_domain !== undefined) value.time_domain = time_domain;
138
+ const executed = runValue([value]);
139
+ assert.equal(executed.status, 0, executed.stderr);
140
+ }
141
+ const invalid = runValue([{ ...caption, time_domain: "timeline" }]);
142
+ assert.equal(invalid.status, 1, invalid.stdout);
143
+ assert.match(invalid.stderr, /time_domain は source または output/u);
144
+ });
145
+
134
146
  test("display policy, manual fragments, and reference-pixel style pass together", () => {
135
147
  const executed = runValue({
136
148
  display_policy: displayPolicy,
@@ -46,12 +46,36 @@ YouTube の safe zone も全端末保証ではない。オーガニック投稿
46
46
  放送の左上・右上に常駐する番組名/コーナー名ラベル。番組タイトル + 現在の章タイトルを出し、章立てに追従させる定型。**素材ライブラリに入れず、この定型から都度生成してよい**(プロジェクト固有のテキスト差し替えが本体で、再利用価値は構造にしかないため)。
47
47
 
48
48
  - **HTML/CSS で作る。画像生成にしない**: 章ごとのテキスト差し替えが必要で、画像だと章数分の生成と文字品質リスク(画像生成の日本語誤字)を抱える。HTML なら 1 断片 + 章ごとのオーバーレイエントリで済み、プレビューと書き出しが同一ソースを通る
49
- - **章追従の実装**: 同じ断片を章数分コピーし、章タイトルのテキストと `data-start` / `data-duration` を章の区間に合わせる(1 章 = 1 オーバーレイエントリ)。テキストは**断片 DOM に直接置く**(CSS 変数でのテキスト保持は禁止。変更は html パッチ / contenteditable 経由)
49
+ - **章追従の実装**: スロット付きの同じ断片を章数分参照し、章タイトルを各 HTML アイテムの `source.params`、区間を `at` / `duration` に持たせる(1 章 = 1 オーバーレイアイテム)。固定文言だけの既存断片は従来どおり DOM 直書きでもよい。CSS 変数へテキストを持たせない
50
50
  - **構造の目安**: ルート 1 つ + プレート(番組名の行 + 章名の行、または 1 行連結)。位置は外側コンテナの transform(ランタイム所有)で決め、断片は見た目だけを持つ
51
51
  - **常駐物の抑制**: 常時表示なので主張を抑える。小さめの `--font-size`、半透明の地、画面の隅で被写体・字幕・映像内 UI と重ねない。字幕(下側)と同時表示が前提なので、下側には置かない
52
52
  - knobs の例: `--plate-bg`、`--accent`、`--font-size`、`--radius`。既定値は `var(--name, fallback)` で公開する
53
53
  - 章替わりの出入りは短い opacity / translate のみ。保持中は動かさない(アニメーション節の原則どおり)
54
54
 
55
+ ## テキストスロット(data-akari-slot)
56
+
57
+ 1 本の HTML を複数アイテムで共有し、文字だけを `edit.json` に分離するときに使う。
58
+
59
+ ```html
60
+ <span data-akari-slot="title">既定タイトル</span>
61
+ ```
62
+
63
+ ```json
64
+ {
65
+ "source": {
66
+ "kind": "html",
67
+ "path": "overlays/chapter-tag.html",
68
+ "params": { "title": "第1章 問題の本質" }
69
+ }
70
+ }
71
+ ```
72
+
73
+ - **宣言**: 差し替えるテキスト要素そのものへ `data-akari-slot="<name>"` を付ける。スロット内はプレーンテキストだけにし、子要素や別スロットを入れ子にしない。ランタイムは値を `textContent` へ入れるため、`<b>` 等を params に書いてもタグにはならない
74
+ - **既定テキスト**: 要素内へ必ず人が読める既定値を書く。対応する params キーが無いアイテムでは、その既定値がそのまま表示される
75
+ - **命名**: `^[a-z][a-z0-9_-]*$` の意味名を使う(例: `title`、`chapter-title`、`speaker_name`)。連番や表示文言そのものを名前にせず、同じ名前を複数要素へ使う場合は同じ文字を表示する意図に限る
76
+ - **共有境界**: 色・余白・書体・アニメーションはテンプレ HTML/CSS、インスタンスごとの文字は `source.params` に置く。スロットをダブルクリック編集した場合も、テンプレファイルではなく params だけが更新される
77
+ - **非スロットとの互換**: `data-akari-slot` の無い断片は従来どおり DOM の文字を編集し、参照先 HTML ファイルへ書き戻す
78
+
55
79
  ## アニメーション
56
80
 
57
81
  - 字幕の出入りは短い opacity / translate を使い、保持中は動かさない。