akari-video 1.3.0-beta.2 → 1.3.0-beta.4

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": "1.3.0-beta.2",
3
+ "version": "1.3.0-beta.4",
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": "1.3.0-beta.2",
3
+ "version": "1.3.0-beta.4",
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": [
@@ -422,6 +422,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
422
422
  let selectedId = null;
423
423
  let selectedIds = [];
424
424
  let elementFocus = null;
425
+ // Inner selection belongs only to the currently selected telop, never to edit.json.
426
+ let telopInnerId = null;
425
427
  let elementGeometryCache = null;
426
428
  let elementNudge = null;
427
429
  let elementNudgeTimer = null;
@@ -1819,8 +1821,11 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1819
1821
 
1820
1822
  function clearSelection() {
1821
1823
  const hadElementFocus = window.akari.capabilities?.elementSelection === true && Boolean(selectedElementFocus());
1824
+ const hadTelopSelection = window.akari.capabilities?.elementSelection === true
1825
+ && selectedOverlay && isTelopOverlay(selectedOverlay);
1822
1826
  flushElementNudge();
1823
1827
  elementFocus = null;
1828
+ telopInnerId = null;
1824
1829
  if (activeRotate) cancelRotate();
1825
1830
  if (activeLine) finishLineEndpoint(true);
1826
1831
  flushNudge();
@@ -1839,7 +1844,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1839
1844
  selectionFrame?.remove();
1840
1845
  selectionFrame = null;
1841
1846
  hideSnapGuides();
1842
- if (hadElementFocus) renderScopeBreadcrumb();
1847
+ if (hadElementFocus || hadTelopSelection) renderScopeBreadcrumb();
1843
1848
  }
1844
1849
 
1845
1850
  function handleSelectedOverlayUnavailable(container) {
@@ -1879,24 +1884,41 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1879
1884
  selectedOverlay.setAttribute('data-akari-interaction-selected', 'true');
1880
1885
  reconcileElementFocus();
1881
1886
  } else {
1887
+ const keepTelopInner = selectedId === nextId && telopInnerId === nextId
1888
+ && selectedIds.length === 1 && !groupSelection;
1882
1889
  clearSelection();
1883
1890
  selectedOverlay = container;
1884
1891
  selectedId = nextId;
1885
1892
  selectedIds = selectedId === null ? [] : [selectedId];
1893
+ if (keepTelopInner) telopInnerId = nextId;
1886
1894
  selectedOverlay.setAttribute("data-akari-interaction-selected", "true");
1887
1895
  }
1888
1896
  }
1889
1897
 
1890
1898
  refreshSelectionFrame();
1899
+ if (window.akari.capabilities?.elementSelection === true && isTelopOverlay(selectedOverlay)) renderScopeBreadcrumb();
1891
1900
  startSelectionTracking();
1892
1901
  return true;
1893
1902
  }
1894
1903
 
