akari-video 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +14 -7
- package/src/messages.mjs +11 -1
- package/vendor/docs/contract-2026-07-22-render-basics.md +45 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-lint/src/edit-lint.mjs +34 -8
- package/vendor/packages/edit-store/lib/caption-display.js +40 -9
- package/vendor/packages/edit-store/lib/caption-store.d.ts +4 -0
- package/vendor/packages/edit-store/lib/caption-store.js +21 -1
- package/vendor/packages/project-scaffold/src/index.mjs +3 -0
- package/vendor/packages/project-scaffold/test/create-project.test.mjs +8 -0
- package/vendor/packages/schemas/bin/validate-captions.mjs +8 -0
- package/vendor/packages/schemas/captions.schema.json +6 -1
- package/vendor/packages/schemas/test/validate-captions.test.mjs +12 -0
- package/vendor/skills/create-project/SKILL.md +21 -0
- package/vendor/templates/project-default/AGENTS.md +2 -0
- package/vendor/templates/project-default/CLAUDE.md +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { resolveLauncherAssets } from './repo-assets.mjs';
|
|
|
6
6
|
import { detectProjectState } from './project-state.mjs';
|
|
7
7
|
import { findClaudeExecutable, findOpencodeExecutable } from './path-lookup.mjs';
|
|
8
8
|
import { loadTaskLabels } from './task-labels.mjs';
|
|
9
|
-
import { describeForceReinstall, describeInstalledVersions, describeIntake, claudeMissingGuidance, opencodeMissingGuidance, describeUpdateCommand, describeVersionStatus, formatUpdateNotice } from './messages.mjs';
|
|
9
|
+
import { describeForceReinstall, describeInstalledVersions, describeIntake, claudeMissingGuidance, opencodeMissingGuidance, describeUpdateCacheFallback, describeUpdateCommand, describeVersionStatus, formatUpdateNotice } from './messages.mjs';
|
|
10
10
|
import { resolveEffectiveProjectRoot } from './first-run.mjs';
|
|
11
11
|
import { maybeShowAssetIntroNotice } from './sounds-setup.mjs';
|
|
12
12
|
import {
|
|
@@ -188,8 +188,8 @@ function defaultSpawnOpencode(opencodePath, args, projectRoot) {
|
|
|
188
188
|
* 自己更新を試みず、キャッシュに載っている最新版の通知を今後出さないよう記録するだけ
|
|
189
189
|
* (既存挙動を維持)。`--force` は同じ版の本体も再導入し、`--rollback` は直前 1 世代
|
|
190
190
|
* (`~/.akari/app-previous/`)へ戻す。
|
|
191
|
-
*
|
|
192
|
-
*
|
|
191
|
+
* `--dismiss` / `--rollback` 以外の明示 update は、キャッシュ TTL に関係なく
|
|
192
|
+
* フィードの同期取得を 1 回試す。取得失敗時だけ既存キャッシュへフォールバックする。
|
|
193
193
|
*/
|
|
194
194
|
export async function runUpdateCommand(args, options = {}) {
|
|
195
195
|
const log = options.log ?? ((line) => console.log(line));
|
|
@@ -201,6 +201,7 @@ export async function runUpdateCommand(args, options = {}) {
|
|
|
201
201
|
const dismissRequested = args.includes('--dismiss');
|
|
202
202
|
const rollbackRequested = args.includes('--rollback');
|
|
203
203
|
const forceRequested = args.includes('--force');
|
|
204
|
+
let usingCachedFeed = false;
|
|
204
205
|
|
|
205
206
|
if (rollbackRequested) {
|
|
206
207
|
return (options.rollbackSelfUpdate ?? rollbackSelfUpdate)({ env, log });
|
|
@@ -219,9 +220,12 @@ export async function runUpdateCommand(args, options = {}) {
|
|
|
219
220
|
return { exitCode: 0 };
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
cache =
|
|
223
|
+
const refreshed = await (options.refreshUpdateFeed ?? refreshUpdateFeed)({ env, fetchImpl: options.fetchImpl });
|
|
224
|
+
if (isValidFeedShape(refreshed?.feed)) {
|
|
225
|
+
cache = refreshed;
|
|
226
|
+
} else {
|
|
227
|
+
cache = readCacheSync(cachePath) ?? cache;
|
|
228
|
+
usingCachedFeed = isValidFeedShape(cache?.feed);
|
|
225
229
|
}
|
|
226
230
|
|
|
227
231
|
const feed = cache?.feed;
|
|
@@ -234,12 +238,15 @@ export async function runUpdateCommand(args, options = {}) {
|
|
|
234
238
|
&& (hasManagedApp || (options.isRunningFromAppDir ?? isRunningFromAppDir)({ env, launcherRoot: options.launcherRoot }));
|
|
235
239
|
|
|
236
240
|
if (!selfUpdateEligible) {
|
|
237
|
-
for (const line of describeUpdateCommand({ currentVersion, versionInfo, cache, dismissed: false })) {
|
|
241
|
+
for (const line of describeUpdateCommand({ currentVersion, versionInfo, cache, dismissed: false, usingCachedFeed })) {
|
|
238
242
|
log(line);
|
|
239
243
|
}
|
|
240
244
|
return { exitCode: 0 };
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
if (usingCachedFeed) {
|
|
248
|
+
log(describeUpdateCacheFallback(cache));
|
|
249
|
+
}
|
|
243
250
|
for (const line of describeInstalledVersions(versionInfo)) {
|
|
244
251
|
log(line);
|
|
245
252
|
}
|
package/src/messages.mjs
CHANGED
|
@@ -172,7 +172,7 @@ export function creatorRootPromptText(defaultPath) {
|
|
|
172
172
|
* `akari update` の出力本文(複数行)。フィード未取得・最新・新版ありで案内が変わる。
|
|
173
173
|
* `dismissed` は今回の実行で dismiss 記録を書いたかどうか(表示文言の切り替えのみに使う)。
|
|
174
174
|
*/
|
|
175
|
-
export function describeUpdateCommand({ currentVersion, versionInfo, cache, dismissed }) {
|
|
175
|
+
export function describeUpdateCommand({ currentVersion, versionInfo, cache, dismissed, usingCachedFeed = false }) {
|
|
176
176
|
const info = versionInfo ?? normalizeVersionInfo(currentVersion);
|
|
177
177
|
const lines = versionInfo ? describeInstalledVersions(info) : [`現在のバージョン: v${currentVersion}`];
|
|
178
178
|
const feed = cache?.feed;
|
|
@@ -182,6 +182,10 @@ export function describeUpdateCommand({ currentVersion, versionInfo, cache, dism
|
|
|
182
182
|
return lines;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
if (usingCachedFeed) {
|
|
186
|
+
lines.push(describeUpdateCacheFallback(cache));
|
|
187
|
+
}
|
|
188
|
+
|
|
185
189
|
lines.push(`最新バージョン: v${feed.product}${channelSuffix(feed.channel)}`);
|
|
186
190
|
if (feed.notes_url) {
|
|
187
191
|
lines.push(`リリースノート: ${feed.notes_url}`);
|
|
@@ -208,6 +212,12 @@ export function describeUpdateCommand({ currentVersion, versionInfo, cache, dism
|
|
|
208
212
|
return lines;
|
|
209
213
|
}
|
|
210
214
|
|
|
215
|
+
/** 明示 update の再取得失敗時に、参照するキャッシュの取得時刻を示す 1 行。 */
|
|
216
|
+
export function describeUpdateCacheFallback(cache) {
|
|
217
|
+
const fetchedAt = typeof cache?.fetched_at === 'string' ? cache.fetched_at : '取得時刻不明';
|
|
218
|
+
return `更新フィードを取得できなかったため、${fetchedAt} 時点のキャッシュを表示します。`;
|
|
219
|
+
}
|
|
220
|
+
|
|
211
221
|
/**
|
|
212
222
|
* `akari init` の出力文言(タスク契約 launcher-init・内部リポ)。作業場
|
|
213
223
|
* (creator-root)の作成・確認だけを行う入口コマンド専用。1 行主義の既存流儀に従う。
|
|
@@ -99,3 +99,48 @@ 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
|
+
- **主映像カット拡大・crop の固定キャンバス化(2026-08-25 追記)**: v2 主映像トラックから
|
|
117
|
+
gap-aware cuts へ変換された `transform.scale` / crop キーフレームにも、レイヤー経路と同じ
|
|
118
|
+
`layerFixedCanvasKeyframeSteps` を適用する。normal blend・perspective 非宣言・rotate=0(静的・
|
|
119
|
+
キーフレームとも)の場合に限り、素材ネイティブ寸法の 2 倍グリッドで補間し、最大 footprint の
|
|
120
|
+
固定透明キャンバスへ偶数座標で配置した後、Lanczos で実寸へ縮小する。これにより毎フレームの
|
|
121
|
+
可変 bitmap 寸法と中央 overlay の組み合わせを排除し、拡大後サイズの偶奇変化による ±1px 往復を
|
|
122
|
+
防ぐ。perspective・rotate・非 normal blend は従来互換経路を維持し、キーフレーム無しカットの
|
|
123
|
+
filter 文字列は変更しない。
|
|
124
|
+
|
|
125
|
+
- **rotate 適用条件の追補(2026-08-25)**: 上記の「rotate=0(静的・キーフレームとも)」は、
|
|
126
|
+
下流 filter に rotate ステップが実際に出るかどうかへ読み替え、`rotateConstant === 0` のときだけ
|
|
127
|
+
固定キャンバスを適用する。静的 `transform.rotate` が非 0 でも、キーフレームが `transform` を
|
|
128
|
+
宣言すれば rotate はキーフレーム由来の既定 0 で解決され、rotate ステップが出ないため対象となる
|
|
129
|
+
(rotate の描画意味論は不変)。これで `layers.mjs` の同名ガードと一致する。静的 rotate 非 0 かつ
|
|
130
|
+
transform キーフレーム無し、キーフレーム rotate が非 0、rotate が時間変化する構成は、rotate
|
|
131
|
+
ステップが実際に出るため従来互換経路を維持する。
|
|
132
|
+
|
|
133
|
+
## 5. macOS の字幕レンダ用 Chrome 起動(2026-08-24 追記)
|
|
134
|
+
|
|
135
|
+
- macOS では `.app` 内の Chrome 実行ファイルを子プロセスとして直接起動しない。書き出し専用の
|
|
136
|
+
一意な `user-data-dir` を作り、`/usr/bin/open -na <Chrome.app> --args` で LaunchServices
|
|
137
|
+
経由の新規インスタンスを起動する。
|
|
138
|
+
- `--remote-debugging-port=0` で起動し、専用プロファイルの `DevToolsActivePort` を
|
|
139
|
+
タイムアウト付きで待ってから `puppeteer.connect()` する。通常の連番 PNG 経路と静止画経路は
|
|
140
|
+
同じ起動・接続・終了層を使う。Linux / Windows は共通層内の `puppeteer.launch()` で
|
|
141
|
+
実行ファイルを直接起動する。
|
|
142
|
+
- 正常・異常を問わず、接続済み Chrome は CDP の `Browser.close` 相当で終了し、専用プロファイルを
|
|
143
|
+
削除する。macOS の終了設計は PID の推測や広域 `kill` に依存しない。
|
|
144
|
+
- Chrome 不在、`.app` でない実行ファイル、`DevToolsActivePort` 待機または接続の失敗は、
|
|
145
|
+
「字幕レンダ用ブラウザの起動に失敗した」ことと Chrome の確認を日本語で示し、書き出しを
|
|
146
|
+
非 0 で停止する。別スタイルの簡易字幕へは切り替えない。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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
|
|
|
@@ -2272,7 +2277,7 @@ async function validateReferences(edit, findings, paths, ignoredSourceIds = new
|
|
|
2272
2277
|
return { sourceExists };
|
|
2273
2278
|
}
|
|
2274
2279
|
|
|
2275
|
-
function validateCaptions(captions, edit, analysis, findings, paths) {
|
|
2280
|
+
function validateCaptions(captions, edit, analysis, findings, paths, cutsEndSeconds) {
|
|
2276
2281
|
const captionPath = relativePath(paths.projectRoot, paths.captionsPath);
|
|
2277
2282
|
const captionsRoot = captions;
|
|
2278
2283
|
let displayPolicy;
|
|
@@ -2336,7 +2341,7 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
|
|
|
2336
2341
|
continue;
|
|
2337
2342
|
}
|
|
2338
2343
|
const required = ["id", "start", "end", "text", "speaker", "sourceRef", "edited"];
|
|
2339
|
-
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"];
|
|
2340
2345
|
for (const field of required) {
|
|
2341
2346
|
if (!Object.hasOwn(caption, field)) {
|
|
2342
2347
|
captionFinding(findings, "captions.schema", `${field} is required`, itemPath);
|
|
@@ -2376,6 +2381,15 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
|
|
|
2376
2381
|
}
|
|
2377
2382
|
}
|
|
2378
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
|
+
}
|
|
2379
2393
|
if (typeof caption.id !== "string" || !/^c-\d{4}$/.test(caption.id)) {
|
|
2380
2394
|
captionFinding(
|
|
2381
2395
|
findings,
|
|
@@ -2472,17 +2486,29 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
|
|
|
2472
2486
|
range: { start: caption.start, end: caption.end },
|
|
2473
2487
|
});
|
|
2474
2488
|
}
|
|
2475
|
-
|
|
2476
|
-
const ratio = kept / (caption.end - caption.start);
|
|
2477
|
-
if (ratio < 0.5 - EPSILON) {
|
|
2489
|
+
if (caption.time_domain === "output" && caption.end > cutsEndSeconds + EPSILON) {
|
|
2478
2490
|
addFinding(findings, {
|
|
2479
|
-
severity: "
|
|
2480
|
-
check: "captions.
|
|
2481
|
-
message:
|
|
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 までにクランプして表示されます。`,
|
|
2482
2494
|
path: itemPath,
|
|
2483
2495
|
range: { start: caption.start, end: caption.end },
|
|
2484
2496
|
});
|
|
2485
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
|
+
}
|
|
2486
2512
|
}
|
|
2487
2513
|
|
|
2488
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);
|
|
@@ -31,6 +31,7 @@ const FALLBACK_CLAUDE_GUIDANCE = [
|
|
|
31
31
|
'- 編集スキルは `.claude/skills/` にあり、`/analyze-footage` などの素の名前で使えます。',
|
|
32
32
|
'- Codex や Cursor など他の AI エージェント用の入り口が `.agents/skills/`、`.cursor/skills/`、`.codex/skills/` にあります(中身は `.claude/skills/` へのリンク)。',
|
|
33
33
|
'- `.akari/intake.json` の `status` が `submitted` なら、そこに書かれた `tasks` / `target` / `autonomy` に従って進めます。`autonomy` が `checkpoint`(既定)なら、企画の承認や書き出し前などの要所で必ず人に確認します。`status` が `draft` のときは進め方がまだ決まっていないので、フォームや対話で確定させてから作業を始めます。',
|
|
34
|
+
'- 進め方を `.akari/intake.json` に書くときは、`tasks` は決められた 5 つの id だけを使い、`target` は `duration_s` か `keep_length: true` のどちらか片方にします。`status` を `submitted` にする前に lint で確認します。',
|
|
34
35
|
'- 利用者へは日本語で、内部の仕組みではなく「変更履歴」「企画メモ」「素材」などの言葉で説明します。',
|
|
35
36
|
'',
|
|
36
37
|
'このファイルはあなたのプロジェクトのものです。自由に書き換えて構いません。',
|
|
@@ -52,6 +53,8 @@ const FALLBACK_AGENT_GUIDANCE = [
|
|
|
52
53
|
'`.akari/intake.json` の `status` が `submitted` なら `tasks` / `target` / `autonomy` に従って進める。',
|
|
53
54
|
'`autonomy: checkpoint`(既定)なら企画承認・書き出し前などの要所で人に確認する。',
|
|
54
55
|
'`status: draft` なら進め方が未確定のため、フォームまたは対話で確定させてから進める。',
|
|
56
|
+
'進め方を `.akari/intake.json` に書くときは、`tasks` は決められた 5 つの id だけを使い、`target` は `duration_s` か `keep_length: true` のどちらか片方にする。',
|
|
57
|
+
'`status` を `submitted` にする前に lint で確認する。',
|
|
55
58
|
'',
|
|
56
59
|
'利用者へは日本語で、内部の仕組みではなく役割が伝わる言葉を使う。',
|
|
57
60
|
'この案内はこのプロジェクトのものです。自由に書き換えて構いません。',
|
|
@@ -51,6 +51,14 @@ test("bare template: scaffold generates a draft .akari/intake.json and a CLAUDE.
|
|
|
51
51
|
assert.match(claudeMd, /submitted/);
|
|
52
52
|
assert.match(claudeMd, /draft/);
|
|
53
53
|
assert.match(claudeMd, /checkpoint/);
|
|
54
|
+
assert.match(claudeMd, /`tasks` は決められた 5 つの id だけ/);
|
|
55
|
+
assert.match(claudeMd, /`duration_s` か `keep_length: true` のどちらか片方/);
|
|
56
|
+
assert.match(claudeMd, /`submitted` にする前に lint で確認/);
|
|
57
|
+
|
|
58
|
+
const agentsMd = await readFile(join(destination, "AGENTS.md"), "utf8");
|
|
59
|
+
assert.match(agentsMd, /`tasks` は決められた 5 つの id だけ/);
|
|
60
|
+
assert.match(agentsMd, /`duration_s` か `keep_length: true` のどちらか片方/);
|
|
61
|
+
assert.match(agentsMd, /`submitted` にする前に lint で確認/);
|
|
54
62
|
|
|
55
63
|
assert.ok(report.fallback.writtenFiles.includes(".akari/intake.json"));
|
|
56
64
|
assert.ok(report.fallback.writtenFiles.includes("CLAUDE.md"));
|
|
@@ -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",
|
|
@@ -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,
|
|
@@ -64,3 +64,24 @@ akari new <target-dir> [--template <path>]
|
|
|
64
64
|
## intake.json(進め方フォームの保存先)
|
|
65
65
|
|
|
66
66
|
作成直後の `.akari/intake.json` は `status: "draft"`・空 `tasks` から始まる(契約: `packages/schemas/intake.schema.json`)。プロジェクトの CLAUDE.md には intake の規律を追記する — `status: "submitted"` なら `tasks` / `target` / `autonomy` に従い、`autonomy: "checkpoint"`(既定)なら企画承認・書き出し前などの要所で人に確認し、`status: "draft"` のままなら進め方をフォームまたは対話で確定させてから進める。
|
|
67
|
+
|
|
68
|
+
- `tasks` は次の固定語彙のみを使う。日本語ラベルの正本は契約正本の `x-akari-labels` とする
|
|
69
|
+
- `transcribe-captions`(文字起こし・テロップ)
|
|
70
|
+
- `silence-cut`(いらない間・NG のカット)
|
|
71
|
+
- `bgm-sfx`(BGM・効果音)
|
|
72
|
+
- `narration`(ナレーション(自分の声 / 既製の声))
|
|
73
|
+
- `3d-inserts`(3D・画面はめ込みの演出)
|
|
74
|
+
- 依頼文・自由文を `tasks` に入れない。語彙に無い要望は `tasks` に書かず、
|
|
75
|
+
`target.taste`(自由記述)や `planning/` 側へ置く
|
|
76
|
+
- `target.duration_s`(数値指定)と `target.keep_length: true`(尺維持)はどちらか片方にする。
|
|
77
|
+
「約60秒にする」なら `{"duration_s": 60, "keep_length": false}`、「尺は変えない」なら
|
|
78
|
+
`{"duration_s": null, "keep_length": true}` とする
|
|
79
|
+
- `status` を `"submitted"` へ変える前に、リポジトリルートから edit-lint スキルと同じ CLI を
|
|
80
|
+
実行し、`intake.*` の error finding が無いことを確認する。`submitted` にするときは
|
|
81
|
+
`submitted_at` に ISO 8601 日時を必ず入れる
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
node packages/edit-lint/bin/edit-lint.mjs <project-root>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
フィールドの契約正本は `packages/schemas/intake.schema.json` とする。
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
従って進める。`autonomy: checkpoint`(既定)なら企画承認・書き出し前などの要所で
|
|
14
14
|
利用者に確認する。`status: draft` なら進め方が未確定のため、フォームまたは対話で
|
|
15
15
|
確定させてから進める。
|
|
16
|
+
- 進め方を `.akari/intake.json` に書くときは、`tasks` は決められた 5 つの id だけを使い、`target` は `duration_s` か `keep_length: true` のどちらか片方にする。
|
|
17
|
+
`status` を `submitted` にする前に lint で確認する。
|
|
16
18
|
- プロジェクトルート直下に新規ファイルを作らない(`edit.json` 等の既存契約ファイルを除く)。
|
|
17
19
|
生成物は `.akari/work/`、証跡は `.akari/reports/`、キャッシュは `.akari/cache/` に置く。
|
|
18
20
|
詳しい層の定義は `docs/contract-2026-07-25-project-structure-v0.md`(本モノレポ側)を参照する。
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
要所で必ず利用者に確認します。`status` が `draft` のときは進め方がまだ決まっていないので、
|
|
20
20
|
フォームまたは対話で確定させてから作業を始めます。
|
|
21
21
|
|
|
22
|
+
進め方を `.akari/intake.json` に書くときは、`tasks` は決められた 5 つの id だけを使い、`target` は `duration_s` か `keep_length: true` のどちらか片方にします。`status` を `submitted` にする前に lint で確認します。
|
|
23
|
+
|
|
22
24
|
素材が足りないときは、`akari assets list` でアカウントの素材ライブラリ(無料全部 + 購入済み)を
|
|
23
25
|
確認できます。使いたい素材が見つかったら `akari assets fetch <id> --project .` でこのプロジェクトへ
|
|
24
26
|
取り込みます(sha256 検証込み)。有料素材は `akari store connect` で接続済みのアカウントで
|