akari-video 0.1.94 → 0.1.96

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.94",
3
+ "version": "0.1.96",
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": {
@@ -234,7 +234,7 @@ function isFiniteNumber(value) {
234
234
  const VALUE_OPTIONS = new Set([
235
235
  "--project", "--engine", "--reading-file", "--script-file",
236
236
  "--t", "--gain-db", "--id", "--speaker", "--profile",
237
- "--voice", "--style", "--speed", "--text", "--caption-ref", "--irodori-url",
237
+ "--voice", "--style", "--speed", "--text", "--caption-ref", "--irodori-url", "--out",
238
238
  ]);
239
239
  const FLAG_OPTIONS = new Set(["--dry-run", "--yes", "--apply", "--json"]);
240
240
 
@@ -252,7 +252,7 @@ function parseArguments(argv) {
252
252
  id: null,
253
253
  speaker: "3",
254
254
  profile: null,
255
- voice: null, style: null, speed: null, text: null, captionRef: null, irodoriUrl: null, json: false,
255
+ voice: null, style: null, speed: null, text: null, captionRef: null, irodoriUrl: null, out: null, json: false,
256
256
  dryRun: false,
257
257
  yes: false,
258
258
  apply: false,
@@ -281,6 +281,7 @@ function parseArguments(argv) {
281
281
  case "--text": options.text = value; break;
282
282
  case "--caption-ref": options.captionRef = value; break;
283
283
  case "--irodori-url": options.irodoriUrl = value; break;
284
+ case "--out": options.out = value; break;
284
285
  default: break;
285
286
  }
286
287
  } else if (argument === "--dry-run") {
@@ -336,6 +337,26 @@ function parseArguments(argv) {
336
337
  }
337
338
 
338
339
  options.project = path.resolve(options.project);
340
+ if (options.out !== null) {
341
+ const out = options.out.replace(/\\/gu, '/');
342
+ if (options.apply || options.id || !/^assets\/generated\/candidates\/[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:wav|mp3)$/u.test(out)
343
+ || out.split('/').some(part => part === '..') || !out.endsWith(`.${extensionFor(options.engine)}`)) {
344
+ throw new PublicError('--out はプロジェクト内の未使用候補音声パスを指定してください', 2);
345
+ }
346
+ const directory = path.dirname(path.join(options.project, out));
347
+ const root = fs.realpathSync(options.project);
348
+ for (let parent = path.dirname(directory); parent !== options.project && parent.startsWith(`${options.project}${path.sep}`); parent = path.dirname(parent)) {
349
+ if (fs.existsSync(parent) && !fs.realpathSync(parent).startsWith(`${root}${path.sep}`)) {
350
+ throw new PublicError('候補の出力先がプロジェクト外です', 2);
351
+ }
352
+ }
353
+ fs.mkdirSync(directory, { recursive: true });
354
+ if (!fs.realpathSync(directory).startsWith(`${root}${path.sep}`)
355
+ || fs.existsSync(path.join(options.project, out)) || fs.existsSync(path.join(options.project, `${out}.meta.json`))) {
356
+ throw new PublicError('候補の出力先がプロジェクト外、または既存ファイルです', 2);
357
+ }
358
+ options.out = out;
359
+ }
339
360
  return options;
340
361
  }
341
362
 
@@ -1008,8 +1029,8 @@ async function runGenerate(options, io) {
1008
1029
  return 0;
1009
1030
  }
1010
1031
 
1011
- const id = options.id ?? computeNextId(options.project);
1012
- const relativePath = relativeOutputPath(id, options.engine);
1032
+ const id = options.out ? null : options.id ?? computeNextId(options.project);
1033
+ const relativePath = options.out ?? relativeOutputPath(id, options.engine);
1013
1034
  const outputPath = path.join(options.project, relativePath);
1014
1035
 
1015
1036
  let audioBuffer;
@@ -1107,7 +1128,8 @@ async function runGenerate(options, io) {
1107
1128
  }
1108
1129
 
1109
1130
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1110
- fs.writeFileSync(outputPath, audioBuffer);
1131
+ if (options.out) fs.writeFileSync(outputPath, audioBuffer, { flag: 'wx' });
1132
+ else fs.writeFileSync(outputPath, audioBuffer);
1111
1133
  const duration = durationForAudio(audioBuffer, outputPath, options.engine, warnings);
1112
1134
 
1113
1135
  const entry = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
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