add: 添加前端对焦交互逻辑

This commit is contained in:
alivender 2025-07-15 20:04:16 +08:00
parent 474151d412
commit 0f850c3ae7
6 changed files with 4455 additions and 4169 deletions

View File

@ -4,6 +4,19 @@ import fetch from 'node-fetch';
const execAsync = promisify(exec);
// Windows 支持函数
function getCommand(command: string): string {
// dotnet 在 Windows 上不需要 .cmd 后缀
if (command === 'dotnet') {
return 'dotnet';
}
return process.platform === 'win32' ? `${command}.cmd` : command;
}
function getSpawnOptions() {
return process.platform === 'win32' ? { stdio: 'pipe', shell: true } : { stdio: 'pipe' };
}
async function waitForServer(url: string, maxRetries: number = 30, interval: number = 1000): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
try {
@ -28,9 +41,7 @@ let webProcess: ChildProcess | null = null;
async function startWeb(): Promise<ChildProcess> {
console.log('Starting Vite frontend...');
return new Promise((resolve, reject) => {
const process = spawn('npm', ['run', 'dev'], {
stdio: 'pipe'
});
const process = spawn(getCommand('npm'), ['run', 'dev'], getSpawnOptions() as any);
let webStarted = false;
@ -75,10 +86,10 @@ async function startWeb(): Promise<ChildProcess> {
async function startServer(): Promise<ChildProcess> {
console.log('Starting .NET server...');
return new Promise((resolve, reject) => {
const process = spawn('dotnet', ['run', '--property:Configuration=Release'], {
const process = spawn(getCommand('dotnet'), ['run', '--property:Configuration=Release'], {
cwd: 'server',
stdio: 'pipe'
});
...getSpawnOptions()
} as any);
let serverStarted = false;
@ -175,7 +186,12 @@ async function stopServer(): Promise<void> {
// 额外清理:确保没有遗留的 dotnet 进程
try {
if (process.platform !== 'win32') {
if (process.platform === 'win32') {
// Windows: 使用 taskkill 清理进程
await execAsync('taskkill /F /IM dotnet.exe').catch(() => {
// 忽略错误,可能没有匹配的进程
});
} else {
// 只清理与我们项目相关的进程
await execAsync('pkill -f "dotnet.*run.*--property:Configuration=Release"').catch(() => {
// 忽略错误,可能没有匹配的进程
@ -242,7 +258,12 @@ async function stopWeb(): Promise<void> {
// 额外清理:确保没有遗留的 npm/node 进程
try {
if (process.platform !== 'win32') {
if (process.platform === 'win32') {
// Windows: 清理可能的 node 进程
await execAsync('taskkill /F /IM node.exe').catch(() => {
// 忽略错误,可能没有匹配的进程
});
} else {
// 清理可能的 vite 进程
await execAsync('pkill -f "vite"').catch(() => {
// 忽略错误,可能没有匹配的进程
@ -257,7 +278,8 @@ async function stopWeb(): Promise<void> {
async function generateApiClient(): Promise<void> {
console.log('Generating API client...');
try {
await execAsync('npx nswag openapi2tsclient /input:http://localhost:5000/swagger/v1/swagger.json /output:src/APIClient.ts');
const npxCommand = getCommand('npx');
await execAsync(`${npxCommand} nswag openapi2tsclient /input:http://localhost:5000/swagger/v1/swagger.json /output:src/APIClient.ts`);
console.log('✓ API client generated successfully');
} catch (error) {
throw new Error(`Failed to generate API client: ${error}`);

View File

@ -447,4 +447,61 @@ public class VideoStreamController : ControllerBase
return TypedResults.InternalServerError($"执行自动对焦失败: {ex.Message}");
}
}
/// <summary>
/// 执行一次自动对焦 (GET方式)
/// </summary>
/// <returns>对焦结果</returns>
[HttpGet("Focus")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public async Task<IResult> Focus()
{
try
{
logger.Info("收到执行一次对焦请求 (GET)");
// 检查摄像头是否已配置
if (!_videoStreamService.IsCameraConfigured())
{
logger.Warn("摄像头未配置,无法执行对焦");
return TypedResults.BadRequest(new
{
success = false,
message = "摄像头未配置,请先配置摄像头连接",
timestamp = DateTime.Now
});
}
var result = await _videoStreamService.PerformAutoFocusAsync();
if (result)
{
logger.Info("对焦执行成功");
return TypedResults.Ok(new
{
success = true,
message = "对焦执行成功",
timestamp = DateTime.Now
});
}
else
{
logger.Warn("对焦执行失败");
return TypedResults.BadRequest(new
{
success = false,
message = "对焦执行失败",
timestamp = DateTime.Now
});
}
}
catch (Exception ex)
{
logger.Error(ex, "执行对焦时发生异常");
return TypedResults.InternalServerError($"执行对焦失败: {ex.Message}");
}
}
}

View File

@ -213,8 +213,7 @@ class Camera
/// <returns>包含图像数据的字节数组</returns>
public async ValueTask<Result<byte[]>> ReadFrame()
{
// 只在第一次或出错时清除UDP缓冲区避免每帧都清除造成延迟
// await MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Reading frame from camera {this.address}");
@ -303,7 +302,13 @@ class Camera
return true;
}
public async ValueTask<Result<byte>> ReadRegisters(UInt16 register)
/// <summary>
/// 读取I2C寄存器字节值
/// </summary>
/// <param name="register">要读取的寄存器地址 (16位)</param>
/// <returns>ret</returns>
public async ValueTask<Result<byte>> ReadRegister(UInt16 register)
{
var i2c = new Peripherals.I2cClient.I2c(this.address, this.port, this.taskID, this.timeout);
@ -1218,18 +1223,22 @@ class Camera
{
logger.Info("开始写入OV5640自动对焦固件");
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
var setStartAddrResult = await ConfigureRegisters([[0x3000, 0x80]]);
if (!setStartAddrResult.IsSuccessful) return setStartAddrResult;
// 组装固件写入命令:地址 + 所有固件数据
UInt16 firmwareAddr = 0x8000;
var firmwareCommands = new List<UInt16[]>();
var firmwareCommand = new UInt16[1 + OV5640_AF_FIRMWARE.Length];
firmwareCommand[0] = firmwareAddr;
// 将固件数据复制到命令数组中
for (int i = 0; i < OV5640_AF_FIRMWARE.Length; i++)
{
UInt16 addr = (UInt16)(firmwareAddr + i);
firmwareCommands.Add([addr, OV5640_AF_FIRMWARE[i]]);
firmwareCommand[i + 1] = OV5640_AF_FIRMWARE[i];
}
var result = await ConfigureRegisters(firmwareCommands.ToArray());
var result = await ConfigureRegisters([firmwareCommand]);
if (!result.IsSuccessful)
{
logger.Error($"固件写入失败: {result.Error}");
@ -1324,7 +1333,7 @@ class Camera
// 步骤2: 读取寄存器 0x3029 的状态,如果返回值为 0x10代表对焦已完成
for (int iteration = 5000; iteration > 0; iteration--)
{
var readResult = await ReadRegisters(0x3029);
var readResult = await ReadRegister(0x3029);
if (!readResult.IsSuccessful)
{
logger.Error($"读取对焦状态寄存器(0x3029)失败: {readResult.Error}");

View File

@ -91,9 +91,9 @@ public class UDPClientPool
var sendLen = socket.SendTo(sendBytes, endPoint);
socket.Close();
// logger.Debug($"UDP socket send address package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
// logger.Debug($" Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
// logger.Debug($" Decoded Data: {pkg.ToString()}");
logger.Debug($"UDP socket send address package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
logger.Debug($" Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
logger.Debug($" Decoded Data: {pkg.ToString()}");
if (sendLen == sendBytes.Length) { return true; }
else { return false; }
@ -164,8 +164,8 @@ public class UDPClientPool
var sendLen = socket.SendTo(sendBytes, endPoint);
socket.Close();
// logger.Debug($"UDP socket send data package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
// logger.Debug($" Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
logger.Debug($"UDP socket send data package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
logger.Debug($" Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
if (sendLen == sendBytes.Length) { return true; }
else { return false; }

File diff suppressed because it is too large Load Diff

View File

@ -161,8 +161,13 @@
</h2>
<div
class="relative bg-black rounded-lg overflow-hidden"
class="relative bg-black rounded-lg overflow-hidden cursor-pointer"
:class="[
focusAnimationClass,
{ 'cursor-not-allowed': !isPlaying || hasVideoError }
]"
style="aspect-ratio: 4/3"
@click="handleVideoClick"
>
<!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
<div
@ -178,6 +183,14 @@
/>
</div>
<!-- 对焦提示 -->
<div
v-if="isPlaying && !hasVideoError"
class="absolute top-4 right-4 text-white text-sm bg-black bg-opacity-50 px-2 py-1 rounded"
>
{{ isFocusing ? '对焦中...' : '点击画面对焦' }}
</div>
<!-- 错误信息显示 -->
<div
v-if="hasVideoError"
@ -353,6 +366,10 @@ const isPlaying = ref(false);
const hasVideoError = ref(false);
const videoStatus = ref('点击"播放视频流"按钮开始查看实时视频');
//
const isFocusing = ref(false);
const focusAnimationClass = ref('');
//
const changingResolution = ref(false);
const loadingResolutions = ref(false);
@ -565,6 +582,67 @@ const tryReconnect = () => {
currentVideoSource.value = `${streamInfo.value.mjpegUrl}?t=${new Date().getTime()}`;
};
//
const performFocus = async () => {
if (isFocusing.value || !isPlaying.value) return;
try {
isFocusing.value = true;
focusAnimationClass.value = 'focus-starting';
addLog("info", "正在执行自动对焦...");
// API
const response = await fetch('/api/VideoStream/Focus');
const result = await response.json();
if (result.success) {
//
focusAnimationClass.value = 'focus-success';
addLog("success", "自动对焦执行成功");
// 2
setTimeout(() => {
focusAnimationClass.value = '';
}, 2000);
} else {
//
focusAnimationClass.value = 'focus-error';
addLog("error", `自动对焦执行失败: ${result.message || '未知错误'}`);
// 2
setTimeout(() => {
focusAnimationClass.value = '';
}, 2000);
}
} catch (error) {
//
focusAnimationClass.value = 'focus-error';
addLog("error", `自动对焦执行失败: ${error}`);
console.error("自动对焦执行失败:", error);
// 2
setTimeout(() => {
focusAnimationClass.value = '';
}, 2000);
} finally {
// 1
setTimeout(() => {
isFocusing.value = false;
}, 1000);
}
};
//
const handleVideoClick = (event: MouseEvent) => {
//
if (!isPlaying.value || hasVideoError.value) return;
//
if (isFocusing.value) return;
performFocus();
};
//
const startStream = async () => {
try {
@ -711,4 +789,69 @@ img {
* {
transition: all 500ms ease-in-out;
}
/* 对焦动画样式 */
.focus-starting {
border: 3px solid transparent;
animation: focus-starting-animation 0.5s ease-in-out forwards;
}
.focus-success {
border: 3px solid transparent;
animation: focus-success-animation 2s ease-in-out forwards;
}
.focus-error {
border: 3px solid transparent;
animation: focus-error-animation 2s ease-in-out forwards;
}
@keyframes focus-starting-animation {
0% {
border-color: transparent;
}
100% {
border-color: #fbbf24; /* 黄色 */
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
}
@keyframes focus-success-animation {
0% {
border-color: #fbbf24; /* 黄色 */
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
50% {
border-color: #10b981; /* 绿色 */
box-shadow: 0 0 20px rgba(16, 185, 129, 0.5);
}
100% {
border-color: transparent;
box-shadow: none;
}
}
@keyframes focus-error-animation {
0% {
border-color: #fbbf24; /* 黄色 */
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
50% {
border-color: #ef4444; /* 红色 */
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
}
100% {
border-color: transparent;
box-shadow: none;
}
}
/* 对焦状态下的鼠标指针 */
.cursor-pointer {
cursor: pointer;
}
.cursor-not-allowed {
cursor: not-allowed;
}
</style>