akari-video 0.1.95 → 0.1.97

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.95",
3
+ "version": "0.1.97",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
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": [
@@ -116,7 +116,7 @@ export async function resolve(
116
116
  const hasFiles = Array.isArray(item.files) && item.files.length > 0;
117
117
  if (price > 0) {
118
118
  const { ids: entitlements } = await fetchEntitlements({ env, fetchImpl });
119
- if (!entitlements.has(item.id)) {
119
+ if (!entitlements.has(item.id) && !entitlements.has(item.product_id)) {
120
120
  throw new AssetResolverError(
121
121
  `未購入の素材です(¥${price.toLocaleString()})。ストアで購入してから再度お試しください: ${item.id}`,
122
122
  'locked',
@@ -208,25 +208,29 @@ async function resolvePaidZip(item, { env, fetchImpl, project, reference, home,
208
208
  const tempAssetDir = path.join(tempRoot, item.category, item.id);
209
209
 
210
210
  try {
211
- const zipPath = path.join(tempRoot, `${item.id}.zip`);
212
- await downloadPaidZip(item.id, credentials, zipPath, { env, fetchImpl });
211
+ const productId = item.product_id ?? item.id;
212
+ const zipPath = path.join(tempRoot, `${productId}.zip`);
213
+ await downloadPaidZip(productId, credentials, zipPath, { env, fetchImpl });
213
214
 
214
215
  const extractedRoot = path.join(tempRoot, 'extracted');
215
216
  extractZip(zipPath, extractedRoot);
216
- const { packageDir, payloadFiles } = await verifyPaidZipContents(extractedRoot, item.id);
217
+ const { packageDir, payloadFiles } = await verifyPaidZipContents(extractedRoot, productId);
217
218
 
218
- // ストア入稿の実形は pack 形状(zip ルートに PACK.json / docs/ を持ち、素材本体は
219
- // assets/<category>/<id>/ 配下)。契約 v0 のフラット形状(zip 直下に meta.json / 本体)も
219
+ // パックは assets/<category>/<id>/ と assets/<id>/ の両配置を受け付ける。
220
+ // 契約 v0 のフラット形状(zip 直下に meta.json / 本体)も
220
221
  // 引き続き受け付ける。どちらの形でも、ライブラリへ入るのは素材ディレクトリの中身だけ
221
222
  // (PACK.json / docs はライブラリに混ぜない — 全量が必要なら `akari store download` が zip を渡す)。
222
- const nestedPrefix = `assets/${item.category}/${item.id}/`;
223
- const nestedFiles = payloadFiles
224
- .filter((relPath) => relPath.startsWith(nestedPrefix))
225
- .map((relPath) => relPath.slice(nestedPrefix.length));
226
- const payloadRoot = nestedFiles.length > 0
227
- ? path.join(packageDir, 'assets', item.category, item.id)
228
- : packageDir;
229
- const assetFiles = nestedFiles.length > 0 ? nestedFiles : payloadFiles;
223
+ const prefixes = [`assets/${item.category}/${item.id}/`, `assets/${item.id}/`];
224
+ const nestedPrefix = prefixes.find(prefix => payloadFiles.some(file => file.startsWith(prefix)));
225
+ const nestedFiles = nestedPrefix
226
+ ? payloadFiles.filter(file => file.startsWith(nestedPrefix)).map(file => file.slice(nestedPrefix.length))
227
+ : [];
228
+ // A shared product zip may contain many assets. Never copy a sibling into this item's directory.
229
+ if (item.product_id && item.product_id !== item.id && !nestedPrefix) {
230
+ throw new AssetResolverError(`パック内に対象素材がありません: ${item.id}`, 'integrity');
231
+ }
232
+ const payloadRoot = nestedPrefix ? path.join(packageDir, nestedPrefix) : packageDir;
233
+ const assetFiles = nestedPrefix ? nestedFiles : payloadFiles;
230
234
 
231
235
  // 有料素材は meta.json 必須(契約検証を必ず通す)。形の取り違えを「meta.json の無い素材」と
232
236
  // 誤認して検証スキップのまま通した前歴(#25)があるため fail-closed に倒す
@@ -89,7 +89,8 @@ export async function composeState({ env = process.env, fetchImpl = fetch } = {}
89
89
  const price = item.price ?? 0;
90
90
  let state;
91
91
  if (installed.has(key)) state = 'cached';
92
- else if (price > 0 && !entitlementsResult.ids.has(item.id)) state = 'locked';
92
+ else if (price > 0 && !entitlementsResult.ids.has(item.id)
93
+ && !entitlementsResult.ids.has(item.product_id)) state = 'locked';
93
94
  else state = 'available';
94
95
  return { ...item, state, ...sourceFields(item, catalogKeys.has(key) || item.source === 'installed') };
95
96
  });
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import { Readable } from 'node:stream';
14
14
  import test from 'node:test';
15
15
  import { AssetResolverError, resolve as resolveAsset } from '../src/resolve.mjs';
16
+ import { composeState } from '../src/state.mjs';
16
17
  import { setupFixtureEnv } from './helpers.mjs';
17
18
 
18
19
  const MINI_PNG = Buffer.from(
@@ -38,7 +39,7 @@ function paidMetaBuffer(id, category) {
38
39
  provenance: { origin: 'asset-resolver test fixture', generator: null },
39
40
  author: 'test',
40
41
  license: { spdx: 'LicenseRef-fixture', scope: 'paid-license-required', attribution_required: false, ai_training_allowed: false },
41
- price: 2980,
42
+ price: category === 'overlay' ? null : 2980,
42
43
  version: 1,
43
44
  };
44
45
  return Buffer.from(`${JSON.stringify(meta, null, 2)}\n`);
@@ -54,24 +55,26 @@ function paidMetaBuffer(id, category) {
54
55
  * (有料経路の meta.json 必須化テスト用)。`extraChecksumLine` は checksums.txt に任意の 1 行を
55
56
  * 追記する(zip-slip 防御テスト用)。
56
57
  */
57
- function buildPaidZip(id, category, { corrupt, layout = 'flat', withoutMeta = false, extraChecksumLine } = {}) {
58
+ function buildPaidZip(id, category, { corrupt, layout = 'flat', withoutMeta = false, extraChecksumLine,
59
+ productId = id, siblingId } = {}) {
58
60
  const stage = mkdtempSync(path.join(tmpdir(), 'paid-zip-fixture-'));
59
- const rootName = `${id}-v1`;
61
+ const rootName = `${productId}-v1`;
60
62
  const rootDir = path.join(stage, rootName);
61
63
  mkdirSync(rootDir, { recursive: true });
62
64
 
63
65
  const payload = {
64
66
  'meta.json': paidMetaBuffer(id, category),
65
- 'fragment.html': Buffer.from(
66
- `<div class="${id}-stub"><canvas></canvas><div data-akari-3d-fallback>fixture</div>`
67
- + '<script type="application/json" data-akari-3d-scene>{"model":"model.glb"}</script></div>\n',
68
- ),
69
- 'model.glb': Buffer.from('glTF-fixture-not-a-real-binary'),
67
+ 'fragment.html': Buffer.from(category === 'overlay'
68
+ ? `<div class="${id}-stub"><span data-mirror="text">fixture</span></div>\n`
69
+ : `<div class="${id}-stub"><canvas></canvas><div data-akari-3d-fallback>fixture</div>`
70
+ + '<script type="application/json" data-akari-3d-scene>{"model":"model.glb"}</script></div>\n'),
71
+ ...(category === 'scene3d' ? { 'model.glb': Buffer.from('glTF-fixture-not-a-real-binary') } : {}),
70
72
  'preview.png': MINI_PNG,
71
73
  };
72
74
  if (withoutMeta) delete payload['meta.json'];
73
75
 
74
- const payloadPrefix = layout === 'pack' ? `assets/${category}/${id}/` : '';
76
+ const payloadPrefix = layout === 'pack' ? `assets/${category}/${id}/`
77
+ : layout === 'telop-pack' ? `assets/${id}/` : '';
75
78
  const allFiles = {
76
79
  'README.md': Buffer.from('# fixture\n'),
77
80
  'LICENSE.md': Buffer.from('fixture license\n'),
@@ -83,6 +86,11 @@ function buildPaidZip(id, category, { corrupt, layout = 'flat', withoutMeta = fa
83
86
  for (const [name, buffer] of Object.entries(payload)) {
84
87
  allFiles[`${payloadPrefix}${name}`] = buffer;
85
88
  }
89
+ if (siblingId) {
90
+ allFiles[`assets/${siblingId}/meta.json`] = paidMetaBuffer(siblingId, category);
91
+ allFiles[`assets/${siblingId}/fragment.html`] = Buffer.from('<div data-mirror="text">sibling</div>');
92
+ allFiles[`assets/${siblingId}/preview.png`] = MINI_PNG;
93
+ }
86
94
  for (const [name, buffer] of Object.entries(allFiles)) {
87
95
  const filePath = path.join(rootDir, name);
88
96
  mkdirSync(path.dirname(filePath), { recursive: true });
@@ -120,7 +128,7 @@ function fetchImplFor(id, { entitled, zipPath }) {
120
128
  };
121
129
  }
122
130
 
123
- function addPaidCatalogItem(catalog, catalogPath, id, category, price) {
131
+ function addPaidCatalogItem(catalog, catalogPath, id, category, price, productId) {
124
132
  const withPaidItem = structuredClone(catalog);
125
133
  withPaidItem.items.push({
126
134
  id,
@@ -129,6 +137,7 @@ function addPaidCatalogItem(catalog, catalogPath, id, category, price) {
129
137
  tags: ['fixture', 'paid'],
130
138
  license: { spdx: 'LicenseRef-fixture' },
131
139
  price,
140
+ ...(productId ? { product_id: productId } : {}),
132
141
  version: 1,
133
142
  preview: '',
134
143
  provenance: {},
@@ -263,6 +272,76 @@ test('resolvePaidZip: pack 形状(assets/<category>/<id>/ 配下)は素材
263
272
  assert.equal(meta.id, 'mini-paid-pack');
264
273
  });
265
274
 
275
+ test('テロップパック: product_id の権利で全素材が available、未購入は locked、対象 1 件だけを配置', async () => {
276
+ const { env, home, catalog, catalogPath } = setupFixtureEnv();
277
+ const productId = 'fixture-telop-pack';
278
+ const first = 'telop-fixture-gold';
279
+ const second = 'telop-fixture-blue';
280
+ writeCredentials(home);
281
+ addPaidCatalogItem(catalog, catalogPath, first, 'overlay', 1980, productId);
282
+ const withFirst = JSON.parse(readFileSync(catalogPath, 'utf8'));
283
+ addPaidCatalogItem(withFirst, catalogPath, second, 'overlay', 1980, productId);
284
+ const zipPath = buildPaidZip(first, 'overlay', { layout: 'telop-pack', productId, siblingId: second });
285
+
286
+ const lockedFetch = fetchImplFor(productId, { entitled: false, zipPath: null });
287
+ const locked = await composeState({ env, fetchImpl: lockedFetch });
288
+ assert.deepEqual([first, second].map(id => locked.items.find(item => item.id === id)?.state), ['locked', 'locked']);
289
+ await assert.rejects(() => resolveAsset(first, { env, fetchImpl: lockedFetch }),
290
+ error => error instanceof AssetResolverError && error.code === 'locked');
291
+
292
+ // The store expands Lifetime access into product entitlements in this API response.
293
+ const entitledFetch = fetchImplFor(productId, { entitled: true, zipPath });
294
+ const available = await composeState({ env, fetchImpl: entitledFetch });
295
+ assert.deepEqual([first, second].map(id => available.items.find(item => item.id === id)?.state), ['available', 'available']);
296
+ const result = await resolveAsset(first, { env, fetchImpl: entitledFetch });
297
+ assert.equal(result.dir, path.join(home, 'assets', 'overlay', first));
298
+ assert.ok(existsSync(path.join(result.dir, 'meta.json')));
299
+ assert.ok(existsSync(path.join(result.dir, 'fragment.html')));
300
+ assert.equal(existsSync(path.join(result.dir, 'assets')), false);
301
+ assert.equal(existsSync(path.join(home, 'assets', 'overlay', second)), false);
302
+ });
303
+
304
+ test('Lifetime パスの展開済み entitlements でパック全件が available、1 件だけ resolve できる', async () => {
305
+ const { env, home, catalog, catalogPath } = setupFixtureEnv();
306
+ const productId = 'telop-rich-pack-01';
307
+ const ids = Array.from({ length: 21 }, (_, index) => `telop-pass-${index + 1}`);
308
+ const [first, second] = ids;
309
+ writeCredentials(home);
310
+ let withPaidItems = catalog;
311
+ for (const id of ids) {
312
+ addPaidCatalogItem(withPaidItems, catalogPath, id, 'overlay', 1980, productId);
313
+ withPaidItems = JSON.parse(readFileSync(catalogPath, 'utf8'));
314
+ }
315
+ const zipPath = buildPaidZip(first, 'overlay', { layout: 'telop-pack', productId, siblingId: second });
316
+ // worker/lib/entitlement.mjs はパスを active + pass_eligible な商品の行へ展開する。
317
+ const entitlements = [
318
+ { product_id: productId, kind: 'asset-pack', current_version: 1 },
319
+ { product_id: 'fixture-other-product', kind: 'asset-pack', current_version: 2 },
320
+ ];
321
+ const downloads = [];
322
+ const fetchImpl = async (url, options = {}) => {
323
+ assert.equal(options.headers?.authorization, 'Bearer akst_test');
324
+ if (String(url).endsWith('/v1/entitlements')) {
325
+ return { ok: true, status: 200, json: async () => ({ entitlements }) };
326
+ }
327
+ downloads.push(String(url));
328
+ assert.ok(String(url).endsWith(`/v1/download/${productId}`));
329
+ return { ok: true, status: 200, body: Readable.toWeb(createReadStream(zipPath)) };
330
+ };
331
+ const state = await composeState({ env, fetchImpl });
332
+ assert.deepEqual(ids.map(id => state.items.find(item => item.id === id)?.state),
333
+ Array(ids.length).fill('available'));
334
+ assert.deepEqual(state.entitledProducts, [
335
+ { id: productId, kind: 'asset-pack', currentVersion: 1 },
336
+ { id: 'fixture-other-product', kind: 'asset-pack', currentVersion: 2 },
337
+ ]);
338
+ const result = await resolveAsset(second, { env, fetchImpl });
339
+ assert.deepEqual(downloads, [`https://example.invalid/api/store/v1/download/${productId}`]);
340
+ assert.equal(result.dir, path.join(home, 'assets', 'overlay', second));
341
+ assert.equal(JSON.parse(readFileSync(path.join(result.dir, 'meta.json'))).id, second);
342
+ assert.equal(existsSync(path.join(home, 'assets', 'overlay', first)), false);
343
+ });
344
+
266
345
  test('resolvePaidZip: meta.json の無い有料 zip は fail-closed(無警告スキップを許さない — #25)', async () => {
267
346
  const { env, home, catalog, catalogPath } = setupFixtureEnv();
268
347
  writeCredentials(home);
@@ -47,7 +47,8 @@ const CAPTION_STYLE_KEYS = new Set([
47
47
  'color', 'size_px', 'font_weight', 'line_height', 'stroke', 'background', 'zone', 'layout',
48
48
  'font_family', 'weight', 'italic', 'underline', 'strikethrough', 'list', 'opacity', 'letter_spacing_em', 'align',
49
49
  'vertical_align', 'vertical', 'text_transform', 'max_width_pct', 'wrap_width_pct', 'max_characters', 'text_anchor',
50
- 'position', 'scale', 'rotate', 'shadow', 'glow', 'animation', 'reference_height_px'
50
+ 'position', 'scale', 'rotate', 'shadow', 'glow', 'animation', 'reference_height_px',
51
+ 'stroke_inner', 'fill_gradient', 'extrude', 'karaoke'
51
52
  ]);
52
53
  const CAPTION_STROKE_KEYS = new Set(['method', 'color', 'width_px']);
53
54
  const CAPTION_BACKGROUND_KEYS = new Set([
@@ -578,6 +579,17 @@ function validateCaptionTextStyle(value, label = 'text_style') {
578
579
  rejectStyleUnknown(value, CAPTION_STYLE_KEYS, label);
579
580
  if (Object.prototype.hasOwnProperty.call(value, 'color'))
580
581
  validateHexColor(value.color, `${label}.color`);
582
+ if (Object.prototype.hasOwnProperty.call(value, 'karaoke')) {
583
+ if (!isRecord(value.karaoke))
584
+ fail('INVALID_TEXT_STYLE', `${label}.karaoke must be an object`);
585
+ rejectStyleUnknown(value.karaoke, new Set(['done_color', 'fill', 'start_index']), `${label}.karaoke`);
586
+ if (Object.prototype.hasOwnProperty.call(value.karaoke, 'done_color'))
587
+ validateHexColor(value.karaoke.done_color, `${label}.karaoke.done_color`);
588
+ if (Object.prototype.hasOwnProperty.call(value.karaoke, 'fill') && !['char', 'word', 'smooth'].includes(value.karaoke.fill))
589
+ fail('INVALID_TEXT_STYLE', `${label}.karaoke.fill must be char, word or smooth`);
590
+ if (Object.prototype.hasOwnProperty.call(value.karaoke, 'start_index') && (!Number.isInteger(value.karaoke.start_index) || value.karaoke.start_index < 0))
591
+ fail('INVALID_TEXT_STYLE', `${label}.karaoke.start_index must be a non-negative integer`);
592
+ }
581
593
  if (Object.prototype.hasOwnProperty.call(value, 'size_px') && !finitePositive(value.size_px)) {
582
594
  fail('INVALID_TEXT_STYLE', `${label}.size_px must be a positive finite number`);
583
595
  }
@@ -603,6 +615,43 @@ function validateCaptionTextStyle(value, label = 'text_style') {
603
615
  validateTextStyleV0(value, label);
604
616
  if (Object.prototype.hasOwnProperty.call(value, 'stroke'))
605
617
  validateCaptionStroke(value.stroke, `${label}.stroke`);
618
+ if (Object.prototype.hasOwnProperty.call(value, 'stroke_inner')) {
619
+ const inner = value.stroke_inner;
620
+ if (!isRecord(inner))
621
+ fail('INVALID_TEXT_STYLE', `${label}.stroke_inner must be an object`);
622
+ rejectStyleUnknown(inner, new Set(['color', 'width_px']), `${label}.stroke_inner`);
623
+ if (inner.color !== undefined)
624
+ validateHexColor(inner.color, `${label}.stroke_inner.color`);
625
+ if (inner.width_px !== undefined && !finiteNonNegative(inner.width_px)) {
626
+ fail('INVALID_TEXT_STYLE', `${label}.stroke_inner.width_px must be non-negative`);
627
+ }
628
+ }
629
+ if (Object.prototype.hasOwnProperty.call(value, 'fill_gradient')) {
630
+ const gradient = value.fill_gradient;
631
+ if (!isRecord(gradient))
632
+ fail('INVALID_TEXT_STYLE', `${label}.fill_gradient must be an object`);
633
+ rejectStyleUnknown(gradient, new Set(['colors', 'angle_deg']), `${label}.fill_gradient`);
634
+ if (!Array.isArray(gradient.colors) || gradient.colors.length < 2 || gradient.colors.length > 3) {
635
+ fail('INVALID_TEXT_STYLE', `${label}.fill_gradient.colors must contain 2 or 3 colors`);
636
+ }
637
+ gradient.colors.forEach((color, index) => validateHexColor(color, `${label}.fill_gradient.colors[${index}]`));
638
+ if (!finiteNumber(gradient.angle_deg))
639
+ fail('INVALID_TEXT_STYLE', `${label}.fill_gradient.angle_deg must be finite`);
640
+ }
641
+ if (Object.prototype.hasOwnProperty.call(value, 'extrude')) {
642
+ const extrude = value.extrude;
643
+ if (!isRecord(extrude))
644
+ fail('INVALID_TEXT_STYLE', `${label}.extrude must be an object`);
645
+ rejectStyleUnknown(extrude, new Set(['depth_px', 'color', 'color_end', 'angle_deg']), `${label}.extrude`);
646
+ if (!Number.isInteger(extrude.depth_px) || extrude.depth_px < 1 || extrude.depth_px > 32) {
647
+ fail('INVALID_TEXT_STYLE', `${label}.extrude.depth_px must be within [1, 32]`);
648
+ }
649
+ validateHexColor(extrude.color, `${label}.extrude.color`);
650
+ if (extrude.color_end !== undefined)
651
+ validateHexColor(extrude.color_end, `${label}.extrude.color_end`);
652
+ if (!finiteNumber(extrude.angle_deg))
653
+ fail('INVALID_TEXT_STYLE', `${label}.extrude.angle_deg must be finite`);
654
+ }
606
655
  if (Object.prototype.hasOwnProperty.call(value, 'background'))
607
656
  validateCaptionBackground(value.background, `${label}.background`);
608
657
  if (Object.prototype.hasOwnProperty.call(value, 'zone') && !CAPTION_ZONES.has(value.zone)) {
@@ -1384,7 +1433,7 @@ function mergeCaptionDisplayStyles(base, override) {
1384
1433
  const left = isRecord(base) ? base : {};
1385
1434
  const right = isRecord(override) ? override : {};
1386
1435
  const merged = { ...left, ...right };
1387
- for (const key of ['stroke', 'background', 'layout']) {
1436
+ for (const key of ['stroke', 'stroke_inner', 'fill_gradient', 'extrude', 'background', 'layout', 'karaoke']) {
1388
1437
  if (isRecord(left[key]) || isRecord(right[key]))
1389
1438
  merged[key] = { ...(isRecord(left[key]) ? left[key] : {}), ...(isRecord(right[key]) ? right[key] : {}) };
1390
1439
  }
@@ -1414,6 +1463,13 @@ function normalizeCaptionLineTextStyle(value) {
1414
1463
  const animationLoop = normalizeCaptionAnimationSlot(value.animation?.loop);
1415
1464
  const animationOut = normalizeCaptionAnimationSlot(value.animation?.out);
1416
1465
  return {
1466
+ ...(isRecord(value.karaoke) ? { karaoke: {
1467
+ ...(typeof value.karaoke.done_color === 'string' ? { done_color: value.karaoke.done_color } : {}),
1468
+ ...(value.karaoke.fill === 'char' || value.karaoke.fill === 'word' || value.karaoke.fill === 'smooth'
1469
+ ? { fill: value.karaoke.fill } : {}),
1470
+ ...(Number.isInteger(value.karaoke.start_index) && value.karaoke.start_index >= 0
1471
+ ? { start_index: value.karaoke.start_index } : {})
1472
+ } } : {}),
1417
1473
  ...(typeof value.color === 'string' ? { color: value.color } : {}),
1418
1474
  ...(finiteNumber(value.size_px) ? { size_px: value.size_px } : {}),
1419
1475
  ...(finiteNumber(value.scale) && value.scale >= 0.4 && value.scale <= 3 ? { scale: value.scale } : {}),
@@ -1472,6 +1528,18 @@ function normalizeCaptionLineTextStyle(value) {
1472
1528
  ...(typeof value.stroke.color === 'string' ? { color: value.stroke.color } : {}),
1473
1529
  ...(finiteNumber(value.stroke.width_px) ? { width_px: value.stroke.width_px } : {})
1474
1530
  } } : {}),
1531
+ ...(isRecord(value.stroke_inner) ? { stroke_inner: {
1532
+ ...(typeof value.stroke_inner.color === 'string' ? { color: value.stroke_inner.color } : {}),
1533
+ ...(finiteNumber(value.stroke_inner.width_px) ? { width_px: value.stroke_inner.width_px } : {})
1534
+ } } : {}),
1535
+ ...(isRecord(value.fill_gradient) ? { fill_gradient: {
1536
+ colors: value.fill_gradient.colors, angle_deg: value.fill_gradient.angle_deg
1537
+ } } : {}),
1538
+ ...(isRecord(value.extrude) ? { extrude: {
1539
+ depth_px: value.extrude.depth_px, color: value.extrude.color,
1540
+ ...(value.extrude.color_end !== undefined ? { color_end: value.extrude.color_end } : {}),
1541
+ angle_deg: value.extrude.angle_deg
1542
+ } } : {}),
1475
1543
  ...(isRecord(value.background) ? { background: {
1476
1544
  ...(typeof value.background.color === 'string' ? { color: value.background.color } : {}),
1477
1545
  ...(finiteNumber(value.background.opacity) ? { opacity: value.background.opacity } : {}),
@@ -1494,7 +1562,7 @@ function mergeCaptionLineTextStyles(base, override) {
1494
1562
  const left = normalizeCaptionLineTextStyle(base);
1495
1563
  const right = normalizeCaptionLineTextStyle(override);
1496
1564
  const merged = { ...left, ...right };
1497
- for (const key of ['stroke', 'background', 'shadow', 'glow', 'position', 'animation']) {
1565
+ for (const key of ['stroke', 'stroke_inner', 'fill_gradient', 'extrude', 'background', 'shadow', 'glow', 'position', 'animation', 'karaoke']) {
1498
1566
  if (isRecord(left[key]) || isRecord(right[key])) {
1499
1567
  merged[key] = { ...(isRecord(left[key]) ? left[key] : {}), ...(isRecord(right[key]) ? right[key] : {}) };
1500
1568
  if (Object.keys(merged[key]).length === 0)
@@ -1644,6 +1712,8 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1644
1712
  }
1645
1713
  if (typeof style.color === 'string')
1646
1714
  vars['--caption-color'] = style.color;
1715
+ if (isRecord(style.karaoke) && typeof style.karaoke.done_color === 'string')
1716
+ vars['--caption-highlight-color'] = style.karaoke.done_color;
1647
1717
  if (finiteNumber(style.size_px))
1648
1718
  vars['--caption-font-size'] = `${px(style.size_px)}px`;
1649
1719
  if (isRecord(style.stroke) && (typeof style.stroke.color === 'string' || finiteNumber(style.stroke.width_px))) {
@@ -1651,6 +1721,17 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1651
1721
  const color = typeof style.stroke.color === 'string' ? style.stroke.color : 'rgba(0,0,0,.9)';
1652
1722
  vars['--caption-stroke'] = `${width * 2}px ${color}`;
1653
1723
  }
1724
+ if (isRecord(style.stroke_inner) && !isRecord(style.stroke))
1725
+ vars['--caption-stroke'] = '0 transparent';
1726
+ if (isRecord(style.fill_gradient) && Array.isArray(style.fill_gradient.colors)
1727
+ && style.fill_gradient.colors.length >= 2 && style.fill_gradient.colors.length <= 3
1728
+ && style.fill_gradient.colors.every((color) => typeof color === 'string' && HEX_COLOR.test(color))
1729
+ && finiteNumber(style.fill_gradient.angle_deg)) {
1730
+ vars['--caption-fill-gradient'] = `linear-gradient(${style.fill_gradient.angle_deg}deg, ${style.fill_gradient.colors.join(', ')})`;
1731
+ vars['--caption-fill-clip'] = 'text';
1732
+ vars['--caption-fill-color'] = 'transparent';
1733
+ vars['--caption-fill-filter'] = captionGradientFilterValue(style, scale);
1734
+ }
1654
1735
  if (isRecord(style.background) && (typeof style.background.color === 'string' || finiteNumber(style.background.opacity))) {
1655
1736
  const name = style.background.mode === 'block'
1656
1737
  ? '--plate-block-bg' : extendedBackground ? '--plate-ext-bg' : '--plate-bg';
@@ -1710,7 +1791,7 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1710
1791
  vars['--plate-pad-y'] = `${px(style.background.padding_px)}px`;
1711
1792
  vars['--plate-pad-x'] = `${px(style.background.padding_px)}px`;
1712
1793
  }
1713
- const textShadow = captionTextShadowValue(style.shadow, style.glow, scale);
1794
+ const textShadow = captionRichTextShadowValue(style, scale);
1714
1795
  if (textShadow !== null)
1715
1796
  vars['--caption-text-shadow'] = textShadow;
1716
1797
  Object.assign(vars, captionZoneVars(style.zone));
@@ -1770,7 +1851,8 @@ function resolveCaptionStyleForOutput(style, output) {
1770
1851
  const vars = resolveCaptionLineStyleVarsAtScale(style, scale);
1771
1852
  Object.assign(vars, captionVerticalHeightVars(style, output));
1772
1853
  vars['--caption-paint-order'] = 'stroke fill';
1773
- if (!isRecord(style.shadow) && !isRecord(style.glow))
1854
+ if (!isRecord(style.shadow) && !isRecord(style.glow)
1855
+ && !isRecord(style.stroke_inner) && !isRecord(style.extrude))
1774
1856
  vars['--caption-text-shadow'] = 'none';
1775
1857
  if (isRecord(style.stroke)) {
1776
1858
  const color = typeof style.stroke.color === 'string' ? style.stroke.color : 'rgba(0,0,0,.85)';
@@ -1778,6 +1860,8 @@ function resolveCaptionStyleForOutput(style, output) {
1778
1860
  vars['--caption-webkit-text-stroke'] = `${formatCssNumber(width * 2)}px ${color}`;
1779
1861
  vars['--caption-paint-order'] = 'stroke fill';
1780
1862
  }
1863
+ if (isRecord(style.stroke_inner) && !isRecord(style.stroke))
1864
+ vars['--caption-webkit-text-stroke'] = '0 transparent';
1781
1865
  if (isRecord(style.background) && finiteNonNegative(style.background.radius_px)) {
1782
1866
  vars['--plate-block-radius'] = `${formatCssNumber(style.background.radius_px * scale)}px`;
1783
1867
  }
@@ -1878,6 +1962,102 @@ function captionTextShadowValue(shadow, glow, scale = 1) {
1878
1962
  }
1879
1963
  return parts.length > 0 ? parts.join(', ') : null;
1880
1964
  }
1965
+ function captionRichTextShadowValue(style, scale) {
1966
+ const parts = [];
1967
+ if (isRecord(style.stroke_inner) && finiteNonNegative(style.stroke_inner.width_px)
1968
+ && style.stroke_inner.width_px > 0) {
1969
+ const radius = scaleCaptionPx(style.stroke_inner.width_px, scale);
1970
+ const color = typeof style.stroke_inner.color === 'string' && HEX_COLOR.test(style.stroke_inner.color)
1971
+ ? style.stroke_inner.color : '#ffffff';
1972
+ for (let index = 0; index < 16; index++) {
1973
+ const angle = 2 * Math.PI * index / 16;
1974
+ parts.push(`${formatCssNumber(Math.cos(angle) * radius)}px ${formatCssNumber(Math.sin(angle) * radius)}px 0 ${color}`);
1975
+ }
1976
+ }
1977
+ if (isRecord(style.extrude) && Number.isInteger(style.extrude.depth_px)
1978
+ && style.extrude.depth_px >= 1 && style.extrude.depth_px <= 32
1979
+ && typeof style.extrude.color === 'string' && HEX_COLOR.test(style.extrude.color)
1980
+ && finiteNumber(style.extrude.angle_deg)) {
1981
+ const depth = Math.ceil(scaleCaptionPx(style.extrude.depth_px, scale));
1982
+ const angle = style.extrude.angle_deg * Math.PI / 180;
1983
+ for (let layer = 1; layer <= depth; layer++) {
1984
+ const distance = Math.min(layer, scaleCaptionPx(style.extrude.depth_px, scale));
1985
+ const color = typeof style.extrude.color_end === 'string' && HEX_COLOR.test(style.extrude.color_end)
1986
+ ? interpolateCaptionHex(style.extrude.color, style.extrude.color_end, layer / depth)
1987
+ : style.extrude.color;
1988
+ parts.push(`${formatCssNumber(Math.sin(angle) * distance)}px ${formatCssNumber(-Math.cos(angle) * distance)}px 0 ${color}`);
1989
+ }
1990
+ }
1991
+ const original = captionTextShadowValue(style.shadow, style.glow, scale);
1992
+ if (original)
1993
+ parts.push(original);
1994
+ return parts.length ? parts.join(', ') : null;
1995
+ }
1996
+ function captionGradientFilterValue(style, scale) {
1997
+ const parts = [];
1998
+ const grow = (radius, color) => {
1999
+ let remaining = radius;
2000
+ let power = 1;
2001
+ while (remaining > 0.0001) {
2002
+ const step = Math.min(power, remaining);
2003
+ for (const [x, y] of [[step, 0], [0, step], [-step, 0], [0, -step]]) {
2004
+ parts.push(`drop-shadow(${formatCssNumber(x)}px ${formatCssNumber(y)}px 0 ${color})`);
2005
+ }
2006
+ remaining -= step;
2007
+ power *= 2;
2008
+ }
2009
+ };
2010
+ const stroke = isRecord(style.stroke) ? style.stroke : null;
2011
+ const inner = isRecord(style.stroke_inner) ? style.stroke_inner : null;
2012
+ const outerRadius = stroke && finiteNonNegative(stroke.width_px) ? scaleCaptionPx(stroke.width_px, scale) : 0;
2013
+ const innerRadius = inner && finiteNonNegative(inner.width_px) ? scaleCaptionPx(inner.width_px, scale) : 0;
2014
+ if (innerRadius > 0)
2015
+ grow(innerRadius, typeof inner?.color === 'string' && HEX_COLOR.test(inner.color)
2016
+ ? inner.color : '#ffffff');
2017
+ if (outerRadius > 0)
2018
+ grow(Math.max(0, outerRadius - innerRadius), typeof stroke?.color === 'string' && HEX_COLOR.test(stroke.color) ? stroke.color : '#000000');
2019
+ if (isRecord(style.extrude) && Number.isInteger(style.extrude.depth_px)
2020
+ && style.extrude.depth_px >= 1 && style.extrude.depth_px <= 32
2021
+ && typeof style.extrude.color === 'string' && HEX_COLOR.test(style.extrude.color)
2022
+ && finiteNumber(style.extrude.angle_deg)) {
2023
+ const depth = Math.ceil(scaleCaptionPx(style.extrude.depth_px, scale));
2024
+ const angle = style.extrude.angle_deg * Math.PI / 180;
2025
+ for (let layer = 1; layer <= depth; layer++) {
2026
+ const color = typeof style.extrude.color_end === 'string' && HEX_COLOR.test(style.extrude.color_end)
2027
+ ? interpolateCaptionHex(style.extrude.color, style.extrude.color_end, layer / depth)
2028
+ : style.extrude.color;
2029
+ parts.push(`drop-shadow(${formatCssNumber(Math.sin(angle))}px ${formatCssNumber(-Math.cos(angle))}px 0 ${color})`);
2030
+ }
2031
+ }
2032
+ if (isRecord(style.shadow) && typeof style.shadow.color === 'string' && HEX_COLOR.test(style.shadow.color)) {
2033
+ const angle = (finiteNumber(style.shadow.angle_deg) ? style.shadow.angle_deg : 90) * Math.PI / 180;
2034
+ const distance = scaleCaptionPx(finiteNonNegative(style.shadow.distance_px) ? style.shadow.distance_px : 0, scale);
2035
+ const blur = scaleCaptionPx(finiteNonNegative(style.shadow.blur_px) ? style.shadow.blur_px : 0, scale);
2036
+ parts.push(`drop-shadow(${formatCssNumber(Math.cos(angle) * distance)}px ${formatCssNumber(Math.sin(angle) * distance)}px ${formatCssNumber(blur)}px ${colorWithOpacity(style.shadow.color, style.shadow.opacity)})`);
2037
+ }
2038
+ if (isRecord(style.glow) && typeof style.glow.color === 'string' && HEX_COLOR.test(style.glow.color)) {
2039
+ const spread = scaleCaptionPx(finiteNonNegative(style.glow.spread) ? style.glow.spread : 40, scale);
2040
+ const alpha = Math.min(1, (finiteNonNegative(style.glow.density) ? style.glow.density : 50) / 60);
2041
+ const x = scaleCaptionPx(finiteNumber(style.glow.offset_x) ? style.glow.offset_x : 0, scale);
2042
+ const y = scaleCaptionPx(finiteNumber(style.glow.offset_y) ? style.glow.offset_y : 0, scale);
2043
+ parts.push(`drop-shadow(${formatCssNumber(x)}px ${formatCssNumber(y)}px ${formatCssNumber(spread)}px ${colorWithOpacity(style.glow.color, alpha)})`);
2044
+ parts.push(`drop-shadow(${formatCssNumber(x)}px ${formatCssNumber(y)}px ${formatCssNumber(spread * 2)}px ${colorWithOpacity(style.glow.color, Number((alpha * 0.7).toFixed(4)))})`);
2045
+ }
2046
+ return parts.join(' ') || 'none';
2047
+ }
2048
+ function interpolateCaptionHex(start, end, fraction) {
2049
+ const rgba = (value) => {
2050
+ const hex = value.slice(1);
2051
+ const full = hex.length === 3 ? hex.split('').map(digit => digit + digit).join('') : hex;
2052
+ return [0, 2, 4, 6].map(index => index === 6 && full.length === 6
2053
+ ? 255 : parseInt(full.slice(index, index + 2), 16));
2054
+ };
2055
+ const from = rgba(start);
2056
+ const to = rgba(end);
2057
+ const channels = start.length === 9 || end.length === 9 ? 4 : 3;
2058
+ return '#' + from.slice(0, channels).map((value, index) => Math.round(value + ((to[index] ?? value) - value) * fraction)
2059
+ .toString(16).padStart(2, '0')).join('');
2060
+ }
1881
2061
  function colorWithOpacity(color, explicitOpacity) {
1882
2062
  const raw = color.slice(1);
1883
2063
  const expanded = raw.length === 3
@@ -49,6 +49,11 @@ export interface CaptionLayout {
49
49
  maxLines: 1;
50
50
  }
51
51
  export interface CaptionTextStyle {
52
+ karaoke?: {
53
+ doneColor?: string;
54
+ fill?: 'char' | 'word' | 'smooth';
55
+ startIndex?: number;
56
+ };
52
57
  color?: string;
53
58
  sizePx?: number;
54
59
  /** zone 方式の px 系フィールドの基準出力高さ(issue #40 §2)。integer ≥ 1。layout と排他。 */
@@ -80,6 +85,20 @@ export interface CaptionTextStyle {
80
85
  color?: string;
81
86
  widthPx?: number;
82
87
  };
88
+ strokeInner?: {
89
+ color?: string;
90
+ widthPx?: number;
91
+ };
92
+ fillGradient?: {
93
+ colors: string[];
94
+ angleDeg: number;
95
+ };
96
+ extrude?: {
97
+ depthPx: number;
98
+ color: string;
99
+ colorEnd?: string;
100
+ angleDeg: number;
101
+ };
83
102
  background?: {
84
103
  color?: string;
85
104
  opacity?: number;
@@ -96,6 +115,7 @@ export interface CaptionTextStyle {
96
115
  layout?: CaptionLayout;
97
116
  }
98
117
  export interface CaptionTextStylePatch {
118
+ karaoke?: CaptionTextStyle['karaoke'] | null;
99
119
  color?: string | null;
100
120
  sizePx?: number | null;
101
121
  wrapWidthPct?: number | null;
@@ -110,6 +130,20 @@ export interface CaptionTextStylePatch {
110
130
  color?: string | null;
111
131
  widthPx?: number | null;
112
132
  };
133
+ strokeInner?: {
134
+ color?: string;
135
+ widthPx?: number;
136
+ } | null;
137
+ fillGradient?: {
138
+ colors: string[];
139
+ angleDeg: number;
140
+ } | null;
141
+ extrude?: {
142
+ depthPx: number;
143
+ color: string;
144
+ colorEnd?: string;
145
+ angleDeg: number;
146
+ } | null;
113
147
  background?: {
114
148
  color?: string | null;
115
149
  opacity?: number | null;