zyc 72e7df09cd Initial commit: AR avatar prototype
包含三个子项目:
- avatar-h5-renderer: Live2D Cubism 4 H5 渲染器 (Vite + TS)
- avatar_flutter_app: Flutter 容器 App (打包 H5 进 WebView)
- gif-export: puppeteer 导出 32 个动作的透明 GIF (供 ESP32 圆屏播放)

模型资源: Haru, Natori (含贴图、moc3、motions, expressions)
设计文档: AI驱动虚拟形象渲染方案_v5.1.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:14:10 +08:00

144 lines
4.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'avatar_bridge.dart';
import 'control_panel_sheet.dart';
int _serverPort = 8080;
/// 探测端口是否可用 —— InAppLocalhostServer.start() 在端口被占时会死等不抛异常,
/// 所以必须先用 ServerSocket 试探一下能不能 bind。
Future<bool> _isPortFree(int port) async {
try {
final s = await ServerSocket.bind('127.0.0.1', port);
await s.close();
return true;
} catch (_) {
return false;
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 同机多端macOS + iOS sim 共享 localhost端口可能冲突循环找一个空闲端口
for (var port = 8080; port < 8090; port++) {
if (await _isPortFree(port)) {
await InAppLocalhostServer(
documentRoot: 'assets/web',
port: port,
).start();
_serverPort = port;
debugPrint('[server] listening on localhost:$port');
break;
} else {
debugPrint('[server] port $port busy, trying next');
}
}
runApp(const AvatarApp());
}
class AvatarApp extends StatelessWidget {
const AvatarApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Avatar PoC',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF1A1A1F),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF4488DD),
surface: Color(0xFF222228),
),
),
home: const AvatarHomePage(),
);
}
}
class AvatarHomePage extends StatefulWidget {
const AvatarHomePage({super.key});
@override
State<AvatarHomePage> createState() => _AvatarHomePageState();
}
class _AvatarHomePageState extends State<AvatarHomePage> {
AvatarBridge? _bridge;
bool _ready = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
children: [
InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('http://localhost:$_serverPort/index.html'),
),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
transparentBackground: false,
allowsInlineMediaPlayback: true,
mediaPlaybackRequiresUserGesture: false,
// macOS / iOS允许 localhost 加载
allowsBackForwardNavigationGestures: false,
),
onWebViewCreated: (controller) {
_bridge = AvatarBridge(controller);
},
onLoadStop: (controller, _) async {
// 隐藏 H5 内置调试面板Flutter 接管控制权)
await controller.evaluateJavascript(source: '''
const p = document.getElementById('debug-panel');
if (p) p.style.display = 'none';
''');
// 等 avatar 全局对象注册完成
await _bridge?.waitReady();
if (mounted) setState(() => _ready = true);
},
onConsoleMessage: (_, msg) {
debugPrint('[webview] ${msg.message}');
},
),
if (!_ready)
const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Loading Live2D...'),
],
),
),
],
),
),
floatingActionButton: _ready
? FloatingActionButton.extended(
onPressed: () {
if (_bridge == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => ControlPanelSheet(bridge: _bridge!),
);
},
icon: const Icon(Icons.tune),
label: const Text('控制面板'),
backgroundColor: const Color(0xFF2766B6),
foregroundColor: Colors.white,
)
: null,
);
}
}