akari-video 0.1.90 → 0.1.91

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.90",
3
+ "version": "0.1.91",
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.90",
3
+ "version": "0.1.91",
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": [
@@ -2596,7 +2596,8 @@ async function validateOverlays(overlays, timeline, findings, paths) {
2596
2596
  });
2597
2597
  }
2598
2598
  if (!isNonEmptyString(overlay.html)) continue;
2599
- const htmlPath = resolveReference(paths.editPath, overlay.html, paths);
2599
+ const htmlBinding = resolveReferenceBinding(paths.editPath, overlay.html, paths);
2600
+ const htmlPath = htmlBinding.path;
2600
2601
  const isHtmlFile = await isRegularFile(htmlPath);
2601
2602
  // overlay.html は file 参照(相対パス)とインライン HTML の両方をとりうる。参照でなければ
2602
2603
  // フィールドの値そのものを断片本文として扱う(inspectHtmlFragment 以降のルート要素検証は
@@ -2626,9 +2627,16 @@ async function validateOverlays(overlays, timeline, findings, paths) {
2626
2627
  });
2627
2628
  continue;
2628
2629
  }
2630
+ // 共有ライブラリ参照(.akari/asset-references.json 経由で原本を読む)の断片は、ライブラリが
2631
+ // data-start="0" data-duration="<素材の長さ>" で配っていて、プロジェクト側からは書き換えられない。
2632
+ // 置いた時刻・長さの写しを作る取り込み(placedFragmentCopy)も参照では走らないため、ここで
2633
+ // 検査すると置いた時点で必ず不一致になり書き出しが止まる。ランタイムは edit.json から作る
2634
+ // .akari-overlay-container の値だけを使うので、原本ルートの値は検査しない。
2635
+ const libraryFragment = htmlBinding.scope === "library";
2629
2636
  if (
2630
- Object.hasOwn(fragment.rootAttributes, "data-start")
2631
- || Object.hasOwn(fragment.rootAttributes, "data-duration")
2637
+ !libraryFragment
2638
+ && (Object.hasOwn(fragment.rootAttributes, "data-start")
2639
+ || Object.hasOwn(fragment.rootAttributes, "data-duration"))
2632
2640
  ) {
2633
2641
  addFinding(findings, {
2634
2642
  severity: "warning",
@@ -2659,7 +2667,7 @@ async function validateOverlays(overlays, timeline, findings, paths) {
2659
2667
  }
2660
2668
  }
2661
2669
 
2662
- for (const [attribute, expected] of [
2670
+ for (const [attribute, expected] of libraryFragment ? [] : [
2663
2671
  ["data-start", overlay.start],
2664
2672
  ["data-duration", overlay.duration],
2665
2673
  ]) {
@@ -45,7 +45,7 @@ exports.CAPTION_UNIT_METRIC = 'ascii-half-other-one-v1';
45
45
  // (legacy 専用キーを display_policy 側が使うと単に無視される — 不正ではない)。
46
46
  const CAPTION_STYLE_KEYS = new Set([
47
47
  'color', 'size_px', 'font_weight', 'line_height', 'stroke', 'background', 'zone', 'layout',
48
- 'font_family', 'weight', 'italic', 'underline', 'letter_spacing_em', 'align',
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
50
  'position', 'scale', 'rotate', 'shadow', 'glow', 'animation', 'reference_height_px'
51
51
  ]);
@@ -712,6 +712,9 @@ function validateTextStyleV0(value, label) {
712
712
  && (!Number.isInteger(value.weight) || value.weight < 100 || value.weight > 900), 'weight must be an integer within [100, 900]');
713
713
  failIf(has('italic') && typeof value.italic !== 'boolean', 'italic must be a boolean');
714
714
  failIf(has('underline') && typeof value.underline !== 'boolean', 'underline must be a boolean');
715
+ failIf(has('strikethrough') && typeof value.strikethrough !== 'boolean', 'strikethrough must be a boolean');
716
+ failIf(has('list') && value.list !== 'bullet' && value.list !== null, 'list must be bullet or null');
717
+ failIf(has('opacity') && (!finiteNumber(value.opacity) || value.opacity < 0 || value.opacity > 1), 'opacity must be within [0, 1]');
715
718
  failIf(has('letter_spacing_em') && !finiteNumber(value.letter_spacing_em), 'letter_spacing_em must be a finite number');
716
719
  failIf(has('align') && !CAPTION_ALIGN_VALUES.has(value.align), 'align must be one of left, center, right');
717
720
  failIf(has('vertical_align') && !CAPTION_VERTICAL_ALIGN_VALUES.has(value.vertical_align), 'vertical_align must be one of top, middle, bottom');
@@ -1421,13 +1424,16 @@ function normalizeCaptionLineTextStyle(value) {
1421
1424
  ? { weight: value.weight }
1422
1425
  : Number.isInteger(value.font_weight) && value.font_weight >= 1 && value.font_weight <= 1000
1423
1426
  ? { weight: value.font_weight } : {}),
1424
- ...(value.italic === true ? { italic: true } : {}),
1425
- ...(value.underline === true ? { underline: true } : {}),
1427
+ ...(typeof value.italic === 'boolean' ? { italic: value.italic } : {}),
1428
+ ...(typeof value.underline === 'boolean' ? { underline: value.underline } : {}),
1429
+ ...(typeof value.strikethrough === 'boolean' ? { strikethrough: value.strikethrough } : {}),
1430
+ ...(value.list === 'bullet' || value.list === null ? { list: value.list } : {}),
1431
+ ...(finiteNumber(value.opacity) && value.opacity >= 0 && value.opacity <= 1 ? { opacity: value.opacity } : {}),
1426
1432
  ...(finiteNumber(value.letter_spacing_em) ? { letter_spacing_em: value.letter_spacing_em } : {}),
1427
1433
  ...(finitePositive(value.line_height) ? { line_height: value.line_height } : {}),
1428
1434
  ...(CAPTION_ALIGN_VALUES.has(value.align) ? { align: value.align } : {}),
1429
1435
  ...(CAPTION_VERTICAL_ALIGN_VALUES.has(value.vertical_align) ? { vertical_align: value.vertical_align } : {}),
1430
- ...(value.vertical === true ? { vertical: true } : {}),
1436
+ ...(typeof value.vertical === 'boolean' ? { vertical: value.vertical } : {}),
1431
1437
  ...(CAPTION_TEXT_TRANSFORM_MAP[value.text_transform]
1432
1438
  ? { text_transform: CAPTION_TEXT_TRANSFORM_MAP[value.text_transform] } : {}),
1433
1439
  ...(finiteNumber(value.max_width_pct) && value.max_width_pct > 0 && value.max_width_pct < 100
@@ -1623,6 +1629,11 @@ function captionAnchorPositionVars(anchorValue, positionValue, verticalAlignValu
1623
1629
  }
1624
1630
  return vars;
1625
1631
  }
1632
+ function cssCaptionFontFamily(value) {
1633
+ if (value.includes(',') || /^(['"]).*\1$/s.test(value.trim()))
1634
+ return value;
1635
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
1636
+ }
1626
1637
  function resolveCaptionLineStyleVarsAtScale(style, scale) {
1627
1638
  const vars = {};
1628
1639
  const px = (value) => scaleCaptionPx(value, scale);
@@ -1651,15 +1662,20 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1651
1662
  vars[name] = `${px(style.background.radius_px)}px`;
1652
1663
  }
1653
1664
  if (typeof style.font_family === 'string')
1654
- vars['--caption-font-family'] = style.font_family;
1665
+ vars['--caption-font-family'] = cssCaptionFontFamily(style.font_family);
1655
1666
  if (finiteNumber(style.weight))
1656
1667
  vars['--caption-font-weight'] = String(style.weight);
1657
1668
  else if (Number.isInteger(style.font_weight))
1658
1669
  vars['--caption-font-weight'] = String(style.font_weight);
1659
1670
  if (style.italic)
1660
1671
  vars['--caption-font-style'] = 'italic';
1661
- if (style.underline)
1662
- vars['--caption-text-decoration'] = 'underline';
1672
+ if (style.underline || style.strikethrough)
1673
+ vars['--caption-text-decoration'] =
1674
+ [style.underline ? 'underline' : '', style.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ');
1675
+ if (style.list === 'bullet')
1676
+ vars['--caption-list-display'] = 'list-item';
1677
+ if (finiteNumber(style.opacity))
1678
+ vars['--caption-opacity'] = String(style.opacity);
1663
1679
  if (finiteNumber(style.letter_spacing_em))
1664
1680
  vars['--caption-letter-spacing'] = `${style.letter_spacing_em}em`;
1665
1681
  if (finiteNumber(style.line_height))
@@ -1671,8 +1687,13 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1671
1687
  vars['--caption-line-max-width'] = `${style.max_width_pct}%`;
1672
1688
  if (finiteNumber(style.wrap_width_pct))
1673
1689
  vars['--caption-wrap-width'] = `${style.wrap_width_pct}%`;
1674
- if (style.vertical)
1690
+ if (style.vertical) {
1675
1691
  vars['--caption-writing-mode'] = 'vertical-rl';
1692
+ vars['--caption-text-orientation'] = 'upright';
1693
+ vars['--caption-width'] = 'max-content';
1694
+ delete vars['--caption-line-max-width'];
1695
+ delete vars['--caption-wrap-width'];
1696
+ }
1676
1697
  if (extendedBackground && isRecord(style.background)) {
1677
1698
  if (style.background.fit !== 'frame') {
1678
1699
  vars['--plate-ext-width'] = percentageBackground
@@ -1693,11 +1714,25 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1693
1714
  if (textShadow !== null)
1694
1715
  vars['--caption-text-shadow'] = textShadow;
1695
1716
  Object.assign(vars, captionZoneVars(style.zone));
1696
- Object.assign(vars, captionAnchorPositionVars(style.text_anchor, style.position, style.vertical_align));
1717
+ Object.assign(vars, captionAnchorPositionVars(style.text_anchor, style.position, style.vertical ? undefined : style.vertical_align));
1718
+ if (style.vertical && style.vertical_align && !(isRecord(style.position) && finiteNumber(style.position.x))) {
1719
+ vars['--caption-left'] = style.vertical_align === 'top' ? 'auto' : style.vertical_align === 'middle' ? '50%' : '4%';
1720
+ vars['--caption-right'] = style.vertical_align === 'top' ? '4%' : 'auto';
1721
+ vars['--caption-align-items'] = style.vertical_align === 'top' ? 'flex-end'
1722
+ : style.vertical_align === 'middle' ? 'center' : 'flex-start';
1723
+ if (style.vertical_align === 'middle')
1724
+ vars['--caption-translate'] = '-50% 0';
1725
+ }
1726
+ else if (style.vertical && !style.vertical_align && !style.text_anchor
1727
+ && (!style.zone || style.zone === 'bottom')
1728
+ && !(isRecord(style.position) && finiteNumber(style.position.x))) {
1729
+ vars['--caption-left'] = '50%';
1730
+ vars['--caption-right'] = 'auto';
1731
+ vars['--caption-align-items'] = 'center';
1732
+ vars['--caption-translate'] = '-50% 0';
1733
+ }
1697
1734
  if (style.align) {
1698
1735
  vars['--caption-text-align'] = style.align;
1699
- vars['--caption-align-items'] = style.align === 'left'
1700
- ? 'flex-start' : style.align === 'right' ? 'flex-end' : 'center';
1701
1736
  }
1702
1737
  return vars;
1703
1738
  }
@@ -1705,7 +1740,16 @@ function resolveCaptionLineStyleVarsAtScale(style, scale) {
1705
1740
  function resolveCaptionLineStyleVars(style, output) {
1706
1741
  if (!isRecord(style))
1707
1742
  return {};
1708
- return resolveCaptionLineStyleVarsAtScale(style, resolveCaptionReferenceScale(style, output));
1743
+ const vars = resolveCaptionLineStyleVarsAtScale(style, resolveCaptionReferenceScale(style, output));
1744
+ Object.assign(vars, captionVerticalHeightVars(style, output));
1745
+ return vars;
1746
+ }
1747
+ function captionVerticalHeightVars(style, output) {
1748
+ if (style.vertical !== true || !output || !finitePositive(output.height))
1749
+ return {};
1750
+ const pct = finitePositive(style.wrap_width_pct) ? style.wrap_width_pct
1751
+ : finitePositive(style.max_width_pct) ? style.max_width_pct : 90;
1752
+ return { '--caption-vertical-max-height': `${formatCssNumber(output.height * pct / 100)}px` };
1709
1753
  }
1710
1754
  function resolveCaptionStyleForOutput(style, output) {
1711
1755
  let layout;
@@ -1724,6 +1768,7 @@ function resolveCaptionStyleForOutput(style, output) {
1724
1768
  scale = resolveCaptionReferenceScale(style, output);
1725
1769
  }
1726
1770
  const vars = resolveCaptionLineStyleVarsAtScale(style, scale);
1771
+ Object.assign(vars, captionVerticalHeightVars(style, output));
1727
1772
  vars['--caption-paint-order'] = 'stroke fill';
1728
1773
  if (!isRecord(style.shadow) && !isRecord(style.glow))
1729
1774
  vars['--caption-text-shadow'] = 'none';
@@ -1771,7 +1816,7 @@ function resolveCaptionWordStyleVars(style, output) {
1771
1816
  if (finitePositive(style.size_px))
1772
1817
  vars['--caption-tok-font-size'] = `${formatCssNumber(style.size_px * scale)}px`;
1773
1818
  if (typeof style.font_family === 'string' && style.font_family.length > 0) {
1774
- vars['--caption-tok-font-family'] = style.font_family;
1819
+ vars['--caption-tok-font-family'] = cssCaptionFontFamily(style.font_family);
1775
1820
  }
1776
1821
  if (Number.isInteger(style.weight) && style.weight >= 100 && style.weight <= 900) {
1777
1822
  vars['--caption-tok-font-weight'] = String(style.weight);
@@ -58,6 +58,9 @@ export interface CaptionTextStyle {
58
58
  weight?: number;
59
59
  italic?: boolean;
60
60
  underline?: boolean;
61
+ strikethrough?: boolean;
62
+ list?: 'bullet' | null;
63
+ opacity?: number;
61
64
  letterSpacingEm?: number;
62
65
  lineHeight?: number;
63
66
  align?: CaptionAlign;
@@ -973,7 +973,7 @@ function isFiniteInRange(value, min, max) {
973
973
  // captions.schema.json の $defs/textStyle が受理する全プロパティ名(2026-08-10 拡張)。
974
974
  // これ以外のキーは「未知」として個別に無視する(行やスタイル全体は破棄しない)。
975
975
  const TEXT_STYLE_KEYS = new Set([
976
- 'color', 'size_px', 'reference_height_px', 'font_family', 'font_weight', 'weight', 'italic', 'underline',
976
+ 'color', 'size_px', 'reference_height_px', 'font_family', 'font_weight', 'weight', 'italic', 'underline', 'strikethrough', 'list', 'opacity',
977
977
  'letter_spacing_em', 'line_height', 'align', 'vertical_align', 'vertical',
978
978
  'text_transform', 'max_width_pct', 'wrap_width_pct', 'max_characters', 'text_anchor', 'position', 'scale', 'rotate', 'shadow', 'glow',
979
979
  'animation', 'stroke', 'background', 'zone', 'layout'
@@ -1019,6 +1019,12 @@ function normalizeTextStyle(value, onUnknownKeys) {
1019
1019
  if (typeof value.underline === 'boolean') {
1020
1020
  style.underline = value.underline;
1021
1021
  }
1022
+ if (typeof value.strikethrough === 'boolean')
1023
+ style.strikethrough = value.strikethrough;
1024
+ if (value.list === 'bullet' || value.list === null)
1025
+ style.list = value.list;
1026
+ if (isFiniteNumber(value.opacity) && value.opacity >= 0 && value.opacity <= 1)
1027
+ style.opacity = value.opacity;
1022
1028
  if (isFiniteNumber(value.letter_spacing_em)) {
1023
1029
  style.letterSpacingEm = value.letter_spacing_em;
1024
1030
  }
@@ -1259,6 +1265,9 @@ function textStyleToJson(style) {
1259
1265
  ...(style.weight !== undefined ? { weight: style.weight } : {}),
1260
1266
  ...(style.italic !== undefined ? { italic: style.italic } : {}),
1261
1267
  ...(style.underline !== undefined ? { underline: style.underline } : {}),
1268
+ ...(style.strikethrough !== undefined ? { strikethrough: style.strikethrough } : {}),
1269
+ ...(style.list !== undefined ? { list: style.list } : {}),
1270
+ ...(style.opacity !== undefined ? { opacity: style.opacity } : {}),
1262
1271
  ...(style.letterSpacingEm !== undefined ? { letter_spacing_em: style.letterSpacingEm } : {}),
1263
1272
  ...(style.lineHeight !== undefined ? { line_height: style.lineHeight } : {}),
1264
1273
  ...(style.align !== undefined ? { align: style.align } : {}),
@@ -5166,13 +5166,16 @@ var AkariEditKernel = (() => {
5166
5166
  ...positiveInteger(value.reference_height_px) ? { reference_height_px: value.reference_height_px } : {},
5167
5167
  ...typeof value.font_family === "string" && value.font_family !== "" ? { font_family: value.font_family } : {},
5168
5168
  ...finiteNumber(value.weight) && value.weight >= 100 && value.weight <= 900 ? { weight: value.weight } : Number.isInteger(value.font_weight) && value.font_weight >= 1 && value.font_weight <= 1e3 ? { weight: value.font_weight } : {},
5169
- ...value.italic === true ? { italic: true } : {},
5170
- ...value.underline === true ? { underline: true } : {},
5169
+ ...typeof value.italic === "boolean" ? { italic: value.italic } : {},
5170
+ ...typeof value.underline === "boolean" ? { underline: value.underline } : {},
5171
+ ...typeof value.strikethrough === "boolean" ? { strikethrough: value.strikethrough } : {},
5172
+ ...value.list === "bullet" || value.list === null ? { list: value.list } : {},
5173
+ ...finiteNumber(value.opacity) && value.opacity >= 0 && value.opacity <= 1 ? { opacity: value.opacity } : {},
5171
5174
  ...finiteNumber(value.letter_spacing_em) ? { letter_spacing_em: value.letter_spacing_em } : {},
5172
5175
  ...finitePositive2(value.line_height) ? { line_height: value.line_height } : {},
5173
5176
  ...CAPTION_ALIGN_VALUES.has(value.align) ? { align: value.align } : {},
5174
5177
  ...CAPTION_VERTICAL_ALIGN_VALUES.has(value.vertical_align) ? { vertical_align: value.vertical_align } : {},
5175
- ...value.vertical === true ? { vertical: true } : {},
5178
+ ...typeof value.vertical === "boolean" ? { vertical: value.vertical } : {},
5176
5179
  ...CAPTION_TEXT_TRANSFORM_MAP[value.text_transform] ? { text_transform: CAPTION_TEXT_TRANSFORM_MAP[value.text_transform] } : {},
5177
5180
  ...finiteNumber(value.max_width_pct) && value.max_width_pct > 0 && value.max_width_pct < 100 ? { max_width_pct: value.max_width_pct } : {},
5178
5181
  ...finiteNumber(value.wrap_width_pct) && value.wrap_width_pct > 0 && value.wrap_width_pct <= 100 ? { wrap_width_pct: value.wrap_width_pct } : {},
@@ -5313,6 +5316,10 @@ var AkariEditKernel = (() => {
5313
5316
  }
5314
5317
  return vars;
5315
5318
  }
5319
+ function cssCaptionFontFamily(value) {
5320
+ if (value.includes(",") || /^(['"]).*\1$/s.test(value.trim())) return value;
5321
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
5322
+ }
5316
5323
  function resolveCaptionLineStyleVarsAtScale(style, scale) {
5317
5324
  const vars = {};
5318
5325
  const px = (value) => scaleCaptionPx(value, scale);
@@ -5339,11 +5346,13 @@ var AkariEditKernel = (() => {
5339
5346
  const name = style.background.mode === "block" ? "--plate-block-radius" : extendedBackground ? "--plate-ext-radius" : "--plate-radius";
5340
5347
  vars[name] = `${px(style.background.radius_px)}px`;
5341
5348
  }
5342
- if (typeof style.font_family === "string") vars["--caption-font-family"] = style.font_family;
5349
+ if (typeof style.font_family === "string") vars["--caption-font-family"] = cssCaptionFontFamily(style.font_family);
5343
5350
  if (finiteNumber(style.weight)) vars["--caption-font-weight"] = String(style.weight);
5344
5351
  else if (Number.isInteger(style.font_weight)) vars["--caption-font-weight"] = String(style.font_weight);
5345
5352
  if (style.italic) vars["--caption-font-style"] = "italic";
5346
- if (style.underline) vars["--caption-text-decoration"] = "underline";
5353
+ if (style.underline || style.strikethrough) vars["--caption-text-decoration"] = [style.underline ? "underline" : "", style.strikethrough ? "line-through" : ""].filter(Boolean).join(" ");
5354
+ if (style.list === "bullet") vars["--caption-list-display"] = "list-item";
5355
+ if (finiteNumber(style.opacity)) vars["--caption-opacity"] = String(style.opacity);
5347
5356
  if (finiteNumber(style.letter_spacing_em)) vars["--caption-letter-spacing"] = `${style.letter_spacing_em}em`;
5348
5357
  if (finiteNumber(style.line_height)) vars["--caption-line-height"] = String(style.line_height);
5349
5358
  if (typeof style.text_transform === "string" && CAPTION_TEXT_TRANSFORM_MAP[style.text_transform]) {
@@ -5351,7 +5360,13 @@ var AkariEditKernel = (() => {
5351
5360
  }
5352
5361
  if (finiteNumber(style.max_width_pct)) vars["--caption-line-max-width"] = `${style.max_width_pct}%`;
5353
5362
  if (finiteNumber(style.wrap_width_pct)) vars["--caption-wrap-width"] = `${style.wrap_width_pct}%`;
5354
- if (style.vertical) vars["--caption-writing-mode"] = "vertical-rl";
5363
+ if (style.vertical) {
5364
+ vars["--caption-writing-mode"] = "vertical-rl";
5365
+ vars["--caption-text-orientation"] = "upright";
5366
+ vars["--caption-width"] = "max-content";
5367
+ delete vars["--caption-line-max-width"];
5368
+ delete vars["--caption-wrap-width"];
5369
+ }
5355
5370
  if (extendedBackground && isRecord4(style.background)) {
5356
5371
  if (style.background.fit !== "frame") {
5357
5372
  vars["--plate-ext-width"] = percentageBackground ? `${style.background.width_pct ?? 0}%` : `${px(style.background.padding_px ?? 0)}px`;
@@ -5366,16 +5381,37 @@ var AkariEditKernel = (() => {
5366
5381
  const textShadow = captionTextShadowValue(style.shadow, style.glow, scale);
5367
5382
  if (textShadow !== null) vars["--caption-text-shadow"] = textShadow;
5368
5383
  Object.assign(vars, captionZoneVars(style.zone));
5369
- Object.assign(vars, captionAnchorPositionVars(style.text_anchor, style.position, style.vertical_align));
5384
+ Object.assign(vars, captionAnchorPositionVars(
5385
+ style.text_anchor,
5386
+ style.position,
5387
+ style.vertical ? void 0 : style.vertical_align
5388
+ ));
5389
+ if (style.vertical && style.vertical_align && !(isRecord4(style.position) && finiteNumber(style.position.x))) {
5390
+ vars["--caption-left"] = style.vertical_align === "top" ? "auto" : style.vertical_align === "middle" ? "50%" : "4%";
5391
+ vars["--caption-right"] = style.vertical_align === "top" ? "4%" : "auto";
5392
+ vars["--caption-align-items"] = style.vertical_align === "top" ? "flex-end" : style.vertical_align === "middle" ? "center" : "flex-start";
5393
+ if (style.vertical_align === "middle") vars["--caption-translate"] = "-50% 0";
5394
+ } else if (style.vertical && !style.vertical_align && !style.text_anchor && (!style.zone || style.zone === "bottom") && !(isRecord4(style.position) && finiteNumber(style.position.x))) {
5395
+ vars["--caption-left"] = "50%";
5396
+ vars["--caption-right"] = "auto";
5397
+ vars["--caption-align-items"] = "center";
5398
+ vars["--caption-translate"] = "-50% 0";
5399
+ }
5370
5400
  if (style.align) {
5371
5401
  vars["--caption-text-align"] = style.align;
5372
- vars["--caption-align-items"] = style.align === "left" ? "flex-start" : style.align === "right" ? "flex-end" : "center";
5373
5402
  }
5374
5403
  return vars;
5375
5404
  }
5376
5405
  function resolveCaptionLineStyleVars(style, output) {
5377
5406
  if (!isRecord4(style)) return {};
5378
- return resolveCaptionLineStyleVarsAtScale(style, resolveCaptionReferenceScale(style, output));
5407
+ const vars = resolveCaptionLineStyleVarsAtScale(style, resolveCaptionReferenceScale(style, output));
5408
+ Object.assign(vars, captionVerticalHeightVars(style, output));
5409
+ return vars;
5410
+ }
5411
+ function captionVerticalHeightVars(style, output) {
5412
+ if (style.vertical !== true || !output || !finitePositive2(output.height)) return {};
5413
+ const pct = finitePositive2(style.wrap_width_pct) ? style.wrap_width_pct : finitePositive2(style.max_width_pct) ? style.max_width_pct : 90;
5414
+ return { "--caption-vertical-max-height": `${formatCssNumber(output.height * pct / 100)}px` };
5379
5415
  }
5380
5416
  var CAPTION_TEXT_TRANSFORM_MAP = {
5381
5417
  upper: "uppercase",
@@ -5415,6 +5451,9 @@ var AkariEditKernel = (() => {
5415
5451
  const alpha = explicitOpacity ?? alphaFromColor;
5416
5452
  return `rgba(${parseInt(rgb.slice(0, 2), 16)},${parseInt(rgb.slice(2, 4), 16)},${parseInt(rgb.slice(4, 6), 16)},${Number(alpha.toFixed(4))})`;
5417
5453
  }
5454
+ function formatCssNumber(value) {
5455
+ return Number(value.toFixed(6)).toString();
5456
+ }
5418
5457
  function finiteNumber(value) {
5419
5458
  return typeof value === "number" && Number.isFinite(value);
5420
5459
  }
@@ -39,43 +39,88 @@ globalThis.akariHandleGeometry = (() => {
39
39
  return { x: anchor.x - c * ratioX * lx + s * ratioY * ly,
40
40
  y: anchor.y - s * ratioX * lx - c * ratioY * ly };
41
41
  }
42
- function snapBounds(moving, others, canvas, displayScale = 1, tolerance = 6) {
42
+ // 他の素材への吸着は、揃えとして意味のある組み合わせ(中央どうし・同じ辺どうし・隣接する辺)
43
+ // だけに絞る。辺と中央の交差まで候補にすると、小さい素材ほど近くに候補がひしめいて飛び移る。
44
+ // 添字は coordinates() の並び(0: 左/上, 1: 中央, 2: 右/下)。
45
+ const ITEM_PAIRS = new Set(['0:0', '1:1', '2:2', '0:2', '2:0']);
46
+ // 吸着距離(表示px)。合わせたい画面中央を一番強く、画面の端、他の素材の順に弱くする。
47
+ // tolerance は画面中央の距離(従来の 6px)。端と素材は options で上書きできる。
48
+ const CANVAS_EDGE_TOLERANCE = 4;
49
+ const ITEM_TOLERANCE = 3;
50
+ // 保持中の吸着先から乗り換えるのは、この差以上に近い候補が現れたとき(または、より近い画面の端・中央)。
51
+ const SWITCH_MARGIN = 2;
52
+ // options.previous: 直前に吸着していた先({x, y})。
53
+ // options.fast: 素早く動かしている最中(true か軸別の {x, y})。その軸では新しく吸着しない。
54
+ function snapBounds(moving, others, canvas, displayScale = 1, tolerance = 6, options = {}) {
55
+ const edgeTolerance = Number.isFinite(options.edgeTolerance) ? options.edgeTolerance
56
+ : Math.min(tolerance, CANVAS_EDGE_TOLERANCE);
57
+ const itemTolerance = Number.isFinite(options.itemTolerance) ? options.itemTolerance
58
+ : Math.min(tolerance, ITEM_TOLERANCE);
43
59
  const coordinates = (b, axis) => axis === 'x'
44
60
  ? [b.left, (b.left + b.right) / 2, b.right]
45
61
  : [b.top, (b.top + b.bottom) / 2, b.bottom];
46
62
  const pick = axis => {
47
63
  const own = coordinates(moving, axis);
64
+ const size = axis === 'x' ? canvas.width : canvas.height;
65
+ const previous = options.previous?.[axis] ?? null;
66
+ const fast = typeof options.fast === 'object' && options.fast !== null
67
+ ? Boolean(options.fast[axis]) : Boolean(options.fast);
48
68
  const targets = [
49
- ...[0, (axis === 'x' ? canvas.width : canvas.height) / 2, axis === 'x' ? canvas.width : canvas.height]
50
- .map(value => ({ value, kind: 'canvas', bounds: null })),
51
- ...others.flatMap(bounds => coordinates(bounds, axis).map(value => ({ value, kind: 'item', bounds })))
69
+ ...[0, size / 2, size].map((value, targetIndex) => ({ value, kind: 'canvas', bounds: null, targetIndex })),
70
+ ...others.flatMap(bounds => coordinates(bounds, axis)
71
+ .map((value, targetIndex) => ({ value, kind: 'item', bounds, targetIndex })))
52
72
  ];
53
- let best = null;
73
+ const limitFor = target => target.kind === 'item' ? itemTolerance
74
+ : target.targetIndex === 1 ? tolerance : edgeTolerance;
75
+ // いったん吸着した先は、その吸着距離を超えて離れるまで保つ。近くの別候補へ毎回
76
+ // 選び直すと、素早いドラッグで候補から候補へ飛び移って見える。
77
+ let held = null;
78
+ if (previous && Number.isInteger(previous.sourceIndex) && Number.isFinite(previous.target)) {
79
+ const kept = targets.find(target => target.kind === previous.kind
80
+ && Math.abs(target.value - previous.target) <= 1e-6);
81
+ const correction = previous.target - own[previous.sourceIndex];
82
+ if (kept && Number.isFinite(correction) && Math.abs(correction) * displayScale <= limitFor(kept)) {
83
+ held = { ...previous, correction, bounds: kept.bounds, distance: Math.abs(correction) * displayScale };
84
+ }
85
+ }
86
+ const finish = snap => snap && { ...snap, guide: guideFor(axis, moving, snap.bounds, canvas) };
87
+ if (fast) return finish(held);
88
+ const candidates = [];
54
89
  own.forEach((source, sourceIndex) => targets.forEach(target => {
90
+ if (target.kind === 'item' && !ITEM_PAIRS.has(sourceIndex + ':' + target.targetIndex)) return;
55
91
  const correction = target.value - source;
56
92
  const distance = Math.abs(correction) * displayScale;
57
- if (distance > tolerance) return;
58
- const visiblyEqual = best && Math.abs(distance - best.distance) <= .5;
59
- if (!best || distance < best.distance - .5 ||
60
- (visiblyEqual && target.kind === 'canvas' && best.kind !== 'canvas')) {
61
- best = { correction, target: target.value, sourceIndex, kind: target.kind,
62
- bounds: target.bounds, distance };
63
- }
93
+ if (distance > limitFor(target)) return;
94
+ candidates.push({ correction, target: target.value, sourceIndex, kind: target.kind,
95
+ bounds: target.bounds, distance });
64
96
  }));
65
- if (!best) return null;
66
- const other = best.bounds;
67
- best.guide = axis === 'x'
68
- ? { start: other ? Math.min(moving.top, other.top) : 0,
69
- end: other ? Math.max(moving.bottom, other.bottom) : canvas.height }
70
- : { start: other ? Math.min(moving.left, other.left) : 0,
71
- end: other ? Math.max(moving.right, other.right) : canvas.width };
72
- return best;
97
+ // 保持中でも、はっきり近い候補や、より近い画面の端・中央へは乗り換える
98
+ // (素材の辺に掴まったまま画面中央へ合わせられない、を防ぐ)。
99
+ const pool = held ? candidates.filter(candidate => candidate.distance + SWITCH_MARGIN <= held.distance
100
+ || (candidate.kind === 'canvas' && held.kind !== 'canvas' && candidate.distance < held.distance)) : candidates;
101
+ if (held && pool.length === 0) return finish(held);
102
+ let best = null;
103
+ for (const candidate of pool) {
104
+ const visiblyEqual = best && Math.abs(candidate.distance - best.distance) <= .5;
105
+ if (!best || candidate.distance < best.distance - .5 ||
106
+ (visiblyEqual && candidate.kind === 'canvas' && best.kind !== 'canvas')) best = candidate;
107
+ }
108
+ return finish(best);
73
109
  };
74
110
  return { x: pick('x'), y: pick('y') };
75
111
  }
76
- function snapEndpoint(point, others, canvas, displayScale = 1, tolerance = 6) {
112
+ function guideFor(axis, moving, other, canvas) {
113
+ return axis === 'x'
114
+ ? { start: other ? Math.min(moving.top, other.top) : 0,
115
+ end: other ? Math.max(moving.bottom, other.bottom) : canvas.height }
116
+ : { start: other ? Math.min(moving.left, other.left) : 0,
117
+ end: other ? Math.max(moving.right, other.right) : canvas.width };
118
+ }
119
+ function snapEndpoint(point, others, canvas, displayScale = 1, tolerance = 6, options = {}) {
120
+ // 線の端点は 1 点(左・中央・右が同じ値)なので、組み合わせを絞っても相手のどの辺・中央へも届く。
121
+ // 端点を相手の角へ正確に置く用途のため、吸着距離は従来どおり画面の端・中央と同じにする。
77
122
  return snapBounds({ left: point.x, right: point.x, top: point.y, bottom: point.y },
78
- others, canvas, displayScale, tolerance);
123
+ others, canvas, displayScale, tolerance, { itemTolerance: tolerance, edgeTolerance: tolerance, ...options });
79
124
  }
80
125
  function solveLineEndpoint(fixed, pointer, others, canvas, displayScale = 1, disabled = false,
81
126
  anglePointer = pointer) {
@@ -1338,7 +1338,7 @@ function marqueeHits(candidates, rect) {
1338
1338
  startClientX: event.clientX, startClientY: event.clientY,
1339
1339
  startStagePoint: stageLocalPoint(event.clientX, event.clientY),
1340
1340
  startX: world.x ?? 0, startY: world.y ?? 0, dx: 0, dy: 0,
1341
- snapX: null, snapY: null, moved: false, duplicate: event.altKey,
1341
+ snapX: null, snapY: null, snapMotion: beginSnapMotion(fragmentVideoBounds(container)), moved: false, duplicate: event.altKey,
1342
1342
  writeContext: captureWriteContext() };
1343
1343
  try { container.setPointerCapture?.(event.pointerId); } catch { /* synthetic pointer */ }
1344
1344
  }
@@ -1445,7 +1445,8 @@ function marqueeHits(candidates, rect) {
1445
1445
  if (disabled || !tl || !br) { drag.snapX = null; drag.snapY = null; hideSnapGuides(); return; }
1446
1446
  const bounds = { left: tl.x, top: tl.y, right: br.x, bottom: br.y,
1447
1447
  centerX: (tl.x + br.x) / 2, centerY: (tl.y + br.y) / 2 };
1448
- const snap = computeSnapCorrection(bounds, { x: drag.snapX, y: drag.snapY });
1448
+ const snap = computeSnapCorrection(bounds, { x: drag.snapX, y: drag.snapY, motion: drag.snapMotion });
1449
+ drag.snapMotion = snap.motion;
1449
1450
  if (lockedAxis === 'x') snap.y = null;
1450
1451
  if (lockedAxis === 'y') snap.x = null;
1451
1452
  drag.snapX = snap.x; drag.snapY = snap.y;
@@ -1856,6 +1857,36 @@ function marqueeHits(candidates, rect) {
1856
1857
  // {left,top,right,bottom,centerX,centerY} から計算する。overlays のドラッグに限らず、
1857
1858
  // resize・layers[]・cut/caption のドラッグからも共通で呼べるよう window.akari.interaction
1858
1859
  // 経由でも公開する(㉒ スナップ統一の単一正本)。
1860
+ // 素早く動かしている最中は、その軸で新しく吸着しない(通り過ぎる候補ごとにカクッと引っかかって、
1861
+ // 掴んだ素材がジャンプしたように見えるため)。速さは動かしている素材の中心の表示px/ms を軸ごとに、
1862
+ // 時間基準の移動平均でならして測る。止める閾値と戻す閾値を分けて、境目でばたつかないようにする。
1863
+ // 状態は吸着結果(motion)に載せて返し、呼び出し元が次回の previousSnap として渡す = ドラッグごとに閉じる。
1864
+ const FAST_SNAP_START_SPEED = 0.6;
1865
+ const FAST_SNAP_END_SPEED = 0.35;
1866
+ const SNAP_SPEED_SMOOTHING_MS = 50;
1867
+ function nextSnapMotion(bounds, previous) {
1868
+ const now = performance.now();
1869
+ const x = (bounds.left + bounds.right) / 2;
1870
+ const y = (bounds.top + bounds.bottom) / 2;
1871
+ if (!previous || !Number.isFinite(previous.at)) {
1872
+ return { at: now, x, y, speedX: 0, speedY: 0, fastX: false, fastY: false };
1873
+ }
1874
+ const elapsed = now - previous.at;
1875
+ if (!(elapsed > 0)) return previous;
1876
+ const scale = currentDisplayScale();
1877
+ const alpha = 1 - Math.exp(-elapsed / SNAP_SPEED_SMOOTHING_MS);
1878
+ const speedX = previous.speedX + alpha * (Math.abs(x - previous.x) * scale / elapsed - previous.speedX);
1879
+ const speedY = previous.speedY + alpha * (Math.abs(y - previous.y) * scale / elapsed - previous.speedY);
1880
+ return { at: now, x, y, speedX, speedY,
1881
+ fastX: previous.fastX ? speedX > FAST_SNAP_END_SPEED : speedX > FAST_SNAP_START_SPEED,
1882
+ fastY: previous.fastY ? speedY > FAST_SNAP_END_SPEED : speedY > FAST_SNAP_START_SPEED };
1883
+ }
1884
+
1885
+ // ドラッグ開始時(pointerdown)の位置で速度の起点を作る。最初の move から実速度で判定できる。
1886
+ function beginSnapMotion(bounds) {
1887
+ return bounds ? nextSnapMotion(bounds, null) : null;
1888
+ }
1889
+
1859
1890
  function computeSnapCorrection(bounds, previousSnap) {
1860
1891
  if (!bounds) return { x: null, y: null };
1861
1892
  if (!globalThis.akariHandleGeometry) {
@@ -1865,10 +1896,15 @@ function marqueeHits(candidates, rect) {
1865
1896
  y: closestAxisSnap([bounds.top, bounds.centerY, bounds.bottom], targets.y,
1866
1897
  previousSnap?.y ?? null, currentDisplayScale()) };
1867
1898
  }
1899
+ // 動かしている素材自身(単体選択・グループ/複数選択のメンバー)は吸着先にしない。
1900
+ const moving = new Set([selectedOverlay, ...selectionMembers()].filter(Boolean));
1868
1901
  const others = stage ? Array.from(stage.children)
1869
- .filter(element => isSelectable(element) && element !== selectedOverlay)
1902
+ .filter(element => isSelectable(element) && !moving.has(element))
1870
1903
  .map(fragmentVideoBounds).filter(Boolean) : [];
1871
- return globalThis.akariHandleGeometry.snapBounds(bounds, others, outputSize(), currentDisplayScale());
1904
+ const motion = nextSnapMotion(bounds, previousSnap?.motion);
1905
+ const snap = globalThis.akariHandleGeometry.snapBounds(bounds, others, outputSize(), currentDisplayScale(), 6,
1906
+ { previous: previousSnap, fast: { x: motion.fastX, y: motion.fastY } });
1907
+ return { ...snap, motion };
1872
1908
  }
1873
1909
 
1874
1910
  function applyDragSnapping(drag, rawX, rawY, disabled, lockedAxis = null) {
@@ -1890,7 +1926,8 @@ function marqueeHits(candidates, rect) {
1890
1926
  return;
1891
1927
  }
1892
1928
 
1893
- const snap = computeSnapCorrection(bounds, { x: drag.snapX, y: drag.snapY });
1929
+ const snap = computeSnapCorrection(bounds, { x: drag.snapX, y: drag.snapY, motion: drag.snapMotion });
1930
+ drag.snapMotion = snap.motion;
1894
1931
  if (lockedAxis === 'x') snap.y = null;
1895
1932
  if (lockedAxis === 'y') snap.x = null;
1896
1933
  drag.snapX = snap.x;
@@ -2859,6 +2896,7 @@ function marqueeHits(candidates, rect) {
2859
2896
  startY: transform.y,
2860
2897
  snapX: null,
2861
2898
  snapY: null,
2899
+ snapMotion: beginSnapMotion(fragmentVideoBounds(container)),
2862
2900
  moved: false,
2863
2901
  duplicate: event.altKey,
2864
2902
  motionDriven,
@@ -360,7 +360,7 @@ function validateTextStyle(value, label) {
360
360
  // 正本は captions.schema.json の $defs/textStyle。
361
361
  const allowedKeys = new Set([
362
362
  "color", "size_px", "font_weight", "line_height", "stroke", "background", "zone", "layout",
363
- "font_family", "weight", "italic", "underline", "letter_spacing_em", "align",
363
+ "font_family", "weight", "italic", "underline", "strikethrough", "list", "opacity", "letter_spacing_em", "align",
364
364
  "vertical_align", "vertical", "text_transform", "max_width_pct", "wrap_width_pct", "max_characters", "text_anchor",
365
365
  "position", "scale", "rotate", "shadow", "glow", "animation", "reference_height_px",
366
366
  ]);
@@ -411,6 +411,16 @@ function validateTextStyleV0(value, label) {
411
411
  for (const key of ["italic", "underline", "vertical"]) {
412
412
  if (hasOwn(value, key) && typeof value[key] !== "boolean") fail(`${label}.${key} は boolean である必要があります`);
413
413
  }
414
+ if (hasOwn(value, "strikethrough") && typeof value.strikethrough !== "boolean") {
415
+ fail(`${label}.strikethrough は boolean である必要があります`);
416
+ }
417
+ if (hasOwn(value, "list") && value.list !== "bullet" && value.list !== null) {
418
+ fail(`${label}.list は bullet または null である必要があります`);
419
+ }
420
+ if (hasOwn(value, "opacity")
421
+ && (!isFiniteNumber(value.opacity) || value.opacity < 0 || value.opacity > 1)) {
422
+ fail(`${label}.opacity は 0 から 1 の範囲の有限数である必要があります`);
423
+ }
414
424
  if (hasOwn(value, "letter_spacing_em") && !isFiniteNumber(value.letter_spacing_em)) {
415
425
  fail(`${label}.letter_spacing_em は有限数である必要があります`);
416
426
  }
@@ -219,6 +219,9 @@
219
219
  "weight": { "type": "integer", "minimum": 100, "maximum": 900 },
220
220
  "italic": { "type": "boolean" },
221
221
  "underline": { "type": "boolean" },
222
+ "strikethrough": { "type": "boolean" },
223
+ "list": { "enum": ["bullet", null] },
224
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1 },
222
225
  "letter_spacing_em": { "type": "number" },
223
226
  "line_height": { "type": "number", "exclusiveMinimum": 0 },
224
227
  "align": { "type": "string", "enum": ["left", "center", "right"] },
@@ -0,0 +1,12 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const schema = JSON.parse(readFileSync(new URL('../captions.schema.json', import.meta.url), 'utf8'));
6
+
7
+ test('字幕 style v2 の取り消し線・箇条書き・透明度はスキーマにある', () => {
8
+ const style = schema.$defs.textStyle.properties;
9
+ assert.deepEqual(style.strikethrough, { type: 'boolean' });
10
+ assert.deepEqual(style.list, { enum: ['bullet', null] });
11
+ assert.deepEqual(style.opacity, { type: 'number', minimum: 0, maximum: 1 });
12
+ });