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 +1 -1
- package/src/narration-command.mjs +27 -5
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/asset-resolver/src/resolve.mjs +18 -14
- package/vendor/packages/asset-resolver/src/state.mjs +2 -1
- package/vendor/packages/asset-resolver/test/resolve-paid-zip.test.mjs +89 -10
- package/vendor/packages/edit-store/lib/caption-display.js +185 -5
- package/vendor/packages/edit-store/lib/caption-store.d.ts +34 -0
- package/vendor/packages/edit-store/lib/caption-store.js +132 -2
- package/vendor/packages/edit-store/lib/generated/textstyle-catalog.js +466 -0
- package/vendor/packages/edit-store/lib/webview-kernel.js +580 -2
- package/vendor/packages/schemas/bin/validate-captions.mjs +40 -0
- package/vendor/packages/schemas/captions.schema.json +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
|
212
|
-
|
|
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,
|
|
217
|
+
const { packageDir, payloadFiles } = await verifyPaidZipContents(extractedRoot, productId);
|
|
217
218
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
219
|
+
// パックは assets/<category>/<id>/ と assets/<id>/ の両配置を受け付ける。
|
|
220
|
+
// 契約 v0 のフラット形状(zip 直下に meta.json / 本体)も
|
|
220
221
|
// 引き続き受け付ける。どちらの形でも、ライブラリへ入るのは素材ディレクトリの中身だけ
|
|
221
222
|
// (PACK.json / docs はライブラリに混ぜない — 全量が必要なら `akari store download` が zip を渡す)。
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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)
|
|
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 = `${
|
|
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"><
|
|
67
|
-
|
|
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 =
|
|
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
|