akari-video 0.1.50 → 0.1.52
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/cli.mjs +2 -0
- package/src/decision-log-command.mjs +20 -0
- package/src/repo-assets.mjs +4 -0
- package/vendor/.akari-capability-sources.json +2 -0
- package/vendor/docs/contract-2026-09-06-vgpu-layer-v0.md +6 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-store/lib/cut-audio-split-ops.d.ts +36 -0
- package/vendor/packages/edit-store/lib/cut-audio-split-ops.js +196 -0
- package/vendor/packages/edit-store/lib/index.d.ts +1 -0
- package/vendor/packages/edit-store/lib/index.js +1 -0
- package/vendor/packages/overlay-runtime/README.md +1 -0
- package/vendor/skills/edit-lint/SKILL.md +1 -1
- package/vendor/skills/edit-lint/preview.md +31 -0
- package/vendor/skills/edit-plan/SKILL.md +2 -0
- package/vendor/skills/edit-plan/autonomy.md +2 -2
- package/vendor/skills/edit-plan/execution.md +2 -2
- package/vendor/skills/overlay-authoring/3d.md +8 -0
- package/vendor/skills/overlay-authoring/SKILL.md +1 -1
- package/vendor/skills/overlay-authoring/dev-fixtures/test_device_materials.py +75 -0
- package/vendor/skills/overlay-authoring/device-materials.md +64 -0
- package/vendor/skills/overlay-authoring/scripts/device_materials.py +157 -0
- package/vendor/skills/render-cut/SKILL.md +1 -0
- package/vendor/templates/project-default/AGENTS.md +11 -0
- package/vendor/templates/project-default/CLAUDE.md +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.52",
|
|
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": {
|
package/src/cli.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
triggerBackgroundRefresh
|
|
23
23
|
} from './update-check.mjs';
|
|
24
24
|
import { applySelfUpdate, isRunningFromAppDir, rollbackSelfUpdate } from './self-update.mjs';
|
|
25
|
+
import { runDecisionLogCommand } from './decision-log-command.mjs';
|
|
25
26
|
import { runCaptionsCommand } from './captions-command.mjs';
|
|
26
27
|
import { runCaptureCommand } from './capture-command.mjs';
|
|
27
28
|
import { runMediaCommand } from './media-command.mjs';
|
|
@@ -44,6 +45,7 @@ export async function run(args, options = {}) {
|
|
|
44
45
|
error(`akari ${retiredBrowserCommand} は廃止されました(Chrome は不要になりました)`);
|
|
45
46
|
return { exitCode: 1 };
|
|
46
47
|
}
|
|
48
|
+
if (args[0] === 'decision-log') return runDecisionLogCommand(args.slice(1), options);
|
|
47
49
|
if (args[0] === 'captions') return runCaptionsCommand(args.slice(1), options);
|
|
48
50
|
if (args[0] === 'capture') return runCaptureCommand(args.slice(1), options);
|
|
49
51
|
if (args[0] === 'media') return runMediaCommand(args.slice(1), options);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { resolveLauncherAssets } from "./repo-assets.mjs";
|
|
5
|
+
|
|
6
|
+
export async function runDecisionLogCommand(argv, options = {}) {
|
|
7
|
+
const logError = options.error ?? options.logError ?? ((line) => console.error(line));
|
|
8
|
+
const assets = options.assets ?? resolveLauncherAssets();
|
|
9
|
+
if (!assets.decisionLogScript || !existsSync(assets.decisionLogScript)) {
|
|
10
|
+
logError("akari decision-log の実行スクリプトが見つかりません。AKARI Video を再インストールしてください。");
|
|
11
|
+
return { exitCode: 1 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const spawn = options.spawn ?? spawnSync;
|
|
15
|
+
const result = spawn(process.execPath, [assets.decisionLogScript, ...argv], {
|
|
16
|
+
stdio: "inherit",
|
|
17
|
+
cwd: options.cwd ?? process.cwd(),
|
|
18
|
+
});
|
|
19
|
+
return { exitCode: typeof result.status === "number" ? result.status : 1 };
|
|
20
|
+
}
|
package/src/repo-assets.mjs
CHANGED
|
@@ -33,6 +33,7 @@ const AUDIO_FETCH_SCRIPT_RELATIVE = path.join('packages', 'audio-library-setup',
|
|
|
33
33
|
const ASSET_RESOLVER_CLI_RELATIVE = path.join('packages', 'asset-resolver', 'bin', 'akari-assets.mjs');
|
|
34
34
|
const BEATMAP_SCRIPT_RELATIVE = path.join('packages', 'akari-tools', 'bin', 'beatmap.mjs');
|
|
35
35
|
const PROBE_FRAME_SCRIPT_RELATIVE = path.join('packages', 'akari-tools', 'bin', 'probe-frame.mjs');
|
|
36
|
+
const DECISION_LOG_SCRIPT_RELATIVE = path.join('packages', 'akari-tools', 'bin', 'decision-log.mjs');
|
|
36
37
|
const CAPTIONS_SCRIPT_RELATIVE = path.join('packages', 'akari-tools', 'bin', 'captions.mjs');
|
|
37
38
|
const CAPTURE_SCRIPT_RELATIVE = path.join('packages', 'akari-tools', 'bin', 'capture.mjs');
|
|
38
39
|
const RENDER_WHEN_IDLE_SCRIPT_RELATIVE = path.join('packages', 'akari-tools', 'bin', 'render-when-idle.sh');
|
|
@@ -56,6 +57,7 @@ export function resolveRepoAssets(repoRoot = DEFAULT_REPO_ROOT_CANDIDATE) {
|
|
|
56
57
|
const renderWhenIdleScript = path.join(repoRoot, RENDER_WHEN_IDLE_SCRIPT_RELATIVE);
|
|
57
58
|
const eyeBarScript = path.join(repoRoot, EYE_BAR_SCRIPT_RELATIVE);
|
|
58
59
|
const mediaScript = path.join(repoRoot, 'packages', 'akari-tools', 'bin', 'media.mjs');
|
|
60
|
+
const decisionLogScript = path.join(repoRoot, DECISION_LOG_SCRIPT_RELATIVE);
|
|
59
61
|
const wordBookScript = path.join(repoRoot, 'packages', 'akari-tools', 'bin', 'word-book.mjs');
|
|
60
62
|
|
|
61
63
|
return {
|
|
@@ -75,6 +77,7 @@ export function resolveRepoAssets(repoRoot = DEFAULT_REPO_ROOT_CANDIDATE) {
|
|
|
75
77
|
renderWhenIdleScript: existsSync(renderWhenIdleScript) ? renderWhenIdleScript : null,
|
|
76
78
|
eyeBarScript: existsSync(eyeBarScript) ? eyeBarScript : null,
|
|
77
79
|
mediaScript: existsSync(mediaScript) ? mediaScript : null,
|
|
80
|
+
...(existsSync(decisionLogScript) ? { decisionLogScript } : {}),
|
|
78
81
|
...(existsSync(wordBookScript) ? { wordBookScript } : {})
|
|
79
82
|
};
|
|
80
83
|
}
|
|
@@ -111,6 +114,7 @@ export function resolveLauncherAssets({
|
|
|
111
114
|
renderWhenIdleScript: candidate.renderWhenIdleScript ?? vendor.renderWhenIdleScript,
|
|
112
115
|
eyeBarScript: candidate.eyeBarScript ?? vendor.eyeBarScript,
|
|
113
116
|
mediaScript: candidate.mediaScript ?? vendor.mediaScript,
|
|
117
|
+
...(candidate.decisionLogScript ?? vendor.decisionLogScript ? { decisionLogScript: candidate.decisionLogScript ?? vendor.decisionLogScript } : {}),
|
|
114
118
|
...(candidate.wordBookScript ?? vendor.wordBookScript
|
|
115
119
|
? { wordBookScript: candidate.wordBookScript ?? vendor.wordBookScript }
|
|
116
120
|
: {})
|
|
@@ -146,6 +146,7 @@
|
|
|
146
146
|
"skills/declare-audio/launch.md",
|
|
147
147
|
"skills/declare-audio/SKILL.md",
|
|
148
148
|
"skills/declare-audio/what-to-declare.md",
|
|
149
|
+
"skills/edit-lint/preview.md",
|
|
149
150
|
"skills/edit-lint/SKILL.md",
|
|
150
151
|
"skills/edit-plan/approvals-and-generation.md",
|
|
151
152
|
"skills/edit-plan/autonomy.md",
|
|
@@ -170,6 +171,7 @@
|
|
|
170
171
|
"skills/harvest-asset/SKILL.md",
|
|
171
172
|
"skills/manage-connections/SKILL.md",
|
|
172
173
|
"skills/overlay-authoring/3d.md",
|
|
174
|
+
"skills/overlay-authoring/device-materials.md",
|
|
173
175
|
"skills/overlay-authoring/glass.md",
|
|
174
176
|
"skills/overlay-authoring/motion.md",
|
|
175
177
|
"skills/overlay-authoring/SKILL.md",
|
|
@@ -50,6 +50,7 @@ CPU への画素読み戻しはこの経路に追加しない。ブラウザ内
|
|
|
50
50
|
| `passes[].wgsl` | 必須、空白だけではない WGSL 文字列 |
|
|
51
51
|
| `passes[].inputs` | 先行パス id の文字列配列、最大 8 個。省略時 `[]`。自己参照・前方参照不可 |
|
|
52
52
|
| `passes[].scale` | 正の有限数、省略時 `1`。最終パスでは無視する |
|
|
53
|
+
| `passes[].format` | `pure` のみ、`"rgba8unorm"` または `"rgba16float"`。省略時 `"rgba8unorm"`。最終パスでは無視する(surface の format は vgpu の既定) |
|
|
53
54
|
|
|
54
55
|
未知のトップレベルキー・pass キー、不正 JSON、不正な値は `readDescriptor` が TypeError にする。
|
|
55
56
|
`uniforms` のキーは WGSL `Params` のメンバーに対応し、断片が
|
|
@@ -91,6 +92,9 @@ fn akari_uv(pos: vec4f) -> vec2f { return pos.xy / vec2f(akari.pad.x, akari.pad.
|
|
|
91
92
|
断片は `akari_uv(position)`、または vgpu の頂点段が供給する左上原点の `@location(0) uv` を使う。
|
|
92
93
|
`position.xy / vec2f(akari.width, akari.height)` は縮小時に構図が変わるので使わない。
|
|
93
94
|
|
|
95
|
+
`rgba16float` の中間 target は線形 HDR(> 1)をそのまま次のパスへ渡せる。
|
|
96
|
+
`rgba8unorm` は [0,1] に飽和する。
|
|
97
|
+
|
|
94
98
|
## 4. API・解像度・ツマミ
|
|
95
99
|
|
|
96
100
|
- `probe()` はページで 1 回だけ `init()` し、同じ Promise を返す。64×64 一時 surface に 2 コマ描画し、
|
|
@@ -152,6 +156,8 @@ vgpu 不在の overlay sheet には新しい script や seek 文を一切挿入
|
|
|
152
156
|
|
|
153
157
|
## 6. fail-loud と v0 の限界
|
|
154
158
|
|
|
159
|
+
- `passes[].format` の `rgba32float` は未対応(`float32-filterable` 機能に依存するため)。
|
|
160
|
+
|
|
155
161
|
- WebGPU 未提供・adapter null・初期化/試験描画失敗は `VGPU-UNAVAILABLE:` で probe を reject。
|
|
156
162
|
- device lost 後の render は `VGPU-DEVICE-LOST:` で throw。復帰や別 device への切り替えは行わない。
|
|
157
163
|
- `auto` でも `VGPU-` を含む失敗は OSR へフォールバックせずそのまま伝播する。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akari-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.52",
|
|
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": [
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { EditV2 } from './edit-v2';
|
|
2
|
+
/** Serialized v2 document; editing does not require a Project instance. */
|
|
3
|
+
export type EditV2Document = EditV2;
|
|
4
|
+
export type CutAudioSplitBlocker = 'not-found' | 'not-visual-media' | 'nested' | 'anchored' | 'speed' | 'freeze' | 'transition-crossfade' | 'already-split' | 'no-audio';
|
|
5
|
+
export declare function canSplitCutAudio(doc: EditV2Document, cutId: string, options?: {
|
|
6
|
+
hasAudio?: boolean;
|
|
7
|
+
}): {
|
|
8
|
+
ok: true;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
blocker: CutAudioSplitBlocker;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function splitCutAudio(doc: EditV2Document, options: {
|
|
15
|
+
cutId: string;
|
|
16
|
+
hasAudio?: boolean;
|
|
17
|
+
}): {
|
|
18
|
+
document: EditV2Document;
|
|
19
|
+
audioItemId: string;
|
|
20
|
+
audioTrackId: string;
|
|
21
|
+
createdTrack: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare function linkedAudioItemIdOf(doc: EditV2Document, cutId: string): string | undefined;
|
|
24
|
+
export declare function linkedCutIdOf(doc: EditV2Document, audioItemId: string): string | undefined;
|
|
25
|
+
export declare function unlinkCutAudio(doc: EditV2Document, options: {
|
|
26
|
+
audioItemId: string;
|
|
27
|
+
}): EditV2Document;
|
|
28
|
+
export declare function moveLinkedCutAudio(doc: EditV2Document, options: {
|
|
29
|
+
cutId: string;
|
|
30
|
+
deltaFrames: number;
|
|
31
|
+
}): EditV2Document;
|
|
32
|
+
export declare function removeCutAudioLinked(doc: EditV2Document, options: {
|
|
33
|
+
target: 'pair' | 'audio-only' | 'cut-only';
|
|
34
|
+
cutId?: string;
|
|
35
|
+
audioItemId?: string;
|
|
36
|
+
}): EditV2Document;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canSplitCutAudio = canSplitCutAudio;
|
|
4
|
+
exports.splitCutAudio = splitCutAudio;
|
|
5
|
+
exports.linkedAudioItemIdOf = linkedAudioItemIdOf;
|
|
6
|
+
exports.linkedCutIdOf = linkedCutIdOf;
|
|
7
|
+
exports.unlinkCutAudio = unlinkCutAudio;
|
|
8
|
+
exports.moveLinkedCutAudio = moveLinkedCutAudio;
|
|
9
|
+
exports.removeCutAudioLinked = removeCutAudioLinked;
|
|
10
|
+
const tree_ops_1 = require("./tree-ops");
|
|
11
|
+
const BLOCKER_MESSAGES = {
|
|
12
|
+
'not-found': 'カットが見つかりません',
|
|
13
|
+
'not-visual-media': '映像トラックの素材カットだけ音声を分離できます',
|
|
14
|
+
nested: '入れ子のカットはまだ音声を分離できません',
|
|
15
|
+
anchored: '字幕に固定したカットはまだ音声を分離できません',
|
|
16
|
+
speed: '速度を変えたカットはまだ音声を分離できません',
|
|
17
|
+
freeze: '静止区間を持つカットはまだ音声を分離できません',
|
|
18
|
+
'transition-crossfade': 'トランジションを持つカットはまだ音声を分離できません',
|
|
19
|
+
'already-split': 'このカットの音声はすでに分離されています',
|
|
20
|
+
'no-audio': 'この素材には音声がありません',
|
|
21
|
+
};
|
|
22
|
+
// These tree helpers only access tracks/items; no Project methods are needed.
|
|
23
|
+
function tree(doc) {
|
|
24
|
+
return doc;
|
|
25
|
+
}
|
|
26
|
+
function canSplitCutAudio(doc, cutId, options = {}) {
|
|
27
|
+
const location = (0, tree_ops_1.locate)(tree(doc), cutId);
|
|
28
|
+
let blocker;
|
|
29
|
+
if (!location)
|
|
30
|
+
blocker = 'not-found';
|
|
31
|
+
else if (location.parent || location.item.items !== undefined)
|
|
32
|
+
blocker = 'nested';
|
|
33
|
+
else if (location.track.lane !== 'visual' || location.item.source.kind !== 'media')
|
|
34
|
+
blocker = 'not-visual-media';
|
|
35
|
+
else if (location.item.anchor !== undefined)
|
|
36
|
+
blocker = 'anchored';
|
|
37
|
+
else if (location.item.source.speed !== undefined && location.item.source.speed !== 1)
|
|
38
|
+
blocker = 'speed';
|
|
39
|
+
else if (location.item.source.freeze != null)
|
|
40
|
+
blocker = 'freeze';
|
|
41
|
+
else if (location.item.source.transition_out != null)
|
|
42
|
+
blocker = 'transition-crossfade';
|
|
43
|
+
else if (location.item.audio === false)
|
|
44
|
+
blocker = 'already-split';
|
|
45
|
+
else if (options.hasAudio === false)
|
|
46
|
+
blocker = 'no-audio';
|
|
47
|
+
return blocker ? { ok: false, blocker, message: BLOCKER_MESSAGES[blocker] } : { ok: true };
|
|
48
|
+
}
|
|
49
|
+
function splitCutAudio(doc, options) {
|
|
50
|
+
const eligible = canSplitCutAudio(doc, options.cutId, options);
|
|
51
|
+
if (eligible.ok === false)
|
|
52
|
+
throw new Error(eligible.message);
|
|
53
|
+
const document = structuredClone(doc);
|
|
54
|
+
const location = (0, tree_ops_1.locate)(tree(document), options.cutId);
|
|
55
|
+
const cut = location.item;
|
|
56
|
+
const visualIds = new Set(location.track.items.map(item => item.id));
|
|
57
|
+
let audioTrack = document.tracks.find((track) => track.lane === 'audio' && 'items' in track
|
|
58
|
+
&& (track.muted === true) === (location.track.muted === true)
|
|
59
|
+
&& track.items.length > 0
|
|
60
|
+
&& track.items.every(item => item.role === 'speech' && visualIds.has(item.link)));
|
|
61
|
+
const createdTrack = audioTrack === undefined;
|
|
62
|
+
if (!audioTrack) {
|
|
63
|
+
// Audio tracks are inserted at the end of their lane, preserving all existing order.
|
|
64
|
+
let index = 0;
|
|
65
|
+
document.tracks.forEach((track, i) => { if (track.lane === 'audio')
|
|
66
|
+
index = i + 1; });
|
|
67
|
+
audioTrack = (0, tree_ops_1.createTrackAt)(tree(document), 'audio', index);
|
|
68
|
+
const visualNumber = document.tracks.filter(track => track.lane === 'visual').findIndex(track => track.id === location.track.id) + 1;
|
|
69
|
+
audioTrack.name = `${location.track.name ?? `V${visualNumber}`}の音声`;
|
|
70
|
+
if (location.track.muted === true)
|
|
71
|
+
audioTrack.muted = true;
|
|
72
|
+
}
|
|
73
|
+
const ids = new Set((0, tree_ops_1.allLocations)(tree(document)).map(entry => entry.item.id));
|
|
74
|
+
const base = `${cut.id}-audio`;
|
|
75
|
+
let audioItemId = base;
|
|
76
|
+
for (let serial = 2; ids.has(audioItemId); serial++)
|
|
77
|
+
audioItemId = `${base}-${serial}`;
|
|
78
|
+
const audio = {
|
|
79
|
+
id: audioItemId, role: 'speech', link: cut.id, at: cut.at, duration: cut.duration,
|
|
80
|
+
source: { kind: 'media', src: cut.source.src, in: cut.source.in, out: cut.source.out },
|
|
81
|
+
};
|
|
82
|
+
if (cut.source.gain_db !== undefined) {
|
|
83
|
+
audio.gain_db = cut.source.gain_db;
|
|
84
|
+
delete cut.source.gain_db;
|
|
85
|
+
}
|
|
86
|
+
if (cut.source.mute === true) {
|
|
87
|
+
audio.mute = true;
|
|
88
|
+
delete cut.source.mute;
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(cut.keyframes)) {
|
|
91
|
+
const visualPoints = [];
|
|
92
|
+
const audioPoints = [];
|
|
93
|
+
for (const point of cut.keyframes) {
|
|
94
|
+
if (point.gain_db === undefined) {
|
|
95
|
+
visualPoints.push(point);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
audioPoints.push({ t: point.t, gain_db: point.gain_db,
|
|
99
|
+
...(point.easing === undefined ? {} : { easing: structuredClone(point.easing) }) });
|
|
100
|
+
delete point.gain_db;
|
|
101
|
+
if (Object.keys(point).some(key => key !== 't' && key !== 'easing'))
|
|
102
|
+
visualPoints.push(point);
|
|
103
|
+
}
|
|
104
|
+
if (audioPoints.length) {
|
|
105
|
+
if (audioPoints.length === 1) {
|
|
106
|
+
// A singleton envelope is a constant offset to the base gain; clamp sums outside the schema range.
|
|
107
|
+
audio.gain_db = Math.max(-60, Math.min(12, (audio.gain_db ?? 0) + audioPoints[0].gain_db));
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
audio.keyframes = audioPoints;
|
|
111
|
+
// Keep the original, gain-stripped array for a singleton visual: inert points preserve
|
|
112
|
+
// the two-point minimum without changing any property's filtered interpolation.
|
|
113
|
+
if (visualPoints.length >= 2)
|
|
114
|
+
cut.keyframes = visualPoints;
|
|
115
|
+
else if (visualPoints.length === 0)
|
|
116
|
+
delete cut.keyframes;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
cut.audio = false;
|
|
120
|
+
audioTrack.items.push(audio);
|
|
121
|
+
return { document, audioItemId, audioTrackId: audioTrack.id, createdTrack };
|
|
122
|
+
}
|
|
123
|
+
function linkedAudioItemIdOf(doc, cutId) {
|
|
124
|
+
return (0, tree_ops_1.allLocations)(tree(doc)).find(location => location.track.lane === 'audio'
|
|
125
|
+
&& location.item.link === cutId)?.item.id;
|
|
126
|
+
}
|
|
127
|
+
function linkedCutIdOf(doc, audioItemId) {
|
|
128
|
+
const location = (0, tree_ops_1.locate)(tree(doc), audioItemId);
|
|
129
|
+
return location?.track.lane === 'audio' && typeof location.item.link === 'string'
|
|
130
|
+
? location.item.link : undefined;
|
|
131
|
+
}
|
|
132
|
+
function unlinkCutAudio(doc, options) {
|
|
133
|
+
const document = structuredClone(doc);
|
|
134
|
+
const audio = requireAudio(document, options.audioItemId);
|
|
135
|
+
delete audio.item.link;
|
|
136
|
+
return document;
|
|
137
|
+
}
|
|
138
|
+
function moveLinkedCutAudio(doc, options) {
|
|
139
|
+
if (!Number.isInteger(options.deltaFrames))
|
|
140
|
+
throw new Error('移動量は整数フレームで指定してください');
|
|
141
|
+
const document = structuredClone(doc);
|
|
142
|
+
const cut = requireCut(document, options.cutId);
|
|
143
|
+
const audioId = linkedAudioItemIdOf(document, options.cutId);
|
|
144
|
+
const locations = [cut, ...(audioId === undefined ? [] : [requireAudio(document, audioId)])];
|
|
145
|
+
if (locations.some(location => location.item.at + options.deltaFrames < 0)) {
|
|
146
|
+
throw new Error('カットまたは音声がタイムラインの先頭より前になるため移動できません');
|
|
147
|
+
}
|
|
148
|
+
for (const location of locations)
|
|
149
|
+
location.item.at += options.deltaFrames;
|
|
150
|
+
return document;
|
|
151
|
+
}
|
|
152
|
+
function removeCutAudioLinked(doc, options) {
|
|
153
|
+
const document = structuredClone(doc);
|
|
154
|
+
if (!['pair', 'audio-only', 'cut-only'].includes(options.target))
|
|
155
|
+
throw new Error('削除対象が不正です');
|
|
156
|
+
if (options.cutId === undefined && options.audioItemId === undefined)
|
|
157
|
+
throw new Error('削除対象を指定してください');
|
|
158
|
+
let cut = options.cutId === undefined ? undefined : requireCut(document, options.cutId);
|
|
159
|
+
let audio = options.audioItemId === undefined ? undefined : requireAudio(document, options.audioItemId);
|
|
160
|
+
if (cut && audio && audio.item.link !== cut.item.id)
|
|
161
|
+
throw new Error('指定された映像と音声はリンクしていません');
|
|
162
|
+
if (!cut && typeof audio?.item.link === 'string')
|
|
163
|
+
cut = requireCut(document, audio.item.link);
|
|
164
|
+
if (!audio && cut) {
|
|
165
|
+
const audioId = linkedAudioItemIdOf(document, cut.item.id);
|
|
166
|
+
if (audioId !== undefined)
|
|
167
|
+
audio = requireAudio(document, audioId);
|
|
168
|
+
}
|
|
169
|
+
if (options.target === 'audio-only' && !audio)
|
|
170
|
+
throw new Error('リンクされた音声が見つかりません');
|
|
171
|
+
if (options.target === 'cut-only' && !cut)
|
|
172
|
+
throw new Error('リンクされたカットが見つかりません');
|
|
173
|
+
if (options.target !== 'audio-only' && cut)
|
|
174
|
+
removeLocation(cut);
|
|
175
|
+
if (options.target !== 'cut-only' && audio)
|
|
176
|
+
removeLocation(audio);
|
|
177
|
+
if (options.target === 'cut-only' && audio)
|
|
178
|
+
delete audio.item.link;
|
|
179
|
+
return document;
|
|
180
|
+
}
|
|
181
|
+
function requireCut(doc, id) {
|
|
182
|
+
const location = (0, tree_ops_1.locate)(tree(doc), id);
|
|
183
|
+
if (!location || location.track.lane !== 'visual' || location.item.source.kind !== 'media') {
|
|
184
|
+
throw new Error('映像の素材カットが見つかりません');
|
|
185
|
+
}
|
|
186
|
+
return location;
|
|
187
|
+
}
|
|
188
|
+
function requireAudio(doc, id) {
|
|
189
|
+
const location = (0, tree_ops_1.locate)(tree(doc), id);
|
|
190
|
+
if (!location || location.track.lane !== 'audio')
|
|
191
|
+
throw new Error('音声が見つかりません');
|
|
192
|
+
return location;
|
|
193
|
+
}
|
|
194
|
+
function removeLocation(location) {
|
|
195
|
+
location.items.splice(location.index, 1);
|
|
196
|
+
}
|
|
@@ -27,6 +27,7 @@ export * from './ducking';
|
|
|
27
27
|
export * from './envelope';
|
|
28
28
|
export * from './audio-schedule';
|
|
29
29
|
export * from './audio-ownership';
|
|
30
|
+
export * from './cut-audio-split-ops';
|
|
30
31
|
export * from './canonical';
|
|
31
32
|
export * from './tree-ops';
|
|
32
33
|
export * from './item-anchor';
|
|
@@ -45,6 +45,7 @@ __exportStar(require("./ducking"), exports);
|
|
|
45
45
|
__exportStar(require("./envelope"), exports);
|
|
46
46
|
__exportStar(require("./audio-schedule"), exports);
|
|
47
47
|
__exportStar(require("./audio-ownership"), exports);
|
|
48
|
+
__exportStar(require("./cut-audio-split-ops"), exports);
|
|
48
49
|
__exportStar(require("./canonical"), exports);
|
|
49
50
|
__exportStar(require("./tree-ops"), exports);
|
|
50
51
|
__exportStar(require("./item-anchor"), exports);
|
|
@@ -164,6 +164,7 @@ vgpu は他の宣言型ランタイムと同じく `runtimes.mjs` に登録さ
|
|
|
164
164
|
`ready` であることを確認する。プレビューは `render(container, localTimeSeconds, { previewScale: 0.5, fps })` を呼び、
|
|
165
165
|
非表示化と unmount で `dispose(container)` を呼ぶ。非対応環境の警告はランタイムが 1 回にまとめる。
|
|
166
166
|
共有 device は container の破棄時には落とさない。
|
|
167
|
+
`pure` の中間パスは `passes[].format` に `rgba16float` を指定すると線形 HDR を保持でき、省略時は `rgba8unorm`、最終パスでは format を無視する。
|
|
167
168
|
`mode: "stateful"` の断片では `render(container, localTimeSeconds, { fps })` の `fps`(= `edit.output.fps`)が必須で、省略すると TypeError になる。
|
|
168
169
|
stateful のプレビューは上の options にも `fps` を加え、`{ previewScale: 0.5, fps }` を渡す。
|
|
169
170
|
逆戻りシークは reset + 固定ステップ replay で追従し、`maxReplaySteps` 超過は `VGPU-REPLAY-LIMIT` で失敗する。
|
|
@@ -61,7 +61,7 @@ node の解決順は `AKARI_NODE_BIN` → PATH の node(20 以上)→ 同梱
|
|
|
61
61
|
4. 同じコマンドを再実行し、error finding がなく `verdict: "pass"` になるまで繰り返す。analysis.json または captions.json が無い検査は `skipped[]` で確認する。
|
|
62
62
|
5. 書き出し前は、使う出口に合わせて `--engine gpu` または `--engine osr` を追加して再実行する。
|
|
63
63
|
書き出し側が出口を自動選択する場合は `--engine auto` を使い、エンジン適合性も PASS させる。
|
|
64
|
-
6. PASS
|
|
64
|
+
6. PASS 後に [既存プレビューの起動・確認](preview.md) に従って対象の `edit.json` を開き、再生・シーク・音声(ある場合)を確認する。専用プレイヤーを自作しない。確認した入口と未確認項目を報告に明記する。そのうえで、カット境界と overlay の開始・終了フレームを実際に視認する。**開始・終了フレームに加えて中間時刻(各区間の 1/4・1/2・3/4)も必ず視認する** — 拍ちょうど・カット境界ちょうどのフレームは区間の境界値(0% / 100% = 画面外・opacity 0)に必ず当たるため、正常な動きを事故と誤診する(`akari capture --auto` は各オーバーレイ / 字幕区間の中点を含む代表時刻を決定論で導出する)。機械検査の PASS を意味的な品質確認の代わりにしない。
|
|
65
65
|
7. `<project>/.akari/reports/edit-lint-report.html` とフレーム視認結果を編集レポートへ反映し、checkpoint 状態と provenance を実態に合わせて閉じる。
|
|
66
66
|
|
|
67
67
|
音声も確認するときだけ `--media` を追加する。無音区間と音量値は既定で warning になり、次の明示閾値を指定した検査だけが FAIL になり得る。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# 既存プレビューを開いて確認する
|
|
2
|
+
|
|
3
|
+
制作・QA では既存の出力プレビューを使う。対象はプロジェクトの `edit.json`。
|
|
4
|
+
プレビュー提供だけを目的に、案件専用の再生 HTML・再生 UI・シーク・音声同期・編集データ変換を
|
|
5
|
+
新規実装しない。利用者が独立した再生ページを明示的に依頼した場合は、その依頼範囲で作成する。
|
|
6
|
+
|
|
7
|
+
## 起動と接続
|
|
8
|
+
|
|
9
|
+
1. 対象プロジェクトの絶対パスを確認する。起動済みの既存プレビューが同じプロジェクトを
|
|
10
|
+
開いていれば再利用する。ポートが開いているだけでは対象プロジェクトの一致とみなさない。
|
|
11
|
+
2. **デスクトップアプリ**: 対象フォルダを開き、タイムラインから「出力プレビュー」を開く。
|
|
12
|
+
ブラウザで見る場合は左の「メニュー」→「ブラウザプレビュー」を使い、起動後に表示される
|
|
13
|
+
URL を開く。アプリ同梱の機能なので `~/.akari/app` や別途 Node の導入を前提にしない。
|
|
14
|
+
3. **`install.sh` / ソース版**: 実在する `akari.sh` を使い、別の端末で
|
|
15
|
+
`./akari.sh --preview /absolute/path/to/project --port 4567` を実行する。
|
|
16
|
+
`~/.akari/app` に導入した場合は、その中の `akari.sh` をフルパスで指定できる。
|
|
17
|
+
起動ログの Project と URL を確認し、その URL を開く。ポート競合時は別の空きポートを指定する。
|
|
18
|
+
存在を確認していない CLI コマンドやインストールパスを作り上げない。
|
|
19
|
+
4. 画面に対象作品が表示されることを確認する。HTTP 200 やサーバー起動ログだけで完了にしない。
|
|
20
|
+
起動・読込が失敗したら、使った入口、対象パス、操作またはコマンド、エラーを記録する。
|
|
21
|
+
診断は最小限にとどめ、代替プレイヤーの開発へ進まない。
|
|
22
|
+
|
|
23
|
+
## 確認と報告
|
|
24
|
+
|
|
25
|
+
- 既存画面で再生・停止し、区間の途中へシークして表示が変わることを確認する。
|
|
26
|
+
開始・終了に加え区間中間(1/4・1/2・3/4)も見る。音声がある場合は音声再生も確認する。
|
|
27
|
+
- 「プレビュー確認済み」は上記を実施した場合に限る。アプリ内か既存ブラウザか、
|
|
28
|
+
対象プロジェクト、確認時刻、再生・シーク・音声の結果と証跡を記録する。
|
|
29
|
+
- `akari capture` のフレーム視認や描画部品単独の比較は、それぞれその検証として記録する。
|
|
30
|
+
別ページの描画一致をアプリの入口・素材判定・読込・再生の確認へ読み替えない。
|
|
31
|
+
未確認の経路と音声を未確認と明記する。
|
|
@@ -19,6 +19,8 @@ description: analyze-project が作る分析レポート(interpretation.json +
|
|
|
19
19
|
|
|
20
20
|
## 出力ルール(全モード共通)
|
|
21
21
|
|
|
22
|
+
- 仕上がりを見せる前に [既存プレビューの起動・確認](../edit-lint/preview.md) を読む。既存機能を使い、案件専用の再生 HTML・再生 UI・音声同期を自作しない(利用者が独立ページを明示依頼した場合を除く)。確認した入口を報告に明記する。
|
|
23
|
+
|
|
22
24
|
- 判断の正本は検証済み `analysis.json` に置き、根拠のない transcript、フレーム、素材、承認を作らない。
|
|
23
25
|
- 決定は `decision-log.md` へ記録する。`decision-log.md` の既存行は変更・削除せず、常に追記する。
|
|
24
26
|
`decision-log.md` を読み取り専用 HTML へ派生描画する判断記録レポート
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
1. 依頼文 + `intake.tasks` + akari.md の好みで「入れる物」を決める。**頼まれた物に加えて良さそうな物を足してよい**(B ロール・図解・テロップ演出・BGM・SE・章立て。足す判断は akari.md の調達の好み表に従う)。
|
|
33
33
|
2. 分析は事実層 `analysis.json` だけを読む。分析レポートの必読は課さない。無ければ [analyze-footage](../analyze-footage/SKILL.md) の既定(L0 + L1)を実行する。
|
|
34
34
|
3. 方針・素材計画をチャットで提示しない・承認を求めない。
|
|
35
|
-
4. **足した物 1 件ごとに `decision-log.md` へ「予測」1 行**を、タイムラインに入れるのと同じターンで書く。既存の [decision_log 表形式](report-guide.md#decision_log) を使い、ISO 8601 日時と `category = proposal` / `subject = item id または caption id` / `決定 = 何を入れたか` / `理由 = なぜ良さそうか(根拠 = analysis.json のどこ / akari.md のどの行)` / `決定者 = machine:director` / `関連 = 出所(素材 id・手段)`
|
|
35
|
+
4. **足した物 1 件ごとに `decision-log.md` へ「予測」1 行**を、タイムラインに入れるのと同じターンで書く。既存の [decision_log 表形式](report-guide.md#decision_log) を使い、ISO 8601 日時と `category = proposal` / `subject = item id または caption id` / `決定 = 何を入れたか` / `理由 = なぜ良さそうか(根拠 = analysis.json のどこ / akari.md のどの行)` / `決定者 = machine:director` / `関連 = 出所(素材 id・手段)` を記す。関連セルには必ず `sha:<提案時の item / record の canonical JSON の sha256 先頭 8 hex>` を含める。canonical JSON はオブジェクトのキーを再帰的にソートして JSON.stringify した文字列(配列の順序は保持)とする。例: `source-01 / HTML overlay sha:1a2b3c4d`(sha は提案時の実データから計算する)。頼まれた物そのもの(提案でない物)には予測行を書かない。予測行の欠落は edit-lint の warning 止まりとする。
|
|
36
36
|
5. [execution.md](execution.md) の出力ルールで v2 の `edit.json` / `captions.json` / overlays を書き、書いた直後に [edit-lint](../edit-lint/SKILL.md) を実行する。FAIL は直して再実行する。直せなければ §4 に従って止まる。原本は変更せず、`edit.json` を書くのはディレクター 1 人とし、生成物には `<file>.meta.json` の provenance を残す。字幕は `akari captions <project-dir>`(execution.md §4)を使う。
|
|
37
37
|
6. **書き出さない(render-cut を呼ばない)**。最後に「入れた物 N 件(うち提案 M 件・帳面に M 行)・lint 結果・**プレビューで見て、要らなければ消してください。書き出しはヘッダの書き出しボタン**」を報告する。この報告が唯一のチャット出力となる(§1 の offer-once と §4 の停止時の連絡を除く)。
|
|
38
|
-
7. 人間が消した / 直した / そのまま出した
|
|
38
|
+
7. 人間が消した / 直した / そのまま出した の**結果行は書き出し時**に `akari decision-log settle`(render-cut の CLI が自動で呼ぶ)が追記する。シェルの書き出しからの呼び出しは別票。
|
|
39
39
|
|
|
40
40
|
## 4. 止まる条件(3 つだけ)
|
|
41
41
|
|
|
@@ -169,8 +169,8 @@ akari captions <project-dir>
|
|
|
169
169
|
- **次 caption の start まで引き伸ばさない**(敷き詰め禁止)。**無発話区間は無字幕**とし、隙間を字幕で埋めない。短い字幕は次 caption を超えない範囲で既定 1.0 秒の床まで延ばし、床に届かない場合は warning を確認する。
|
|
170
170
|
- caption の start / end は source 秒アンカー(§1)。`words[]` も同じ source 秒で持つ。
|
|
171
171
|
|
|
172
|
-
`--max-chars`
|
|
173
|
-
按分 fallback は CLI
|
|
172
|
+
`--max-chars` は既定 20・文節優先(句点 > ポーズ > 読点 > 文字数 / 秒数)。`--split none` で旧挙動に戻せる。複数素材では `--source <sources[].id>` で対象を選ぶ。
|
|
173
|
+
按分 fallback は CLI が内包する(語時刻が無い場合は句点等で切り、segment 区間を文字数比で按分して読み切り猶予を加える)。
|
|
174
174
|
字幕の付ける/付けない・スタイル等の方針レベルは [report-guide.md](report-guide.md) の素材計画 §字幕枠で決め、ここでは区間の作り方だけを定める。
|
|
175
175
|
|
|
176
176
|
### 字幕スタイルを適用する
|
|
@@ -146,6 +146,14 @@ fragment は単一ルートとし、透明 canvas、任意の静的 fallback、
|
|
|
146
146
|
`offsetWidth` / `offsetHeight`(fallback は `clientWidth` / `clientHeight`)から決まるため、
|
|
147
147
|
回転で投影が縦横に歪むことはなく、回転用の回避策は不要。
|
|
148
148
|
|
|
149
|
+
## 端末画面・キーの反射と材質
|
|
150
|
+
|
|
151
|
+
端末モックの画面を生成・差し替え・PBR へ変換するときは
|
|
152
|
+
[画面・キーの共通材質生成と比較](device-materials.md) を読む。
|
|
153
|
+
発光画像があっても鏡面反射は残る。画面・キー・印字・筐体を分け、ガラス画面のスマホも
|
|
154
|
+
正面と斜めで確認する。共通の生成ヘルパーに明示した対象へだけ設定し、案件専用の GLB
|
|
155
|
+
後処理や全体露出の調整で回避しない。
|
|
156
|
+
|
|
149
157
|
## texts[] — 3D テキスト(flat / extrude。2026-08-12 導入。contract-2026-08-12-3d-text-rail.md)
|
|
150
158
|
|
|
151
159
|
`data-akari-3d-scene` に `texts[]` を足すと、troika-three-text(SDF 平面文字)で per-char に
|
|
@@ -37,7 +37,7 @@ description: AKARI Video のオーバーレイ HTML、字幕、表・グラフ
|
|
|
37
37
|
|
|
38
38
|
- 字幕・テロップの日本語組版、可読性、配置: [telop.md](telop.md)
|
|
39
39
|
- 表・グラフの HTML/CSS 構成とアニメーション: [table.md](table.md)
|
|
40
|
-
- Three.js + glTF、動画テクスチャ、3D 性能: [3d.md](3d.md)
|
|
40
|
+
- Three.js + glTF、動画テクスチャ、3D 性能: [3d.md](3d.md)。端末の画面・キーの生成や反射調整は [device-materials.md](device-materials.md) も読む。
|
|
41
41
|
- ガラス屈折の宣言、入れ子、ツマミ、静止背景: [glass.md](glass.md)
|
|
42
42
|
- 新しい描画の種類は `packages/overlay-runtime/runtimes.mjs` のマニフェストへ登録する(追加手順: `packages/overlay-runtime/README.md`)。
|
|
43
43
|
- 決定的モーション、イージング、compositor 制約: [motion.md](motion.md)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import struct
|
|
6
|
+
import sys
|
|
7
|
+
sys.dont_write_bytecode = True
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
spec = importlib.util.spec_from_file_location('device_materials', Path(__file__).parents[1] / 'scripts/device_materials.py')
|
|
11
|
+
module = importlib.util.module_from_spec(spec)
|
|
12
|
+
spec.loader.exec_module(module)
|
|
13
|
+
|
|
14
|
+
class DeviceMaterialsTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.gltf = {'asset': {'version': '2.0'}, 'materials': [
|
|
17
|
+
{'name': 'screen', 'emissiveFactor': [1, 1, 1], 'emissiveTexture': {'index': 0},
|
|
18
|
+
'pbrMetallicRoughness': {'baseColorFactor': [0, 0, 0, 1]}},
|
|
19
|
+
{'name': 'key', 'pbrMetallicRoughness': {'baseColorFactor': [.03, .03, .04, 1]},
|
|
20
|
+
'extensions': {'KHR_materials_clearcoat': {'clearcoatFactor': .12, 'clearcoatTexture': {'index': 1}}}}
|
|
21
|
+
], 'meshes': [
|
|
22
|
+
{'name': 'display', 'primitives': [{'material': 0, 'attributes': {'POSITION': 0}}]},
|
|
23
|
+
{'name': 'keyboard', 'primitives': [{'material': 1, 'attributes': {'POSITION': 1}}]},
|
|
24
|
+
{'name': 'usb', 'primitives': [{'material': 1, 'attributes': {'POSITION': 2}}]}
|
|
25
|
+
], 'animations': [{'name': 'motion'}]}
|
|
26
|
+
self.profiles = [
|
|
27
|
+
{'material': 'screen', 'values': {'metallic': 0, 'roughness': .9, 'specular': .05}},
|
|
28
|
+
{'material': 'key', 'meshes': ['keyboard'], 'name': 'keyboard-matte', 'values': {'roughness': .9, 'specular': .02, 'clearcoat': 0}}
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def test_selected_mesh_gets_its_own_material_and_usb_and_emission_survive(self):
|
|
32
|
+
before = copy.deepcopy(self.gltf)
|
|
33
|
+
result, records = module.apply_profiles(self.gltf, self.profiles)
|
|
34
|
+
self.assertEqual(self.gltf, before)
|
|
35
|
+
self.assertEqual(result['materials'][1], before['materials'][1])
|
|
36
|
+
self.assertEqual(result['meshes'][2], before['meshes'][2])
|
|
37
|
+
self.assertEqual(result['meshes'][1]['primitives'][0]['material'], 2)
|
|
38
|
+
self.assertEqual(result['materials'][0]['emissiveTexture'], {'index': 0})
|
|
39
|
+
self.assertEqual(result['materials'][0]['emissiveFactor'], [1, 1, 1])
|
|
40
|
+
self.assertEqual(result['materials'][2]['extensions']['KHR_materials_clearcoat']['clearcoatTexture'], {'index': 1})
|
|
41
|
+
self.assertEqual(records[1]['meshes'], ['keyboard'])
|
|
42
|
+
self.assertIn('KHR_materials_specular', result['extensionsUsed'])
|
|
43
|
+
|
|
44
|
+
def test_binary_geometry_animation_and_images_are_unchanged_and_output_is_deterministic(self):
|
|
45
|
+
j = json.dumps(self.gltf).encode(); j += b' ' * (-len(j) % 4)
|
|
46
|
+
binary = b'preserve mesh image animation!!! ' * 4
|
|
47
|
+
raw = struct.pack('<III', 0x46546C67, 2, 28 + len(j) + len(binary)) + struct.pack('<II', len(j), module.JSON_CHUNK) + j + struct.pack('<II', len(binary), 0x004E4942) + binary
|
|
48
|
+
out, report = module.prepare_glb(raw, self.profiles)
|
|
49
|
+
result, chunks = module.read_glb(out)
|
|
50
|
+
self.assertEqual(chunks[1][1], binary)
|
|
51
|
+
self.assertEqual(result['animations'], self.gltf['animations'])
|
|
52
|
+
self.assertTrue(report['binary_chunks_unchanged'])
|
|
53
|
+
self.assertEqual(module.prepare_glb(raw, self.profiles)[0], out)
|
|
54
|
+
|
|
55
|
+
def test_invalid_profiles_fail_without_mutating_the_input(self):
|
|
56
|
+
before = copy.deepcopy(self.gltf)
|
|
57
|
+
for profile in [
|
|
58
|
+
{'material': 'missing', 'values': {'roughness': .5}},
|
|
59
|
+
{'material': 'key', 'meshes': ['missing'], 'name': 'new', 'values': {'roughness': .5}},
|
|
60
|
+
{'material': 'key', 'meshes': ['keyboard'], 'name': 'screen', 'values': {'roughness': .5}},
|
|
61
|
+
{'material': 'key', 'values': {'brightness': .5}},
|
|
62
|
+
*[{'material': 'key', 'values': {'roughness': value}} for value in [-1, 2, True, float('nan'), float('inf')]],
|
|
63
|
+
]:
|
|
64
|
+
with self.subTest(profile=profile), self.assertRaises(ValueError):
|
|
65
|
+
module.apply_profiles(self.gltf, [self.profiles[0], profile])
|
|
66
|
+
self.assertEqual(self.gltf, before)
|
|
67
|
+
|
|
68
|
+
def test_ambiguous_names_are_rejected(self):
|
|
69
|
+
self.gltf['materials'].append(copy.deepcopy(self.gltf['materials'][0]))
|
|
70
|
+
with self.assertRaises(ValueError): module.apply_profiles(self.gltf, self.profiles)
|
|
71
|
+
|
|
72
|
+
def test_truncated_glb_is_rejected(self):
|
|
73
|
+
with self.assertRaises(ValueError): module.read_glb(b'glTF')
|
|
74
|
+
|
|
75
|
+
if __name__ == '__main__': unittest.main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# 端末画面・キーの材質を生成時に整える
|
|
2
|
+
|
|
3
|
+
画面の白濁を見つけたら、まず取得原本・生成レシピ・ライブ用派生 GLB の材質を区別する。
|
|
4
|
+
黒い Base Color と発光画像だけでは PBR の鏡面反射は止まらない。Emission を Principled に
|
|
5
|
+
置き換える経路では metallic / roughness / specular / clearcoat の実効値も調べる。
|
|
6
|
+
|
|
7
|
+
画面、キーの樹脂、印字、筐体、トラックパッドを個別に扱う。画面のためにシーン全体の
|
|
8
|
+
環境光・露出を下げない。`ScreenMaterial` 等の名前から全素材へ無条件に設定を適用しない。
|
|
9
|
+
|
|
10
|
+
## 共通の生成入口
|
|
11
|
+
|
|
12
|
+
[scripts/device_materials.py](scripts/device_materials.py) は GLB の JSON チャンクだけに
|
|
13
|
+
明示指定した材質係数を適用する。画像、形状、カメラ、アニメーションの BIN チャンクは
|
|
14
|
+
バイト単位で保持する。`emissiveTexture` / `emissiveFactor` / 発光強度、未指定の係数・
|
|
15
|
+
テクスチャ・拡張も維持する。ランタイムの `materialOverrides` に未対応キーを追加しない。
|
|
16
|
+
|
|
17
|
+
Blender レシピから `export_glb(filepath, profiles, **export_options)` を呼ぶと、通常の GLB
|
|
18
|
+
書き出しと材質設定が一つの生成処理になる。モジュールはこのスキルの `scripts/` から
|
|
19
|
+
`importlib.util.spec_from_file_location` で読み込める。プロジェクトへ配備されたスキルは
|
|
20
|
+
`.claude/skills/overlay-authoring/scripts/device_materials.py`。単独配布レシピに組み込む場合は
|
|
21
|
+
ヘルパーもレシピ内へ同梱し、ユーザー固有の絶対パスを埋め込まない。
|
|
22
|
+
|
|
23
|
+
既存モデルを確認用の派生物へ変換するときは Python 3 で次を実行する:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
python3 .claude/skills/overlay-authoring/scripts/device_materials.py \
|
|
27
|
+
--input assets/models/original.glb --output assets/generated/device-matte.glb \
|
|
28
|
+
--profiles .akari/work/keep/device-material-profiles.json \
|
|
29
|
+
--report .akari/reports/device-materials.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
入力・既存出力は上書きしない。原本を更新する回避スクリプトを案件ごとに作らない。
|
|
33
|
+
選定した profiles JSON を生成レシピの入力として残し、後段の変換では材質を保持する。
|
|
34
|
+
|
|
35
|
+
profiles は明示的な対象の配列。次は強いスタジオ照明で確認した候補値の例であり、
|
|
36
|
+
全端末に使う既定値ではない。`values` の各係数は 0〜1。
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
[
|
|
40
|
+
{"material":"ScreenMaterial","values":{"metallic":0,"roughness":0.90,"specular":0.05}},
|
|
41
|
+
{"material":"keycap","meshes":["keyboard_keys"],"name":"KeyboardMatteMaterial",
|
|
42
|
+
"values":{"roughness":0.90,"specular":0.02,"clearcoat":0}}
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`meshes` を指定すると材質を複製し、そのメッシュの対象 primitive にだけ割り当てる。
|
|
47
|
+
USB 端子などで共有される元の keycap 材質は維持する。材質名・メッシュ名が重複して
|
|
48
|
+
曖昧な場合、対象が無い場合、未知の係数や範囲外の値はエラーにする。
|
|
49
|
+
`meshes` を省略するとその材質の全使用箇所が対象になるため、監査レポートの対象一覧を確認する。
|
|
50
|
+
|
|
51
|
+
## 比較と採用
|
|
52
|
+
|
|
53
|
+
- 同じ画像・カメラ・照明・発光倍率で、正面と左右斜めを比較する。キーは specular、
|
|
54
|
+
clearcoat、roughness を一つずつ変えた対照を作り、色・露出を先に暗くして原因を隠さない。
|
|
55
|
+
- ガラス画面のスマホも同じ照明で確認する。発光画面の挿入板と、その背後のガラス・
|
|
56
|
+
ベゼル・背面を分ける。白濁を再現しないモデルへ不要な変更を入れない。
|
|
57
|
+
- 画像と動画の差し替え、brightness の保持を検査する。GLB の
|
|
58
|
+
`KHR_materials_specular` / `KHR_materials_clearcoat` が読み込み後も残ることを確認する。
|
|
59
|
+
`threeRuntime.inspect(container).materials` は読み込み後の係数を返す検証用の入口。
|
|
60
|
+
- [既存プレビュー](../edit-lint/preview.md) と実際の GPU / OSR 出力で比較し、どの入口で
|
|
61
|
+
再生・シーク・フレームを確認したか明記する。別ページの描画をアプリ確認済みとしない。
|
|
62
|
+
- 比較画像、採用値、素材の版・ハッシュ、変更対象と非対象の不変確認を記録する。
|
|
63
|
+
画面の黒・白文字・色付き UI、キー印字と筐体の区別が保たれることを視認する。
|
|
64
|
+
配布原本の更新は検証済みの版更新として行い、既存案件の原本を置き換えない。
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Explicit, reproducible material profiles at the glTF export boundary.
|
|
2
|
+
|
|
3
|
+
No material is selected by convention. Numeric factors are authored per model and lighting.
|
|
4
|
+
Import export_glb from a Blender recipe, or use --input/--output/--profiles for a derivative.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import copy
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import struct
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
JSON_CHUNK = 0x4E4F534A
|
|
16
|
+
FACTORS = {
|
|
17
|
+
'metallic': ('pbrMetallicRoughness', 'metallicFactor'),
|
|
18
|
+
'roughness': ('pbrMetallicRoughness', 'roughnessFactor'),
|
|
19
|
+
'specular': ('KHR_materials_specular', 'specularFactor'),
|
|
20
|
+
'clearcoat': ('KHR_materials_clearcoat', 'clearcoatFactor'),
|
|
21
|
+
'clearcoat_roughness': ('KHR_materials_clearcoat', 'clearcoatRoughnessFactor'),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_glb(data):
|
|
26
|
+
if len(data) < 20 or struct.unpack_from('<III', data) != (0x46546C67, 2, len(data)):
|
|
27
|
+
raise ValueError('Expected a complete glTF 2.0 GLB')
|
|
28
|
+
chunks = []
|
|
29
|
+
offset = 12
|
|
30
|
+
while offset < len(data):
|
|
31
|
+
if offset + 8 > len(data):
|
|
32
|
+
raise ValueError('Truncated GLB chunk header')
|
|
33
|
+
size, kind = struct.unpack_from('<II', data, offset)
|
|
34
|
+
offset += 8
|
|
35
|
+
if size % 4 or offset + size > len(data):
|
|
36
|
+
raise ValueError('Invalid GLB chunk size')
|
|
37
|
+
chunks.append((kind, data[offset:offset + size]))
|
|
38
|
+
offset += size
|
|
39
|
+
if not chunks or chunks[0][0] != JSON_CHUNK or sum(k == JSON_CHUNK for k, _ in chunks) != 1:
|
|
40
|
+
raise ValueError('Expected exactly one leading JSON chunk')
|
|
41
|
+
return json.loads(chunks[0][1]), chunks
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_profiles(gltf, profiles):
|
|
45
|
+
"""Return a copy and audit records; preserve all unselected data and texture bindings."""
|
|
46
|
+
if not isinstance(profiles, list) or not profiles:
|
|
47
|
+
raise ValueError('profiles must be a nonempty array of explicit targets')
|
|
48
|
+
result = copy.deepcopy(gltf)
|
|
49
|
+
records = []
|
|
50
|
+
for profile in profiles:
|
|
51
|
+
if not isinstance(profile, dict) or set(profile) - {'material', 'meshes', 'name', 'values'}:
|
|
52
|
+
raise ValueError('A profile accepts material, meshes, name, values only')
|
|
53
|
+
name = profile.get('material')
|
|
54
|
+
matches = [i for i, m in enumerate(result.get('materials', [])) if m.get('name') == name]
|
|
55
|
+
if not isinstance(name, str) or not name or len(matches) != 1:
|
|
56
|
+
raise ValueError(f'Material must match exactly once: {name!r}')
|
|
57
|
+
values = profile.get('values')
|
|
58
|
+
if not isinstance(values, dict) or not values or set(values) - FACTORS.keys():
|
|
59
|
+
raise ValueError(f'Explicit material factors required: {name}')
|
|
60
|
+
for key, value in values.items():
|
|
61
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1:
|
|
62
|
+
raise ValueError(f'{name}.{key} must be finite and between 0 and 1')
|
|
63
|
+
index = matches[0]
|
|
64
|
+
material = result['materials'][index]
|
|
65
|
+
before = copy.deepcopy(material)
|
|
66
|
+
affected = []
|
|
67
|
+
if 'meshes' in profile:
|
|
68
|
+
targets = profile['meshes']
|
|
69
|
+
if not isinstance(targets, list) or not targets or any(not isinstance(t, str) or not t for t in targets) or len(set(targets)) != len(targets):
|
|
70
|
+
raise ValueError('meshes must contain unique explicit mesh names')
|
|
71
|
+
clone_name = profile.get('name')
|
|
72
|
+
if not isinstance(clone_name, str) or not clone_name or any(m.get('name') == clone_name for m in result['materials']):
|
|
73
|
+
raise ValueError('Mesh-scoped profiles require a unique cloned material name')
|
|
74
|
+
material = copy.deepcopy(material)
|
|
75
|
+
material['name'] = clone_name
|
|
76
|
+
replacement = len(result['materials'])
|
|
77
|
+
for target in targets:
|
|
78
|
+
meshes = [m for m in result.get('meshes', []) if m.get('name') == target]
|
|
79
|
+
if len(meshes) != 1:
|
|
80
|
+
raise ValueError(f'Mesh must match exactly once: {target}')
|
|
81
|
+
primitives = [p for p in meshes[0].get('primitives', []) if p.get('material') == index]
|
|
82
|
+
if not primitives:
|
|
83
|
+
raise ValueError(f'{target} does not use {name}')
|
|
84
|
+
for primitive in primitives:
|
|
85
|
+
primitive['material'] = replacement
|
|
86
|
+
affected.append(target)
|
|
87
|
+
result['materials'].append(material)
|
|
88
|
+
elif 'name' in profile:
|
|
89
|
+
raise ValueError('name is only used when cloning for explicit meshes')
|
|
90
|
+
else:
|
|
91
|
+
affected = [m.get('name') for m in result.get('meshes', []) if any(p.get('material') == index for p in m.get('primitives', []))]
|
|
92
|
+
for key, value in values.items():
|
|
93
|
+
section, field = FACTORS[key]
|
|
94
|
+
if section == 'pbrMetallicRoughness':
|
|
95
|
+
material.setdefault(section, {})[field] = value
|
|
96
|
+
else:
|
|
97
|
+
material.setdefault('extensions', {}).setdefault(section, {})[field] = value
|
|
98
|
+
used = result.setdefault('extensionsUsed', [])
|
|
99
|
+
if section not in used:
|
|
100
|
+
used.append(section)
|
|
101
|
+
records.append({'material': name, 'meshes': affected, 'before': before, 'after': copy.deepcopy(material)})
|
|
102
|
+
return result, records
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def prepare_glb(data, profiles):
|
|
106
|
+
gltf, chunks = read_glb(data)
|
|
107
|
+
result, records = apply_profiles(gltf, profiles)
|
|
108
|
+
encoded = json.dumps(result, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
|
|
109
|
+
encoded += b' ' * (-len(encoded) % 4)
|
|
110
|
+
updated = [(JSON_CHUNK, encoded), *chunks[1:]]
|
|
111
|
+
output = struct.pack('<III', 0x46546C67, 2, 12 + sum(8 + len(b) for _, b in updated))
|
|
112
|
+
output += b''.join(struct.pack('<II', len(b), kind) + b for kind, b in updated)
|
|
113
|
+
return output, {
|
|
114
|
+
'source_sha256': hashlib.sha256(data).hexdigest(),
|
|
115
|
+
'output_sha256': hashlib.sha256(output).hexdigest(),
|
|
116
|
+
'binary_chunks_unchanged': chunks[1:] == read_glb(output)[1][1:],
|
|
117
|
+
'changes': records,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def export_glb(filepath, profiles, **options):
|
|
122
|
+
"""Blender recipe entry: export and prepare in one operation, before publishing the GLB."""
|
|
123
|
+
import bpy
|
|
124
|
+
target = Path(filepath)
|
|
125
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
with tempfile.TemporaryDirectory(prefix='akari-material-export-', dir=target.parent) as tmp:
|
|
127
|
+
raw = Path(tmp) / 'scene.glb'
|
|
128
|
+
bpy.ops.export_scene.gltf(filepath=str(raw), export_format='GLB', **options)
|
|
129
|
+
prepared, report = prepare_glb(raw.read_bytes(), profiles)
|
|
130
|
+
ready = Path(tmp) / 'ready.glb'
|
|
131
|
+
ready.write_bytes(prepared)
|
|
132
|
+
ready.replace(target)
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
138
|
+
parser.add_argument('--input', required=True, type=Path)
|
|
139
|
+
parser.add_argument('--output', required=True, type=Path)
|
|
140
|
+
parser.add_argument('--profiles', required=True, type=Path)
|
|
141
|
+
parser.add_argument('--report', type=Path)
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
if args.input.resolve() == args.output.resolve() or args.output.exists():
|
|
144
|
+
parser.error('Write a new derivative; input and existing files cannot be overwritten')
|
|
145
|
+
if args.report and (args.report.exists() or args.report.resolve() in {args.input.resolve(), args.output.resolve(), args.profiles.resolve()}):
|
|
146
|
+
parser.error('Report must be a new, separate file')
|
|
147
|
+
output, report = prepare_glb(args.input.read_bytes(), json.loads(args.profiles.read_text()))
|
|
148
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
args.output.write_bytes(output)
|
|
150
|
+
if args.report:
|
|
151
|
+
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
|
153
|
+
print(json.dumps(report, ensure_ascii=False))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == '__main__':
|
|
157
|
+
main()
|
|
@@ -86,6 +86,7 @@ node の解決順は `AKARI_NODE_BIN` → PATH の node(20 以上)→ 同梱
|
|
|
86
86
|
|
|
87
87
|
- 成果物は既定で `<project>/exports/<source-name>.mp4` に置く。既存名があれば連番を使う。
|
|
88
88
|
- 状態の正本は `<project>/.akari/render.json` とする。HTML レポートは可視化専用とする。
|
|
89
|
+
- CLI は書き出し成功後に帳面の結果行を追記する(`akari decision-log settle`)。`--no-settle` で抑止できる。
|
|
89
90
|
- 成功時だけ `<project>/.akari/render-tmp/` を削除する。失敗時は診断用に保持する。
|
|
90
91
|
- 字幕は `captions.json` から決定的な HTML へ生成し、他のオーバーレイと同じ経路で焼き込む。
|
|
91
92
|
- 字幕スタイルの preset は `presets/textstyle/` にあり、`akari-apply-textstyle.mjs` で `captions.json` へ適用できる。この実行体も同じ解決の対象で、`<render-cut>` と同じ `bin/` 配下にある。通常は edit-plan 段階で適用を済ませ、render-cut はその結果をそのまま描画する。
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
ローカル候補は (b) `install.sh` 経路の `~/.akari/app/docs/contract-2026-07-25-project-structure-v0.md`、
|
|
22
22
|
(c) モノレポの `<repo>/docs/contract-2026-07-25-project-structure-v0.md` である。
|
|
23
23
|
|
|
24
|
+
## プレビューの確認
|
|
25
|
+
|
|
26
|
+
プレビューは既存機能を使う。提供するためだけに専用の再生 HTML・再生 UI・音声同期を
|
|
27
|
+
新規実装しない(独立した再生ページを利用者が明示依頼した場合を除く)。
|
|
28
|
+
アプリで対象プロジェクトの出力プレビューを開く。ブラウザ版は「メニュー」→
|
|
29
|
+
「ブラウザプレビュー」から起動できる。詳しくは
|
|
30
|
+
[既存プレビューの起動・確認](.claude/skills/edit-lint/preview.md) を読む。
|
|
31
|
+
起動できない場合は原因と再現条件を記録する。確認済みと報告するには既存画面で
|
|
32
|
+
再生・シーク・音声(ある場合)を確認し、アプリ内/既存ブラウザのどちらかを明記する。
|
|
33
|
+
描画部品や別ページだけの検証を、既存プレビューの確認済みとして扱わない。
|
|
34
|
+
|
|
24
35
|
## AKARI Video の在処
|
|
25
36
|
|
|
26
37
|
- `~/.akari/cli` … CLI 本体とシム(`~/.akari/cli/bin/akari`)。パートナー接続時に配備される。
|
|
@@ -23,6 +23,17 @@
|
|
|
23
23
|
購入していれば使え、未購入のものは価格付きの `locked` と表示されます。ライブラリの実体は
|
|
24
24
|
`~/.akari/assets/` に置かれますが、直接編集せず上記コマンド経由で操作してください。
|
|
25
25
|
|
|
26
|
+
## プレビューの確認
|
|
27
|
+
|
|
28
|
+
プレビューは既存機能を使う。提供するためだけに専用の再生 HTML・再生 UI・音声同期を
|
|
29
|
+
新規実装しない(独立した再生ページを利用者が明示依頼した場合を除く)。
|
|
30
|
+
アプリで対象プロジェクトの出力プレビューを開く。ブラウザ版は「メニュー」→
|
|
31
|
+
「ブラウザプレビュー」から起動できる。詳しくは
|
|
32
|
+
[既存プレビューの起動・確認](.claude/skills/edit-lint/preview.md) を読む。
|
|
33
|
+
起動できない場合は原因と再現条件を記録する。確認済みと報告するには既存画面で
|
|
34
|
+
再生・シーク・音声(ある場合)を確認し、アプリ内/既存ブラウザのどちらかを明記する。
|
|
35
|
+
描画部品や別ページだけの検証を、既存プレビューの確認済みとして扱わない。
|
|
36
|
+
|
|
26
37
|
## AKARI Video の在処
|
|
27
38
|
|
|
28
39
|
- `~/.akari/cli` … コマンド操作の本体と入口(`~/.akari/cli/bin/akari`)です。パートナー接続時に配備されます。
|