1895
- function canFocusElement(container) {
1904
+ function elementSelectionEnabled(container) {
1896
1905
  if (window.akari.capabilities?.elementSelection !== true || !container || selectedIds.length > 1) return false;
1897
1906
  return window.akari.state?.summary?.overlays?.some(overlay => overlay.id === container.dataset.overlayId
1898
1907
  && overlay.elementSelection === true) === true;
1899
1908
  }
1909
+ function canFocusElement(container) {
1910
+ return elementSelectionEnabled(container)
1911
+ && (!isTelopOverlay(container) || telopInnerId === selectedId && selectedId === container.dataset.overlayId);
1912
+ }
1913
+ function setTelopInnerSelection(overlayId, inner) {
1914
+ if (typeof overlayId !== 'string' || selectedId !== overlayId || selectedIds.length !== 1
1915
+ || groupSelection || !selectedOverlay || !isTelopOverlay(selectedOverlay)
1916
+ || !elementSelectionEnabled(selectedOverlay)) return false;
1917
+ telopInnerId = inner === true ? overlayId : null;
1918
+ if (!telopInnerId && selectedElementFocus()) focusElement(null);
1919
+ hideHover();
1920
+ return true;
1921
+ }
1900
1922
  function focusedElement() {
1901
1923
  if (!selectedElementFocus() || !selectedOverlay) return null;
1902
1924
  const root = fragmentRoot(selectedOverlay);
@@ -1929,6 +1951,7 @@ function elementBoxCompanions(style, parentStyle, width, height) {
1929
1951
  const ref = element && elementAddress(root, element);
1930
1952
  const next = ref ? { overlayId: selectedId, ref, tag: element.tagName.toLowerCase(),
1931
1953
  label: elementLabel(root, element) } : null;
1954
+ if (!next && telopInnerId === selectedId) telopInnerId = null;
1932
1955
  if (elementFocus?.ref === next?.ref && elementFocus?.overlayId === next?.overlayId) return;
1933
1956
  flushElementNudge();
1934
1957
  elementFocus = next;
@@ -2063,7 +2086,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
2063
2086
  clearBreadcrumbHover();
2064
2087
  const entries = [];
2065
2088
  const append = (label, action, target) => entries.push({ label, action, target });
2066
- if (selectedElementFocus() && selectedOverlay) {
2089
+ if (selectedOverlay && (selectedElementFocus() || window.akari.capabilities?.elementSelection === true
2090
+ && selectedIds.length === 1 && isTelopOverlay(selectedOverlay))) {
2067
2091
  append('全体', () => { clearSelection(); publishScopedSelection(); }, { kind: 'stage' });
2068
2092
  for (const id of lineage(selectionTree(), scopeId)) {
2069
2093
  append(treeNode(id)?.label ?? id, () => applyScopedSelection({ selectId: id, scopeId: treeNode(id)?.parentId ?? null }),
@@ -4383,7 +4407,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4383
4407
  if (activeEdit?.container === hit && eventHitsElement(event, activeEdit.element)) return;
4384
4408
  clickOrigin.scopedHit = true;
4385
4409
  if (activeEdit) void commitEdit();
4386
- if (window.akari.capabilities?.elementSelection === true && event.isTrusted && !event.shiftKey) {
4410
+ if (window.akari.capabilities?.elementSelection === true && event.isTrusted && !event.shiftKey
4411
+ && (!isTelopOverlay(hit) || canFocusElement(hit))) {
4387
4412
  const root = fragmentRoot(hit);
4388
4413
  const target = event.target instanceof Element && root?.contains(event.target)
4389
4414
  ? nearestSelectableElement(root, event.target, stage?.getBoundingClientRect(), selectionOptions(root)) : null;
@@ -4404,7 +4429,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
4404
4429
  const focused = focusedElement();
4405
4430
  if (focused && isSelectionContainer(focused) && focused.contains(event.target)) {
4406
4431
  clickOrigin.preservedContainer = true;
4407
- } else if (clickOrigin?.hitId === selectedId) {
4432
+ } else if ((!isTelopOverlay(selectedOverlay) || canFocusElement(selectedOverlay))
4433
+ && clickOrigin?.hitId === selectedId) {
4408
4434
  focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
4409
4435
  } else focusElementAt(selectedOverlay, event);
4410
4436
  } else if (selectedElementFocus()) focusElement(null);
@@ -5209,7 +5235,9 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5209
5235
  if (window.akari.capabilities?.elementSelection === true
5210
5236
  && event.isTrusted && !event.shiftKey && selectedOverlay) {
5211
5237
  if (clickOrigin?.preservedContainer) focusElementAt(selectedOverlay, event);
5212
- else if (!nextId && clickOrigin?.hitId === selectedId) {
5238
+ else if ((!isTelopOverlay(selectedOverlay) || canFocusElement(selectedOverlay))
5239
+ && (!nextId || isTelopOverlay(selectedOverlay) && telopInnerId === selectedId
5240
+ && nextId === selectedId) && clickOrigin?.hitId === selectedId) {
5213
5241
  focusElement(elementByAddress(fragmentRoot(selectedOverlay), clickOrigin.elementRef));
5214
5242
  } else focusElementAt(selectedOverlay, event);
5215
5243
  }
@@ -5288,10 +5316,12 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5288
5316
  }
5289
5317
  }
5290
5318
  if (event.key === 'Enter' && !event.shiftKey && !activeEdit && !selectedElementFocus()
5291
- && selectedOverlay && canFocusElement(selectedOverlay)) {
5319
+ && selectedOverlay && elementSelectionEnabled(selectedOverlay)
5320
+ && (!isTelopOverlay(selectedOverlay) || selectedIds.length === 1)) {
5292
5321
  const root = fragmentRoot(selectedOverlay);
5293
5322
  const first = firstSelectableElement(root, stage?.getBoundingClientRect(), selectionOptions(root));
5294
5323
  if (first) {
5324
+ if (isTelopOverlay(selectedOverlay)) telopInnerId = selectedId;
5295
5325
  focusElement(first);
5296
5326
  event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation();
5297
5327
  return;
@@ -5339,7 +5369,8 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5339
5369
  if (groupSelection) {
5340
5370
  applyScopedSelection(enterScope(selectionTree(), selectedId)); handled(); return;
5341
5371
  }
5342
- if (selectedOverlay) {
5372
+ if (selectedOverlay && (!isTelopOverlay(selectedOverlay)
5373
+ || telopInnerId === selectedId && Boolean(selectedElementFocus()))) {
5343
5374
  const root = fragmentRoot(selectedOverlay);
5344
5375
  const text = root && [root, ...root.querySelectorAll('*')].find(canEditText);
5345
5376
  if (text) { beginEdit(selectedOverlay, text); handled(); }
@@ -5827,6 +5858,9 @@ function elementBoxCompanions(style, parentStyle, width, height) {
5827
5858
  releasePointerOwner,
5828
5859
  get selectedId() { return selectedId; },
5829
5860
  get elementFocus() { return selectedElementFocus(); },
5861
+ get selectedTelop() { return Boolean(selectedOverlay && selectedIds.length === 1 && isTelopOverlay(selectedOverlay)); },
5862
+ get telopInner() { return telopInnerId !== null && telopInnerId === selectedId; },
5863
+ setTelopInnerSelection,
5830
5864
  clearElementFocus() { if (selectedElementFocus()) focusElement(null, { notify: false }); },
5831
5865
  focusElementAtPoint,
5832
5866
  get selectedIds() { return [...selectedIds]; },
@@ -43,6 +43,7 @@ window.akari.threeRuntime = (() => {
43
43
  "lights",
44
44
  "animationClip",
45
45
  "materialOverrides",
46
+ "finishes",
46
47
  "shadows",
47
48
  "texts",
48
49
  "physics",
@@ -136,6 +137,56 @@ window.akari.threeRuntime = (() => {
136
137
  const VIDEO_TEXTURE_PATTERN = /^data:video\/|\.(?:mp4|m4v|mov|webm)(?:[?#]|$)/i;
137
138
  const CSS_VAR_REFERENCE_PATTERN = /^var\(\s*(--[\w-]+)\s*\)$/;
138
139
  const CSS_CUSTOM_PROPERTY_PATTERN = /^--[A-Za-z_][A-Za-z0-9_-]*$/;
140
+ const FINISH_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
141
+ const FINISH_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
142
+ const FINISH_PROPERTIES = new Set([
143
+ "color", "metalness", "roughness", "clearcoat", "clearcoatRoughness", "emissive",
144
+ ]);
145
+
146
+ function validateFinishes(finishes) {
147
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
148
+ if (!isObject(finishes) || Object.keys(finishes).some((key) => !["var", "default", "options"].includes(key))) {
149
+ throw new TypeError("finishes は var / default / options を指定する object である必要があります");
150
+ }
151
+ if (finishes.var !== undefined
152
+ && (typeof finishes.var !== "string" || !CSS_CUSTOM_PROPERTY_PATTERN.test(finishes.var))) {
153
+ throw new TypeError("finishes.var は -- で始まる CSS カスタムプロパティ名である必要があります");
154
+ }
155
+ if (typeof finishes.default !== "string" || !FINISH_NAME_PATTERN.test(finishes.default)) {
156
+ throw new TypeError("finishes.default は有効な選択名である必要があります");
157
+ }
158
+ if (!isObject(finishes.options) || Object.keys(finishes.options).length === 0) {
159
+ throw new TypeError("finishes.options は非空の object である必要があります");
160
+ }
161
+ for (const [name, materials] of Object.entries(finishes.options)) {
162
+ if (!FINISH_NAME_PATTERN.test(name)) {
163
+ throw new TypeError(`finishes.options の選択名が不正です: ${name}`);
164
+ }
165
+ if (!isObject(materials)) {
166
+ throw new TypeError(`finishes.options.${name} は材質名をキーとする object である必要があります`);
167
+ }
168
+ for (const [materialName, values] of Object.entries(materials)) {
169
+ if (!materialName.trim() || !isObject(values)) {
170
+ throw new TypeError(`finishes.options.${name} の材質指定が不正です: ${materialName}`);
171
+ }
172
+ for (const [key, value] of Object.entries(values)) {
173
+ if (!FINISH_PROPERTIES.has(key)) {
174
+ throw new TypeError(`finishes.options.${name}.${materialName} の未対応キーです: ${key}`);
175
+ }
176
+ if (key === "color" || key === "emissive") {
177
+ if (typeof value !== "string" || !FINISH_COLOR_PATTERN.test(value)) {
178
+ throw new TypeError(`finishes.options.${name}.${materialName}.${key} は 6 桁 hex 色である必要があります`);
179
+ }
180
+ } else if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
181
+ throw new TypeError(`finishes.options.${name}.${materialName}.${key} は 0〜1 の有限数である必要があります`);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ if (!Object.hasOwn(finishes.options, finishes.default)) {
187
+ throw new TypeError("finishes.default は finishes.options 内の選択名である必要があります");
188
+ }
189
+ }
139
190
 
140
191
  function finiteNumber(value, fallback) {
141
192
  const number = Number(value);
@@ -570,6 +621,7 @@ window.akari.threeRuntime = (() => {
570
621
  throw new TypeError(`data-akari-3d-scene の未対応キーです: ${key}`);
571
622
  }
572
623
  }
624
+ if (descriptor.finishes !== undefined) validateFinishes(descriptor.finishes);
573
625
  // texts[] があれば model は任意化される(両方あれば併存。§3.1)
574
626
  const hasTexts = descriptor.texts !== undefined;
575
627
  if (hasTexts) {
@@ -946,6 +998,102 @@ window.akari.threeRuntime = (() => {
946
998
  }));
947
999
  }
948
1000
 
1001
+ function initializeFinishes(instance, root) {
1002
+ if (!instance.finish) return;
1003
+ const materialsByName = new Map();
1004
+ root.traverse((object) => {
1005
+ const materials = Array.isArray(object.material) ? object.material : object.material ? [object.material] : [];
1006
+ for (const material of materials) {
1007
+ if (!materialsByName.has(material.name)) materialsByName.set(material.name, new Set());
1008
+ materialsByName.get(material.name).add(material);
1009
+ }
1010
+ });
1011
+ // materialOverrides 適用後の値を素の値として記録し、finishes は指定キーだけ上書きする。
1012
+ // emissiveMap / emissiveIntensity は記録対象にも上書き対象にも含めず保持する。
1013
+ for (const materials of materialsByName.values()) {
1014
+ for (const material of materials) {
1015
+ instance.finish.base.set(material, {
1016
+ color: material.color?.clone(),
1017
+ metalness: material.metalness,
1018
+ roughness: material.roughness,
1019
+ clearcoat: material.clearcoat,
1020
+ clearcoatRoughness: material.clearcoatRoughness,
1021
+ emissive: material.emissive?.clone(),
1022
+ });
1023
+ }
1024
+ }
1025
+ instance.finish.materialsByName = materialsByName;
1026
+ applyFinish(instance);
1027
+ }
1028
+
1029
+ function applyFinish(instance) {
1030
+ const state = instance.finish;
1031
+ if (!state || !state.materialsByName) return;
1032
+ const { descriptor } = state;
1033
+ const raw = getComputedStyle(instance.canvas).getPropertyValue(descriptor.var ?? "--akari-3d-finish").trim();
1034
+ const requested = raw.replace(/^(['"])(.*)\1$/, "$2");
1035
+ const selected = requested === "" ? descriptor.default
1036
+ : Object.hasOwn(descriptor.options, requested) ? requested : descriptor.default;
1037
+ if (requested !== "" && !Object.hasOwn(descriptor.options, requested)
1038
+ && !state.warnedValues.has(requested)) {
1039
+ state.warnedValues.add(requested);
1040
+ console.warn(`[akari-three] finishes の未知の選択値です: ${requested}。${descriptor.default} を使います`);
1041
+ }
1042
+ if (state.selected === selected) return;
1043
+
1044
+ const oldCoats = new Map();
1045
+ for (const [material, base] of state.base) {
1046
+ oldCoats.set(material, material.clearcoat);
1047
+ if (base.color && material.color) material.color.copy(base.color);
1048
+ if (base.emissive && material.emissive) material.emissive.copy(base.emissive);
1049
+ for (const key of ["metalness", "roughness", "clearcoat", "clearcoatRoughness"]) {
1050
+ if (base[key] !== undefined && key in material) material[key] = base[key];
1051
+ }
1052
+ }
1053
+ const applied = [];
1054
+ for (const [name, values] of Object.entries(descriptor.options[selected])) {
1055
+ const materials = state.materialsByName.get(name);
1056
+ if (!materials?.size) {
1057
+ const warningKey = JSON.stringify([selected, name]);
1058
+ if (!state.warnedMaterials.has(warningKey)) {
1059
+ state.warnedMaterials.add(warningKey);
1060
+ console.warn(`[akari-three] finishes の対象が見つかりません: ${name}(選択: ${selected})`);
1061
+ }
1062
+ applied.push({ material: name, applied: false });
1063
+ continue;
1064
+ }
1065
+ for (const material of materials) {
1066
+ for (const [key, value] of Object.entries(values)) {
1067
+ if (!(key in material) || ((key === "color" || key === "emissive") && !material[key]?.set)) {
1068
+ const warningKey = `${material.uuid}:${key}`;
1069
+ if (!state.warnedProperties.has(warningKey)) {
1070
+ state.warnedProperties.add(warningKey);
1071
+ console.warn(`[akari-three] finishes の ${name}.${key} はこの材質に適用できません`);
1072
+ }
1073
+ continue;
1074
+ }
1075
+ if (key === "color" || key === "emissive") material[key].set(value);
1076
+ else material[key] = value;
1077
+ }
1078
+ applied.push({
1079
+ material: name, applied: true,
1080
+ color: material.color ? `#${material.color.getHexString()}` : null,
1081
+ metalness: material.metalness ?? null,
1082
+ roughness: material.roughness ?? null,
1083
+ clearcoat: material.clearcoat ?? null,
1084
+ clearcoatRoughness: material.clearcoatRoughness ?? null,
1085
+ emissive: material.emissive ? `#${material.emissive.getHexString()}` : null,
1086
+ });
1087
+ }
1088
+ }
1089
+ // clearcoat の有無だけがシェーダ定義を変えうる。色・係数の通常変更では再コンパイルしない。
1090
+ for (const [material, oldCoat] of oldCoats) {
1091
+ if ((oldCoat > 0) !== (material.clearcoat > 0)) material.needsUpdate = true;
1092
+ }
1093
+ state.selected = selected;
1094
+ state.applied = applied;
1095
+ }
1096
+
949
1097
  function createCamera(THREE, descriptor) {
950
1098
  const cameraDescriptor = descriptor.camera ?? {};
951
1099
  const camera = new THREE.PerspectiveCamera(
@@ -2017,6 +2165,7 @@ window.akari.threeRuntime = (() => {
2017
2165
  if (!instance.active || !instance.contentReady) return;
2018
2166
  rendererSize(instance, instance.maxRenderSize);
2019
2167
  applyProjectionKnobs(instance);
2168
+ applyFinish(instance);
2020
2169
  if (instance.mixer) instance.mixer.setTime(Math.max(0, localSeconds));
2021
2170
  if (instance.textAnimEntries.length > 0) updateTextVisibility(instance, localSeconds);
2022
2171
  if (instance.textAnimEntries.length > 0) updateTextAnimations(instance, localSeconds);
@@ -2379,6 +2528,16 @@ window.akari.threeRuntime = (() => {
2379
2528
  physicsPresimMs: null,
2380
2529
  // materialOverrides の解決結果(inspect() から検証・証跡に使う)。
2381
2530
  materialOverrideReport: [],
2531
+ finish: descriptor.finishes ? {
2532
+ descriptor: descriptor.finishes,
2533
+ selected: null,
2534
+ applied: [],
2535
+ base: new Map(),
2536
+ materialsByName: null,
2537
+ warnedValues: new Set(),
2538
+ warnedMaterials: new Set(),
2539
+ warnedProperties: new Set(),
2540
+ } : null,
2382
2541
  // model 読み込み(宣言時のみ)+ 全 texts sync() 完了の両方が揃うまで false。
2383
2542
  // draw() はこれを ready 条件に使う(読み込み中フレームが書き出しに混入しないため)
2384
2543
  contentReady: false,
@@ -2410,6 +2569,9 @@ window.akari.threeRuntime = (() => {
2410
2569
  }
2411
2570
  instance.model = gltf.scene;
2412
2571
  await applyMaterialOverrides(THREE, instance, gltf.scene, descriptor.materialOverrides);
2572
+ // materialOverrides を先に適用し、その結果を素の値として finishes が指定キーだけ上書きする。
2573
+ // emissiveMap / emissiveIntensity は materialOverrides、emissive 色は finishes の担当。
2574
+ initializeFinishes(instance, gltf.scene);
2413
2575
  if (descriptor.environment?.map) {
2414
2576
  await applyEnvironmentMap(THREE, instance, descriptor.environment.map);
2415
2577
  }
@@ -2623,6 +2785,8 @@ window.akari.threeRuntime = (() => {
2623
2785
  if (rows.has(material.uuid)) continue;
2624
2786
  rows.set(material.uuid, {
2625
2787
  name: material.name, type: material.type, metalness: material.metalness,
2788
+ color: material.color ? `#${material.color.getHexString()}` : null,
2789
+ emissive: material.emissive ? `#${material.emissive.getHexString()}` : null,
2626
2790
  roughness: material.roughness, specularIntensity: material.specularIntensity,
2627
2791
  clearcoat: material.clearcoat, clearcoatRoughness: material.clearcoatRoughness,
2628
2792
  emissiveIntensity: material.emissiveIntensity,
@@ -2633,6 +2797,12 @@ window.akari.threeRuntime = (() => {
2633
2797
  })(),
2634
2798
  // materialOverrides の CSS 変数解決・適用結果(検証・証跡用)。
2635
2799
  materialOverrides: instance.materialOverrideReport.map((entry) => ({ ...entry })),
2800
+ finish: instance.finish ? {
2801
+ var: instance.finish.descriptor.var ?? "--akari-3d-finish",
2802
+ selected: instance.finish.selected,
2803
+ default: instance.finish.descriptor.default,
2804
+ applied: instance.finish.applied.map((entry) => ({ ...entry })),
2805
+ } : null,
2636
2806
  // texts[] の per-char 展開数(検証・証跡用。flat モードの読み込み完了を絵の比較なしに確認する)
2637
2807
  textNodes: instance.textNodes.length,
2638
2808
  textBlocks: instance.textAnimEntries.length,
@@ -87,7 +87,7 @@ fragment は単一ルートとし、透明 canvas、任意の静的 fallback、
87
87
  </div>
88
88
  ```
89
89
 
90
- - top-level key は `model` / `camera` / `environment` / `fog` / `background` / `lights` / `animationClip` / `materialOverrides` / `shadows` / `texts` / `physics` だけ。`texts[]` があれば `model` は任意(詳細は後述「texts[] — 3D テキスト」節)。`physics` は `texts[]` の物理落下(後述「physics — 3D テキストの物理落下」節)。
90
+ - top-level key は `model` / `camera` / `environment` / `fog` / `background` / `lights` / `animationClip` / `materialOverrides` / `finishes` / `shadows` / `texts` / `physics` だけ。`texts[]` があれば `model` は任意(詳細は後述「texts[] — 3D テキスト」節)。`physics` は `texts[]` の物理落下(後述「physics — 3D テキストの物理落下」節)。
91
91
  - `model` は edit.json のあるディレクトリからの相対 `.glb` パス。URL や CDN を書かない。
92
92
  - camera は perspective camera。`position` / `lookAt` は `[x, y, z]`、`fov` / `near` / `far` は数値。
93
93
  または `{ "fromModel": "<glb 内カメラノード名>" }` で glb 内カメラを描画に使う(§カメラを動かす)。
@@ -120,7 +120,7 @@ fragment は単一ルートとし、透明 canvas、任意の静的 fallback、
120
120
  `"*"` も指定できる。省略時は静的シーンを描画する。Blender の glTF 書き出しはオブジェクトごとに
121
121
  clip を分けるので、1 個のモデルに複数の動きがあるときは `"*"` で束ねる(1 本しか再生しないと
122
122
  片方しか動かない)。存在しない clip 名を書いた場合はエラーになる。
123
- - `materialOverrides` は `{ "<material 名>": { "texture": "<画像または動画の相対パス>" } }` の形で、名前が一致するマテリアルの `emissiveMap` を差し替える。**差し替え先が `emissiveMap` である以上、貼り先の材質が発光しない(glTF の `emissiveFactor` が未設定 = 黒)と「0 × テクスチャ」で何も出ない**(2026-08-14 実害。詳細は後述「発光しない材質には貼れない」)。edit.json のあるディレクトリからの相対 PNG / JPEG / WebP 等、または MP4 / MOV / WebM を指定し、URL や CDN を書かない。該当するマテリアル名がモデル内にない場合は警告して無視される。
123
+ - `materialOverrides` は `{ "<material 名>": { "texture": "<画像または動画の相対パス>" } }` の形で、名前が一致するマテリアルの `emissiveMap` を差し替える。**貼り先の材質が発光しない(glTF の `emissiveFactor` が未設定 = 黒)と、`materialOverrides` 単独では「0 × テクスチャ」で何も出ない**(2026-08-14 実害。詳細は後述「発光しない材質の emissiveMap」)。edit.json のあるディレクトリからの相対 PNG / JPEG / WebP 等、または MP4 / MOV / WebM を指定し、URL や CDN を書かない。該当するマテリアル名がモデル内にない場合は警告して無視される。
124
124
  - `textureVar` は画面に映すものをツマミ(CSS カスタムプロパティ)にする任意キー。`"--screen-src"` のような変数名を書き、overlay の `vars` でその変数に相対パスを入れると `texture` の代わりに差し込まれる。空・未設定なら `texture` がそのまま使われる。
125
125
  - `textureVar` を使う場合も `texture` は実在する相対パスのままにする。書き出し前の宣言済み入力検査が実ファイルを要求するため、`texture` に `var(--screen-src)` を直接書く形はライブプレビューでは動くが書き出しでは失敗する。
126
126
  - `brightness` は任意の発光倍率(0〜4、既定 1)で、差し替えたテクスチャの `emissiveIntensity` に掛かる。`var(--screen-brightness)` のような CSS 変数も書き出しを含めて使える。既定の 1 のときは `emissiveIntensity` に触れず、既存宣言の見た目を維持する。
@@ -448,7 +448,51 @@ texts[] anim と同じ「render は localSeconds の純関数」原則をその
448
448
  (人物セグメンテーション → 輪郭抽出 → 簡略化の自動生成は将来スキル化予定。現時点では手動)。
449
449
  被写体の直上を狙うだけなら `spawn` の矩形を人物の頭・肩幅に合わせて宣言すればよい。
450
450
 
451
- ## 標準ツマミ(camera 投影 — pan / zoom。2026-08-06 導入)
451
+ ## 標準ツマミ(camera 投影と材質 finish)
452
+
453
+ ### 材質 finish の選択
454
+
455
+ `finishes` は glb 内の `material.name` ごとに色と表面係数を宣言し、CSS カスタムプロパティ
456
+ 1 個で選択する。`var` の省略値は `--akari-3d-finish`。`default` は `options` 内に必須。
457
+ 選択名は英字または `_` で始まり、以降は英数字・`_`・`-` のみを使う。
458
+
459
+ ```json
460
+ "finishes": {
461
+ "var": "--akari-3d-finish",
462
+ "default": "natural-titanium",
463
+ "options": {
464
+ "natural-titanium": { "FrameMat": { "color": "#c5c2b9", "metalness": 1, "roughness": 0.35 } },
465
+ "cosmic-orange": { "FrameMat": { "color": "#d66b33", "metalness": 1, "roughness": 0.4 } }
466
+ }
467
+ }
468
+ ```
469
+
470
+ 各材質に指定できるキーは `color` / `metalness` / `roughness` / `clearcoat` /
471
+ `clearcoatRoughness` / `emissive`。色は `#rrggbb` の 6 桁 hex、数値は 0〜1。
472
+ モデルにない材質名や材質にないプロパティは警告して無視する。選択を変えるたび、各材質を
473
+ `materialOverrides` 適用後の値へ戻してから指定キーだけ上書きするため、前の選択の値は残らない。
474
+ テクスチャには一切触れない。
475
+
476
+ ```css
477
+ .phone-live { --akari-3d-finish: var(--phone-finish, natural-titanium); }
478
+ ```
479
+
480
+ 素材 `meta.json` の knob は、既存の `cssVar` バインド形式で指定する:
481
+ 取りうる値は `finishes.options` のキーと一致させる。meta 側は `options` 配列を持たず、`label` に列挙する。
482
+
483
+ ```json
484
+ { "cssVar": "--phone-finish", "type": "dropdown", "group": "style", "label": "本体の仕上げ(natural-titanium / cosmic-orange)", "default": "natural-titanium" }
485
+ ```
486
+
487
+ CSS 値が空なら `default`、未知の値なら警告を 1 回出して `default` を使う。
488
+ `materialOverrides` は `emissiveMap` と `emissiveIntensity`(brightness)を扱い、
489
+ `finishes` は `color` / `metalness` / `roughness` / `clearcoat` / `clearcoatRoughness` と
490
+ `emissive` 色を扱う。`materialOverrides` を先に適用し、その結果を素の値として記録してから
491
+ `finishes` が指定キーだけ上書きする。`emissiveMap` と `emissiveIntensity` は保持される。
492
+ 非発光の材質に貼った `emissiveMap` も、`finishes` で `emissive` を白などにすれば表示できる。
493
+ ライブプレビューと書き出しは同じ経路で適用する。
494
+
495
+ ### カメラ投影の pan / zoom
452
496
 
453
497
  ランタイムは `--akari-3d-pan-x` / `--akari-3d-pan-y` / `--akari-3d-zoom` という 3 つの
454
498
  **標準カスタムプロパティ**を解釈する。断片ルート(または祖先)へ CSS で宣言すると、
@@ -601,11 +645,11 @@ container を渡しても、断片の外側からは同じ値が同じ式で読
601
645
  - 同梱クリップの尺・向きが用途に合わないことは珍しくない。合わないなら**作り直す方が速い**
602
646
  (§glb を直接編集する)
603
647
 
604
- ### 発光しない材質には貼れない(materialOverrides の実測 2026-08-14)
648
+ ### 発光しない材質の emissiveMap(materialOverrides の実測 2026-08-14)
605
649
 
606
650
  `materialOverrides` の差し替え先は `emissiveMap` 固定である。three は `emissive`(glTF の
607
651
  `emissiveFactor`)を掛けてから合成するので、**`emissiveFactor` が未設定=黒の材質へ貼っても
608
- 「0 × テクスチャ」で完全に消える**。しかもエラーにならないため、原因に辿り着きにくい。
652
+ `materialOverrides` 単独では「0 × テクスチャ」で完全に消える**。しかもエラーにならないため、原因に辿り着きにくい。
609
653
 
610
654
  実害: アプリアイコンの glb は前面(`IconFaceMaterial`)が非発光で、`materialOverrides` を
611
655
  宣言しても**白い樹脂の面のまま**だった。同じ宣言でスマホの画面(`ScreenMaterial` =
@@ -618,6 +662,8 @@ container を渡しても、断片の外側からは同じ値が同じ式で読
618
662
  - 対処 1(推奨): モデル側の当該材質に `emissiveFactor: [1,1,1]` を与える。あわせて
619
663
  `baseColorFactor` を暗く落とすと、貼った絵の発色を下地が濁さない
620
664
  - 対処 2: 発光する材質(画面用など)を対象にする
665
+ - 対処 3: `finishes` の同じ材質名に `"emissive": "#ffffff"` などを指定する。
666
+ `materialOverrides` の `emissiveMap` と `emissiveIntensity` は保持される
621
667
  - ランタイムは非発光の材質を対象にすると**警告を出す**(2026-08-14 追加)。ログを見ること
622
668
 
623
669
  ### 動きプリセットは「正面を向く時間」を実測してから使う(2026-08-14)