WolfDavid commited on
Commit
f822421
·
1 Parent(s): 3710182

feat(01-03): add both transports, the standalone stage page and the Gradio component

Browse files

- avatar/avatar.js: inline transport, 145 lines of stagePort plumbing only
- avatar/avatar-iframe.js: postMessage transport, same facade and same turn loop
- both use a deferred event bus so events emitted during mountStage (notably the
no-expressionManager error) survive until installFacade builds the real one
- avatar/stage.html: iframe target and standalone debug harness; ?demo=1 plays a
canned WAV against a hand-authored placeholder timeline
- avatar/assets/demo-konnichiwa.wav: 24000 Hz mono 16-bit, 1.129 s
- avatar_component.py + app.py: AVATAR_TRANSPORT selects the module in one place
- vrm-stage.js: reword the import-map comment so the stage core names no host
- ignore and un-lint Gradio's auto-generated .pyi component stub

.gitignore CHANGED
@@ -7,3 +7,9 @@ node_modules/
7
  .env
8
  voicevox_runtime/
9
  test-results/
 
 
 
 
 
 
 
7
  .env
8
  voicevox_runtime/
9
  test-results/
10
+
11
+ # Gradio rewrites a .pyi type stub next to every module that subclasses a component
12
+ # (gradio/component_meta.py calls create_or_modify_pyi unconditionally at class
13
+ # creation, with no opt-out). It is regenerated on every import and its import order
14
+ # is not ours to fix, so it is build output, not source.
15
+ *.pyi
app.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Space entry point: assembly only.
2
+
3
+ No logic and no module-level mutable state lives here. Gradio shares module globals
4
+ across every visitor session, so the discipline starts now, while there is still
5
+ nothing to share.
6
+ """
7
+
8
+ import os
9
+
10
+ import gradio as gr
11
+
12
+ from japanese_avatar.ui.avatar_component import VrmStage
13
+
14
+ # Serves avatar/ straight off disk, bypassing the Gradio cache, which is how the
15
+ # browser reaches avatar.js, stage.html and tutor.vrm. Deliberately one dedicated
16
+ # directory: this call's own docstring warns that ALL files under a listed path
17
+ # become network-reachable.
18
+ gr.set_static_paths(["avatar"])
19
+
20
+
21
+ def gpu_disabled() -> bool:
22
+ """SC-4's kill switch: the whole turn loop must complete with DISABLE_GPU=1.
23
+
24
+ Read per call rather than captured at import so a Space restart with the variable
25
+ flipped takes effect without a code change.
26
+ """
27
+ return os.environ.get("DISABLE_GPU", "0").strip().lower() in {"1", "true", "yes"}
28
+
29
+
30
+ def build_app() -> gr.Blocks:
31
+ """Build the Blocks app. Importable and callable from tests without launching."""
32
+ with gr.Blocks(title="Japanese Learning Avatar", fill_height=True) as demo:
33
+ VrmStage()
34
+ return demo
35
+
36
+
37
+ if __name__ == "__main__":
38
+ build_app().launch()
avatar/assets/demo-konnichiwa.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4297f70e5b9e41c392c6f9f9457dd72b07470283a2bd038e1dd9d8cef4cdbdfa
3
+ size 54236
avatar/avatar-iframe.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // avatar/avatar-iframe.js
2
+ //
3
+ // THE IFRAME TRANSPORT: the renderer lives inside avatar/stage.html, reachable only
4
+ // by postMessage. This is the documented escape hatch for the inline spike.
5
+ //
6
+ // It is deliberately the same shape as avatar.js: build a six-method stagePort, then
7
+ // call the SAME createTurnLoop and the SAME installFacade. Every method below is
8
+ // message plumbing. There is no turn behaviour in this file, and there must never be
9
+ // - it belongs in avatar/turn-loop.js so both transports get it at once.
10
+ //
11
+ // Note for later waves: microphone capture and ASR run in the PARENT document under
12
+ // both transports, so nothing about them crosses this boundary. Only rendering and
13
+ // audio playback live inside the frame.
14
+
15
+ import { installFacade } from './facade.js';
16
+ import { createTurnLoop } from './turn-loop.js';
17
+
18
+ const STAGE_PATH = '/gradio_api/file=avatar/stage.html';
19
+ // speak() resolves at speech-end, so a reply can legitimately take an utterance's
20
+ // worth of time. This bound only exists so a dead frame fails loudly.
21
+ const REPLY_TIMEOUT_MS = 60000;
22
+
23
+ /**
24
+ * A bus that exists before the real one does. Identical in purpose to the one in
25
+ * avatar.js: the frame can report an error before installFacade() has built the event
26
+ * bus, and those events must not be lost. Buffers until connect(), then replays.
27
+ */
28
+ function deferredBus() {
29
+ let sink = null;
30
+ const queued = [];
31
+ const emit = (name, data) => {
32
+ if (sink) sink(name, data);
33
+ else queued.push([name, data]);
34
+ };
35
+ emit.connect = (real) => {
36
+ sink = real;
37
+ while (queued.length > 0) {
38
+ const [name, data] = queued.shift();
39
+ sink(name, data);
40
+ }
41
+ };
42
+ return emit;
43
+ }
44
+
45
+ /**
46
+ * @param {HTMLElement} element the host-provided mount point
47
+ * @param {object} props at minimum { vrmUrl }; optional { stageUrl }
48
+ * @param {Function} [trigger] host event sink
49
+ * @param {object} [server] host bridge, handed to the turn loop as a getter
50
+ */
51
+ export async function boot(element, props = {}, trigger = null, server = null) {
52
+ if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) {
53
+ return window.Avatar;
54
+ }
55
+
56
+ const emit = deferredBus();
57
+
58
+ const frame = document.createElement('iframe');
59
+ frame.title = 'avatar stage';
60
+ frame.allow = 'microphone; autoplay';
61
+ const stageUrl = props.stageUrl || STAGE_PATH;
62
+ frame.src = `${stageUrl}?vrm=${encodeURIComponent(props.vrmUrl || '')}`;
63
+ element.appendChild(frame);
64
+
65
+ const pending = new Map();
66
+ let seq = 0;
67
+
68
+ let announceReady = null;
69
+ const frameReady = new Promise((resolve) => {
70
+ announceReady = resolve;
71
+ });
72
+
73
+ window.addEventListener('message', (ev) => {
74
+ if (ev.source !== frame.contentWindow) return;
75
+ const msg = ev.data;
76
+ if (!msg || typeof msg.type !== 'string' || !msg.type.startsWith('avatar:')) return;
77
+
78
+ if (msg.type === 'avatar:frame-ready') {
79
+ announceReady();
80
+ return;
81
+ }
82
+ if (msg.type === 'avatar:event') {
83
+ emit(msg.event, msg.data);
84
+ return;
85
+ }
86
+ if (msg.type !== 'avatar:reply') return;
87
+
88
+ const slot = pending.get(msg.id);
89
+ if (!slot) return;
90
+ pending.delete(msg.id);
91
+ clearTimeout(slot.timer);
92
+ if (msg.ok) slot.resolve(msg.value);
93
+ else slot.reject(new Error(msg.error || `stage frame failed: ${msg.id}`));
94
+ });
95
+
96
+ /** One request, one reply, correlated by id. This is the whole transport. */
97
+ async function call(type, extra) {
98
+ await frameReady;
99
+ seq += 1;
100
+ const id = `m${seq}`;
101
+ return new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => {
103
+ pending.delete(id);
104
+ reject(new Error(`stage frame did not answer ${type} within ${REPLY_TIMEOUT_MS} ms`));
105
+ }, REPLY_TIMEOUT_MS);
106
+ pending.set(id, { resolve, reject, timer });
107
+ frame.contentWindow.postMessage({ ...extra, type, id }, '*');
108
+ });
109
+ }
110
+
111
+ // Byte-for-byte the same six names as the inline transport's stagePort. Only the
112
+ // bodies differ, and every body is one round trip.
113
+ const stagePort = {
114
+ mount: (vrmUrl) => call('avatar:mount', { vrmUrl: vrmUrl || props.vrmUrl }),
115
+ speak: (payload) => call('avatar:speak', { payload }),
116
+ replayCached: () => call('avatar:replay', {}),
117
+ setThinking: (value) => call('avatar:setThinking', { value }),
118
+ setListening: (value) => call('avatar:setListening', { value }),
119
+ getDebug: () => call('avatar:debug', {}),
120
+ };
121
+
122
+ const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server });
123
+
124
+ const installed = installFacade({
125
+ transport: 'iframe',
126
+ stagePort,
127
+ turnLoop,
128
+ onEmit: (name, data) => {
129
+ if (typeof trigger === 'function') trigger(name, data);
130
+ },
131
+ });
132
+
133
+ emit.connect(installed.emit);
134
+ await installed.avatar.mount(props.vrmUrl);
135
+ return installed.avatar;
136
+ }
137
+
138
+ /**
139
+ * What the host's watch('value', ...) calls. Identical to the inline transport's:
140
+ * routing a directive is not turn behaviour, it is one line of delegation.
141
+ */
142
+ export function onDirective(value) {
143
+ if (value === null || value === undefined || value === '') return null;
144
+ let directive = value;
145
+ if (typeof value === 'string') {
146
+ try {
147
+ directive = JSON.parse(value);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+ if (!directive || typeof directive !== 'object') return null;
153
+ if (!window.Avatar) throw new Error('onDirective called before boot()');
154
+ return window.Avatar.speak(directive);
155
+ }
avatar/avatar.js ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // avatar/avatar.js
2
+ //
3
+ // THE INLINE TRANSPORT: the renderer lives in the same document as the host component.
4
+ //
5
+ // Its ONLY job is to build a six-method stagePort and hand it to the shared
6
+ // installFacade. It implements no turn behaviour and it never assigns window.Avatar.
7
+ // tests/test_transport_seam.py fails if either rule is broken here.
8
+
9
+ import { getLastDecoded, playBuffer } from './audio-queue.js';
10
+ import { installFacade } from './facade.js';
11
+ import { makePlayer } from './lipsync.js';
12
+ import { createTurnLoop } from './turn-loop.js';
13
+ import { mountStage } from './vrm-stage.js';
14
+
15
+ /**
16
+ * A bus that exists before the real one does.
17
+ *
18
+ * mountStage() needs an emit callback, but the facade's event bus is only created
19
+ * once installFacade() runs - which is after the stage is mounted. Anything emitted
20
+ * during mount (notably the 'error' raised when a VRM has no expressionManager, the
21
+ * exact symptom of a double three.js instance) would otherwise be dropped on the
22
+ * floor. This buffers until connect() and then replays in order.
23
+ */
24
+ function deferredBus() {
25
+ let sink = null;
26
+ const queued = [];
27
+ const emit = (name, data) => {
28
+ if (sink) sink(name, data);
29
+ else queued.push([name, data]);
30
+ };
31
+ emit.connect = (real) => {
32
+ sink = real;
33
+ while (queued.length > 0) {
34
+ const [name, data] = queued.shift();
35
+ sink(name, data);
36
+ }
37
+ };
38
+ return emit;
39
+ }
40
+
41
+ /**
42
+ * @param {HTMLElement} element the host-provided mount point
43
+ * @param {object} props at minimum { vrmUrl }
44
+ * @param {Function} [trigger] host event sink, so the backend also sees avatar events
45
+ * @param {object} [server] host bridge, handed to the turn loop as a getter
46
+ */
47
+ export async function boot(element, props = {}, trigger = null, server = null) {
48
+ // Re-entry guard. key="vrm-stage" should stop the host re-running js_on_load at all,
49
+ // but if it ever does, returning the live object without remounting is what holds
50
+ // mountCount at 1 across 20 interactions (AVTR-01).
51
+ if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) {
52
+ return window.Avatar;
53
+ }
54
+
55
+ const canvas = element.querySelector('#vrm-canvas');
56
+ if (!canvas) throw new Error('boot: no #vrm-canvas inside the mounted element');
57
+
58
+ const AudioCtor = window.AudioContext || window.webkitAudioContext;
59
+ const audioCtx = new AudioCtor();
60
+ const emit = deferredBus();
61
+
62
+ let stage = null;
63
+ let player = null;
64
+ let mounting = null;
65
+ let cached = null;
66
+
67
+ // Memoised: the stage is mounted exactly once no matter how many callers ask.
68
+ function ensureStage(vrmUrl) {
69
+ if (!mounting) {
70
+ mounting = (async () => {
71
+ stage = await mountStage(canvas, vrmUrl || props.vrmUrl, emit);
72
+ player = makePlayer(stage);
73
+ stage.setOnTick((dt) => player.tick(dt));
74
+ return stage;
75
+ })();
76
+ }
77
+ return mounting;
78
+ }
79
+
80
+ await ensureStage(props.vrmUrl);
81
+
82
+ // The stagePort is the ONLY thing that differs between transports. Here every
83
+ // method is a direct call; in the iframe transport every method is a round trip.
84
+ const stagePort = {
85
+ mount: (vrmUrl) => ensureStage(vrmUrl),
86
+
87
+ async speak(payload = {}) {
88
+ await ensureStage();
89
+ cached = payload;
90
+ return playBuffer(audioCtx, payload.audioUrl, emit, (when) =>
91
+ player.start(payload.timeline, audioCtx, when)
92
+ );
93
+ },
94
+
95
+ async replayCached() {
96
+ // The decoded buffer, not the URL: zero network requests, which is what
97
+ // plan 01-09's test_replay asserts with a browser request listener.
98
+ const buffer = getLastDecoded();
99
+ if (!buffer) throw new Error('nothing cached to re-play');
100
+ return playBuffer(audioCtx, buffer, emit, (when) =>
101
+ player.start(cached && cached.timeline, audioCtx, when)
102
+ );
103
+ },
104
+
105
+ setThinking: (value) => (stage ? stage.setThinking(value) : undefined),
106
+ setListening: (value) => (stage ? stage.setListening(value) : undefined),
107
+ getDebug: async () => (stage ? stage.getDebug() : {}),
108
+ };
109
+
110
+ const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server });
111
+
112
+ const installed = installFacade({
113
+ transport: 'inline',
114
+ stagePort,
115
+ turnLoop,
116
+ // The one host-aware line in the file: mirror avatar events onto the host's own
117
+ // event system so backend listeners see them too.
118
+ onEmit: (name, data) => {
119
+ if (typeof trigger === 'function') trigger(name, data);
120
+ },
121
+ });
122
+
123
+ emit.connect(installed.emit);
124
+ await installed.avatar.mount(props.vrmUrl);
125
+ return installed.avatar;
126
+ }
127
+
128
+ /**
129
+ * What the host's watch('value', ...) calls. Tolerates null, '' and already-parsed
130
+ * objects, because a component's initial value is empty and must not throw.
131
+ */
132
+ export function onDirective(value) {
133
+ if (value === null || value === undefined || value === '') return null;
134
+ let directive = value;
135
+ if (typeof value === 'string') {
136
+ try {
137
+ directive = JSON.parse(value);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+ if (!directive || typeof directive !== 'object') return null;
143
+ if (!window.Avatar) throw new Error('onDirective called before boot()');
144
+ return window.Avatar.speak(directive);
145
+ }
avatar/stage.html ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Avatar stage</title>
7
+ <style>
8
+ html,
9
+ body {
10
+ margin: 0;
11
+ height: 100%;
12
+ overflow: hidden;
13
+ background: transparent;
14
+ }
15
+ #vrm-canvas {
16
+ width: 100%;
17
+ height: 100%;
18
+ display: block;
19
+ }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <canvas id="vrm-canvas"></canvas>
24
+
25
+ <!--
26
+ This page has two jobs and both matter.
27
+
28
+ 1. It is the IFRAME FALLBACK TARGET. avatar/avatar-iframe.js drives it entirely
29
+ through the postMessage protocol implemented at the bottom of this file.
30
+ 2. It is the LOCAL DEBUG HARNESS. Open it directly - no Python, no host framework
31
+ - and the avatar renders, blinks, breathes and sways. With ?demo=1 it also
32
+ lip-syncs a canned utterance. If this page works and the deployed inline
33
+ component does not, the problem is the host, not three.js.
34
+
35
+ Query parameters: ?vrm=<url> (defaults to ./assets/tutor.vrm), ?demo=1
36
+ -->
37
+ <script type="module">
38
+ import { playBuffer, getLastDecoded } from './audio-queue.js';
39
+ import { makePlayer } from './lipsync.js';
40
+ import { mountStage } from './vrm-stage.js';
41
+
42
+ // PLACEHOLDER shaped like a real VOICEVOX timeline. These durations are
43
+ // hand-authored, NOT measured. Plan 01-06 replaces this with a timeline
44
+ // generated from a real AudioQuery. こんにちは = k o N n i ch i w a, chosen
45
+ // because the N gives a 'closed' viseme sandwiched between two vowels.
46
+ const DEMO_TIMELINE = [
47
+ { t: 0.0, dur: 0.1, viseme: 'closed', weight: 0.0 }, // prePhonemeLength
48
+ { t: 0.1, dur: 0.064, viseme: 'closed', weight: 0.0 }, // k
49
+ { t: 0.164, dur: 0.128, viseme: 'oh', weight: 1.0 }, // o
50
+ { t: 0.292, dur: 0.139, viseme: 'closed', weight: 0.0 }, // N
51
+ { t: 0.431, dur: 0.053, viseme: 'closed', weight: 0.0 }, // n
52
+ { t: 0.484, dur: 0.117, viseme: 'ih', weight: 1.0 }, // i
53
+ { t: 0.601, dur: 0.075, viseme: 'closed', weight: 0.0 }, // ch
54
+ { t: 0.676, dur: 0.107, viseme: 'ih', weight: 1.0 }, // i
55
+ { t: 0.783, dur: 0.043, viseme: 'closed', weight: 0.0 }, // w
56
+ { t: 0.826, dur: 0.203, viseme: 'aa', weight: 1.0 }, // a
57
+ { t: 1.029, dur: 0.1, viseme: 'closed', weight: 0.0 }, // postPhonemeLength
58
+ ];
59
+ const DEMO_AUDIO = './assets/demo-konnichiwa.wav';
60
+
61
+ const params = new URLSearchParams(location.search);
62
+ const vrmUrl = params.get('vrm') || './assets/tutor.vrm';
63
+ const wantDemo = params.get('demo') === '1';
64
+
65
+ const host = window.parent !== window ? window.parent : null;
66
+ const post = (msg) => host && host.postMessage(msg, '*');
67
+ const emit = (event, data) => post({ type: 'avatar:event', event, data });
68
+
69
+ const canvas = document.getElementById('vrm-canvas');
70
+ let audioCtx = null;
71
+ const ctx = () => {
72
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
73
+ return audioCtx;
74
+ };
75
+
76
+ let stage = null;
77
+ let player = null;
78
+ let mounting = null;
79
+ let cached = null;
80
+
81
+ function ensureStage(url) {
82
+ if (!mounting) {
83
+ mounting = (async () => {
84
+ stage = await mountStage(canvas, url || vrmUrl, emit);
85
+ player = makePlayer(stage);
86
+ // One tick fn drives the viseme player and mirrors the stage's debug
87
+ // object onto window.__stageDebug, so a browser test can read numbers
88
+ // instead of screenshotting a canvas.
89
+ stage.setOnTick((dt) => {
90
+ player.tick(dt);
91
+ window.__stageDebug = stage.getDebug();
92
+ });
93
+ window.__stageDebug = stage.getDebug();
94
+ return stage;
95
+ })();
96
+ }
97
+ return mounting;
98
+ }
99
+
100
+ async function speak(payload) {
101
+ const directive = payload || {};
102
+ await ensureStage();
103
+ cached = directive;
104
+ return playBuffer(ctx(), directive.audioUrl, emit, (when) =>
105
+ player.start(directive.timeline, ctx(), when)
106
+ );
107
+ }
108
+
109
+ async function replayCached() {
110
+ const buffer = getLastDecoded();
111
+ if (!buffer) throw new Error('nothing cached to re-play');
112
+ return playBuffer(ctx(), buffer, emit, (when) =>
113
+ player.start(cached && cached.timeline, ctx(), when)
114
+ );
115
+ }
116
+
117
+ async function runDemo() {
118
+ await ensureStage();
119
+ const c = ctx();
120
+ if (c.state === 'suspended') {
121
+ try {
122
+ await c.resume();
123
+ } catch {
124
+ /* still gesture-gated; the pointerdown handler below retries */
125
+ }
126
+ }
127
+ return speak({ audioUrl: DEMO_AUDIO, timeline: DEMO_TIMELINE, subtitle: 'こんにちは' });
128
+ }
129
+
130
+ // The postMessage protocol. parent -> frame requests, frame -> parent replies.
131
+ const HANDLERS = {
132
+ 'avatar:mount': async (m) => {
133
+ await ensureStage(m.vrmUrl);
134
+ return stage.getDebug();
135
+ },
136
+ 'avatar:speak': (m) => speak(m.payload),
137
+ 'avatar:replay': () => replayCached(),
138
+ 'avatar:setThinking': async (m) => {
139
+ await ensureStage();
140
+ stage.setThinking(m.value);
141
+ return !!m.value;
142
+ },
143
+ 'avatar:setListening': async (m) => {
144
+ await ensureStage();
145
+ stage.setListening(m.value);
146
+ return !!m.value;
147
+ },
148
+ 'avatar:debug': async () => (stage ? stage.getDebug() : {}),
149
+ };
150
+
151
+ window.addEventListener('message', async (ev) => {
152
+ const m = ev.data;
153
+ if (!m || typeof m.type !== 'string') return;
154
+ const handler = HANDLERS[m.type];
155
+ if (!handler) return;
156
+ try {
157
+ const value = await handler(m);
158
+ post({ type: 'avatar:reply', id: m.id, ok: true, value: value === undefined ? null : value });
159
+ } catch (err) {
160
+ post({
161
+ type: 'avatar:reply',
162
+ id: m.id,
163
+ ok: false,
164
+ error: String((err && err.message) || err),
165
+ });
166
+ }
167
+ });
168
+
169
+ // Mount eagerly so the VRM download starts immediately and so opening this file
170
+ // directly shows a living avatar with no driver at all.
171
+ ensureStage().catch((err) =>
172
+ emit('error', { message: String((err && err.message) || err), where: 'mount' })
173
+ );
174
+
175
+ if (wantDemo) {
176
+ runDemo().catch((err) =>
177
+ emit('error', { message: String((err && err.message) || err), where: 'demo' })
178
+ );
179
+ // Browsers that gate audio behind a gesture: one click and the demo runs.
180
+ document.addEventListener('pointerdown', () => {
181
+ if (ctx().state !== 'running') runDemo();
182
+ });
183
+ }
184
+
185
+ post({ type: 'avatar:frame-ready' });
186
+ </script>
187
+ </body>
188
+ </html>
avatar/vrm-stage.js CHANGED
@@ -61,8 +61,9 @@ export async function mountStage(canvasEl, vrmUrl, emit = () => {}) {
61
  // https://esm.sh/three@0.185.1/es2022/three.mjs, so the browser's module map
62
  // guarantees a single instance. Dropping ?deps= gives two instances and the VRM
63
  // silently degrades to a T-posed glTF with no humanoid and no expressions.
64
- // No import map: Gradio's frontend is an already-booted ES-module app, and a late
65
- // import map throws "An import map is added after module script load was triggered."
 
66
  const [THREE, { GLTFLoader }, { VRMLoaderPlugin, VRMUtils }] = await Promise.all([
67
  import(THREE_URL),
68
  import(LOADER_URL),
 
61
  // https://esm.sh/three@0.185.1/es2022/three.mjs, so the browser's module map
62
  // guarantees a single instance. Dropping ?deps= gives two instances and the VRM
63
  // silently degrades to a T-posed glTF with no humanoid and no expressions.
64
+ // No import map: the host page is an already-booted ES-module app, so module
65
+ // resolution has begun long before this file runs and a late import map throws
66
+ // "An import map is added after module script load was triggered."
67
  const [THREE, { GLTFLoader }, { VRMLoaderPlugin, VRMUtils }] = await Promise.all([
68
  import(THREE_URL),
69
  import(LOADER_URL),
pyproject.toml CHANGED
@@ -55,7 +55,10 @@ target-version = "py312"
55
  # ruff 0.16+ lints and formats Python code blocks embedded in Markdown. Planning docs,
56
  # research notes and design snippets are prose, not source: excluding *.md keeps the lint
57
  # gate about shipped Python and stops doc snippets from failing `ruff format --check`.
58
- extend-exclude = ["avatar/vendor", "*.md"]
 
 
 
59
 
60
  [tool.ruff.lint]
61
  select = ["E", "F", "I", "UP", "B", "SIM"]
 
55
  # ruff 0.16+ lints and formats Python code blocks embedded in Markdown. Planning docs,
56
  # research notes and design snippets are prose, not source: excluding *.md keeps the lint
57
  # gate about shipped Python and stops doc snippets from failing `ruff format --check`.
58
+ # *.pyi: Gradio auto-generates a type stub beside any module that subclasses a
59
+ # component, with no opt-out. It is generated output we cannot edit, so it is not
60
+ # subject to our lint gate.
61
+ extend-exclude = ["avatar/vendor", "*.md", "*.pyi"]
62
 
63
  [tool.ruff.lint]
64
  select = ["E", "F", "I", "UP", "B", "SIM"]
src/japanese_avatar/ui/avatar_component.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The single gr.HTML component that owns the WebGL canvas.
2
+
3
+ One env var, read in one place, chooses which transport module ``js_on_load``
4
+ imports. Everything downstream of that import - the facade, the turn loop, the
5
+ renderer - is identical between the two, which is what makes the fallback a flag
6
+ flip rather than a re-plan.
7
+ """
8
+
9
+ import os
10
+
11
+ import gradio as gr
12
+
13
+ AVATAR_TRANSPORT = os.environ.get("AVATAR_TRANSPORT", "inline") # "inline" | "iframe"
14
+ VRM_URL = "/gradio_api/file=avatar/assets/tutor.vrm"
15
+
16
+ # No `head=`: the modules load via dynamic import() inside js_on_load, so there is no
17
+ # ordering constraint and no import map to be injected too late.
18
+ _INLINE_JS = """
19
+ const m = await import('/gradio_api/file=avatar/avatar.js');
20
+ await m.boot(element, props, trigger, server);
21
+ watch('value', () => m.onDirective(props.value));
22
+ """
23
+
24
+ _IFRAME_JS = """
25
+ const m = await import('/gradio_api/file=avatar/avatar-iframe.js');
26
+ await m.boot(element, props, trigger, server);
27
+ watch('value', () => m.onDirective(props.value));
28
+ """
29
+
30
+
31
+ class VrmStage(gr.HTML):
32
+ """The single gr.HTML component that owns the WebGL canvas.
33
+
34
+ Mounted with key="vrm-stage" so Gradio never remounts it across re-renders;
35
+ AVTR-01 requires mountCount to stay at 1 over 20 interactions.
36
+ """
37
+
38
+ def __init__(self, **kwargs):
39
+ super().__init__(
40
+ value="",
41
+ html_template='<div class="vrm-stage"><canvas id="vrm-canvas"></canvas></div>',
42
+ css_template=(
43
+ ".vrm-stage{position:relative;width:100%;height:100%;min-height:480px}"
44
+ "#vrm-canvas{width:100%;height:100%;display:block}"
45
+ ".vrm-stage iframe{width:100%;height:100%;border:0;display:block}"
46
+ ),
47
+ js_on_load=_IFRAME_JS if AVATAR_TRANSPORT == "iframe" else _INLINE_JS,
48
+ props={"vrmUrl": VRM_URL, "transport": AVATAR_TRANSPORT},
49
+ container=False,
50
+ padding=False,
51
+ min_height=480,
52
+ key="vrm-stage",
53
+ preserved_by_key="value",
54
+ elem_id="vrm-stage",
55
+ **kwargs,
56
+ )