feat&fix: 完善JPEGClient逻辑,前端新增编码器组件

This commit is contained in:
alivender 2025-08-17 14:55:38 +08:00
parent 3644c75304
commit fdfc5729ec
5 changed files with 541 additions and 109 deletions

View File

@ -258,12 +258,12 @@ public class Image
/// <summary>
/// 将原始 JPEG 数据补全 JPEG 头部,生成完整的 JPEG 图片
/// </summary>
/// <param name="jpegData">原始 JPEG 数据(可能缺少完整头部</param>
/// <param name="jpegData">原始 JPEG 扫描数据(不含头尾</param>
/// <param name="width">图像宽度</param>
/// <param name="height">图像高度</param>
/// <param name="quality">JPEG质量1-100默认80</param>
/// <param name="quantizationTable">量化表数组Y0-Y63, Cb0-Cb63, Cr0-Cr63共192个值</param>
/// <returns>完整的 JPEG 图片数据</returns>
public static Result<byte[]> CompleteJpegData(byte[] jpegData, int width, int height, int quality = 80)
public static Result<byte[]> CompleteJpegData(byte[] jpegData, int width, int height, uint[] quantizationTable)
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
@ -271,95 +271,148 @@ public class Image
if (width <= 0 || height <= 0)
return new(new ArgumentException("Width and height must be positive"));
if (quality < 1 || quality > 100)
return new(new ArgumentException("Quality must be between 1 and 100"));
if (quantizationTable == null || quantizationTable.Length != 192)
return new(new ArgumentException("Quantization table must contain exactly 192 values (64 Y + 64 Cb + 64 Cr)"));
try
{
// 检查是否已经是完整的 JPEG 文件(以 FFD8 开头FFD9 结尾)
if (jpegData.Length >= 4 &&
jpegData[0] == 0xFF && jpegData[1] == 0xD8 &&
jpegData[jpegData.Length - 2] == 0xFF && jpegData[jpegData.Length - 1] == 0xD9)
var jpegBytes = new List<byte>();
// SOI (Start of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD8 });
// APP0 段
jpegBytes.AddRange(new byte[] {
0xFF, 0xE0, // APP0 marker
0x00, 0x10, // Length (16 bytes)
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, // Version 1.1
0x00, // Units: 0 = no units
0x00, 0x01, // X density (1)
0x00, 0x01, // Y density (1)
0x00, // Thumbnail width
0x00 // Thumbnail height
});
// DQT (Define Quantization Table) - Y table
jpegBytes.AddRange(new byte[] { 0xFF, 0xDB }); // DQT marker
jpegBytes.AddRange(new byte[] { 0x00, 0x43 }); // Length (67 bytes)
jpegBytes.Add(0x00); // Table ID (0 = Y table)
// 添加Y量化表 (quantizationTable[0-63])
for (int i = 0; i < 64; i++)
{
// 已经是完整的 JPEG 文件,直接返回
return jpegData;
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
// 创建一个临时的 RGB24 图像用于生成 JPEG 头部
using var tempImage = new SixLabors.ImageSharp.Image<Rgb24>(new Configuration
{
// DQT (Define Quantization Table) - CbCr table
jpegBytes.AddRange(new byte[] { 0xFF, 0xDB }); // DQT marker
jpegBytes.AddRange(new byte[] { 0x00, 0x43 }); // Length (67 bytes)
jpegBytes.Add(0x01); // Table ID (1 = CbCr table)
}, width, height);
// 填充临时图像(使用简单的渐变色作为占位符)
for (int y = 0; y < height; y++)
// 添加Cb量化表 (quantizationTable[64-127])但这里使用Cr表的数据作为CbCr共用
for (int i = 128; i < 192; i++) // 使用Cr量化表 (quantizationTable[128-191])
{
for (int x = 0; x < width; x++)
{
tempImage[x, y] = new Rgb24((byte)(x % 256), (byte)(y % 256), 128);
}
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
using var stream = new MemoryStream();
tempImage.SaveAsJpeg(stream, new JpegEncoder { Quality = quality });
var completeJpeg = stream.ToArray();
// SOF0 (Start of Frame)
jpegBytes.AddRange(new byte[] {
0xFF, 0xC0, // SOF0 marker
0x00, 0x11, // Length (17 bytes)
0x08, // Precision (8 bits)
(byte)((height >> 8) & 0xFF), (byte)(height & 0xFF), // Height
(byte)((width >> 8) & 0xFF), (byte)(width & 0xFF), // Width
0x03, // Number of components
0x01, 0x11, 0x00, // Y component
0x02, 0x11, 0x01, // Cb component
0x03, 0x11, 0x01 // Cr component
});
// 如果原始数据看起来是 JPEG 扫描数据,尝试替换扫描数据部分
if (jpegData.Length > 0)
{
// 查找 JPEG 扫描数据开始位置SOS 标记 0xFFDA 后)
int sosIndex = -1;
for (int i = 0; i < completeJpeg.Length - 1; i++)
{
if (completeJpeg[i] == 0xFF && completeJpeg[i + 1] == 0xDA)
{
// 跳过 SOS 段头部,找到实际扫描数据开始位置
i += 2; // 跳过 FF DA
if (i < completeJpeg.Length - 1)
{
int segmentLength = (completeJpeg[i] << 8) | completeJpeg[i + 1];
sosIndex = i + segmentLength;
break;
}
}
}
// DHT (Define Huffman Table) - DC Y table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0x1F, // Length
0x00, // Table class and ID (DC table 0)
// DC Y Huffman table
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
});
// 查找 EOI 标记位置0xFFD9
int eoiIndex = -1;
for (int i = completeJpeg.Length - 2; i >= 0; i--)
{
if (completeJpeg[i] == 0xFF && completeJpeg[i + 1] == 0xD9)
{
eoiIndex = i;
break;
}
}
// DHT (Define Huffman Table) - AC Y table (简化版)
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0xB5, // Length
0x10 // Table class and ID (AC table 0)
});
if (sosIndex > 0 && eoiIndex > sosIndex)
{
// 替换扫描数据部分
var headerLength = sosIndex;
var footerStart = eoiIndex;
var footerLength = completeJpeg.Length - footerStart;
// AC Y Huffman table数据
jpegBytes.AddRange(new byte[] {
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
0xF9, 0xFA
});
var newJpegLength = headerLength + jpegData.Length + footerLength;
var newJpegData = new byte[newJpegLength];
// DHT (Define Huffman Table) - DC CbCr table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0x1F, // Length
0x01, // Table class and ID (DC table 1)
// DC CbCr Huffman table
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
});
// 复制头部
Array.Copy(completeJpeg, 0, newJpegData, 0, headerLength);
// DHT (Define Huffman Table) - AC CbCr table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0xB5, // Length
0x11 // Table class and ID (AC table 1)
});
// 复制原始扫描数据
Array.Copy(jpegData, 0, newJpegData, headerLength, jpegData.Length);
// AC CbCr Huffman table数据与AC Y table相同
jpegBytes.AddRange(new byte[] {
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
0xF9, 0xFA
});
// 复制尾部
Array.Copy(completeJpeg, footerStart, newJpegData, headerLength + jpegData.Length, footerLength);
// SOS (Start of Scan)
jpegBytes.AddRange(new byte[] {
0xFF, 0xDA, // SOS marker
0x00, 0x0C, // Length (12 bytes)
0x03, // Number of components
0x01, 0x00, // Y component, DC/AC table
0x02, 0x11, // Cb component, DC/AC table
0x03, 0x11, // Cr component, DC/AC table
0x00, 0x3F, 0x00 // Start of spectral, End of spectral, Ah/Al
});
return newJpegData;
}
}
// 添加原始 JPEG 扫描数据
jpegBytes.AddRange(jpegData);
// 如果无法智能合并,返回完整的模板 JPEG
return completeJpeg;
// EOI (End of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD9 });
return jpegBytes.ToArray();
}
catch (Exception ex)
{

View File

@ -6,7 +6,7 @@ namespace Peripherals.JpegClient;
static class JpegAddr
{
const UInt32 BASE = 0x0000_0000;
const UInt32 BASE = 0xA000_0000;
public const UInt32 CAPTURE_RD_CTRL = BASE + 0x0;
public const UInt32 CAPTURE_WR_CTRL = BASE + 0x1;
@ -26,9 +26,11 @@ static class JpegAddr
public const UInt32 JPEG_FRAME_SAVE_NUM = BASE + 0xC;
public const UInt32 JPEG_FIFO_FRAME_INFO = BASE + 0xD;
public const UInt32 ADDR_HDMI_WD_START = 0x4000_0000;
public const UInt32 ADDR_JPEG_START = 0x8000_0000;
public const UInt32 ADDR_JPEG_END = 0xA000_0000;
public const UInt32 JPEG_QUANTIZATION_TABLE = BASE + 0x100;
public const UInt32 ADDR_HDMI_WD_START = 0x0400_0000;
public const UInt32 ADDR_JPEG_START = 0x0800_0000;
public const UInt32 ADDR_JPEG_END = 0x09FF_FFFF;
}
public class JpegInfo
@ -142,23 +144,33 @@ public class Jpeg
else return true;
}
public async ValueTask<bool> SetEnable(bool enable)
public async ValueTask<Result<bool>> SetEnable(bool enable)
{
if (enable)
{
var ret = await UDPClientPool.WriteAddrSeq(
this.ep,
this.taskID,
[JpegAddr.CAPTURE_RD_CTRL, JpegAddr.CAPTURE_WR_CTRL],
[0b11, 0b01],
this.timeout
);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set JPEG enable: {ret.Error}");
return false;
var ret = await UDPClientPool.WriteAddrSeq(
this.ep,
this.taskID,
[JpegAddr.CAPTURE_RD_CTRL, JpegAddr.CAPTURE_WR_CTRL],
[0b11, 0b01],
this.timeout
);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set JPEG enable: {ret.Error}");
return ret.Value;
}
}
return ret.Value;
{
var ret = await AddFrameNum2Process(1);
if (!ret)
{
logger.Error($"Failed to AddFrameNum2Process: {ret}");
return ret.Value;
}
}
return true;
}
else
{
@ -181,7 +193,7 @@ public class Jpeg
public async ValueTask<Result<bool>> CheckHdmiIsReady()
{
var ret = await UDPClientPool.ReadAddrWithWait(
this.ep, this.taskID, JpegAddr.HDMI_NOT_READY, 0b01, 0b01, 100, this.timeout);
this.ep, this.taskID, JpegAddr.HDMI_NOT_READY, 0b00, 0b01, 100, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI status: {ret.Error}");
@ -192,8 +204,8 @@ public class Jpeg
public async ValueTask<Result<(int, int)>> GetHdmiResolution()
{
var ret = await UDPClientPool.ReadAddr(
this.ep, this.taskID, JpegAddr.HDMI_HEIGHT_WIDTH, 0, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.HDMI_HEIGHT_WIDTH, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
@ -207,11 +219,13 @@ public class Jpeg
return new(new Exception("Invalid HDMI resolution data length"));
}
var width = data[0] | (data[1] << 8);
var height = data[2] | (data[3] << 8);
var width = data[3] | (data[2] << 8);
var height = data[1] | (data[0] << 8);
this.Width = width;
this.Height = height;
logger.Info($"HDMI resolution: {width}x{height}");
return new((width, height));
}
@ -223,7 +237,22 @@ public class Jpeg
return new(new ArgumentException("Invalid HDMI resolution"));
}
var frameSize = (UInt32)(width * height / 4);
var frameSize = (UInt32)(width * height);
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.JPEG_HEIGHT_WIDTH, (uint)((height << 16) + width), this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
@ -243,7 +272,7 @@ public class Jpeg
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_WR_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize, this.timeout);
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output end address: {ret.Error}");
@ -274,7 +303,7 @@ public class Jpeg
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_RD_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize, this.timeout);
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg input end address: {ret.Error}");
@ -339,14 +368,39 @@ public class Jpeg
public async ValueTask<uint> GetFrameNumber()
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.JPEG_FRAME_SAVE_NUM, this.timeout);
if (!ret.IsSuccessful)
const int maxAttempts = 10;
const int delayMs = 5;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
logger.Error($"Failed to get JPEG frame number: {ret.Error}");
return 0;
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.JPEG_FRAME_SAVE_NUM, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame number on attempt {attempt + 1}: {ret.Error}");
if (attempt < maxAttempts - 1)
{
await Task.Delay(delayMs);
continue;
}
return 0;
}
var frameNumber = Number.BytesToUInt32(ret.Value.Options.Data ?? Array.Empty<byte>()).Value;
if (frameNumber != 0)
{
return frameNumber;
}
// 如果不是最后一次尝试等待5ms后重试
if (attempt < maxAttempts - 1)
{
await Task.Delay(delayMs);
}
}
return Number.BytesToUInt32(ret.Value.Options.Data ?? Array.Empty<byte>()).Value;
// 所有尝试都失败或返回0
return 0;
}
public async ValueTask<Optional<List<JpegInfo>>> GetFrameInfo(int num)
@ -379,14 +433,14 @@ public class Jpeg
return new(infos);
}
public async ValueTask<bool> AddFrameNum2Process(uint cnt)
public async ValueTask<Result<bool>> AddFrameNum2Process(uint cnt)
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.JPEG_ADD_NEED_FRAME_NUM, cnt, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to update pointer: {ret.Error}");
return false;
return ret.Value;
}
return ret.Value;
}
@ -510,4 +564,45 @@ public class Jpeg
return frames;
}
public async ValueTask<Result<uint[]?>> GetQuantizationTable()
{
const int totalQuantValues = 8 * 8 * 3; // Y(64) + Cb(64) + Cr(64) = 192个量化值
const int bytesPerValue = 4; // 每个量化值32bit = 4字节
const int totalBytes = totalQuantValues * bytesPerValue; // 总共768字节
try
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.JPEG_QUANTIZATION_TABLE, totalBytes, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read JPEG quantization table: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value;
if (data == null || data.Length != totalBytes)
{
logger.Error($"Invalid quantization table data length: expected {totalBytes}, got {data?.Length ?? 0}");
return new(new Exception("Invalid quantization table data length"));
}
var quantTable = new uint[totalQuantValues];
for (int i = 0; i < totalQuantValues; i++)
{
// 每32bit为一个量化值按小端序读取
var offset = i * bytesPerValue;
quantTable[i] = (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24));
}
logger.Debug($"Successfully read JPEG quantization table with {totalQuantValues} values");
return quantTable;
}
catch (Exception ex)
{
logger.Error(ex, "Exception occurred while reading JPEG quantization table");
return new(ex);
}
}
}

View File

@ -235,7 +235,19 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
var jpegData = frameResult.Value[0];
var jpegImage = Common.Image.CompleteJpegData(jpegData, client.Width, client.Height);
var quantTableResult = await client.JpegClient.GetQuantizationTable();
if (!quantTableResult.IsSuccessful || quantTableResult.Value == null)
{
logger.Error("获取JPEG量化表失败: {Error}", quantTableResult.Error);
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get quantization table");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
var jpegImage = Common.Image.CompleteJpegData(jpegData, client.Width, client.Height, quantTableResult.Value);
if (!jpegImage.IsSuccessful)
{
logger.Error("JPEG数据补全失败");
@ -280,6 +292,18 @@ public class HttpHdmiVideoStreamService : BackgroundService
logger.Debug("开始HDMI MJPEG流传输");
var quantTableResult = await client.JpegClient.GetQuantizationTable();
if (!quantTableResult.IsSuccessful || quantTableResult.Value == null)
{
logger.Error("获取JPEG量化表失败: {Error}", quantTableResult.Error);
response.StatusCode = 500;
await response.OutputStream.WriteAsync(
System.Text.Encoding.UTF8.GetBytes("Failed to get quantization table"), 0, 0, cancellationToken);
response.Close();
return;
}
var quantTable = quantTableResult.Value;
int frameCounter = 0;
while (!cancellationToken.IsCancellationRequested)
@ -297,7 +321,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
foreach (var framebytes in frameResult.Value)
{
var jpegImage = Common.Image.CompleteJpegData(framebytes, client.Width, client.Height);
var jpegImage = Common.Image.CompleteJpegData(framebytes, client.Width, client.Height, quantTable);
if (!jpegImage.IsSuccessful)
{
logger.Error("JPEG数据不完整");

View File

@ -29,6 +29,7 @@ export interface TemplateConfig {
export const previewSizes: Record<string, number> = {
MechanicalButton: 0.4,
Switch: 0.35,
EC11RotaryEncoder: 0.4,
Pin: 0.8,
SMT_LED: 0.7,
SevenSegmentDisplay: 0.4,
@ -48,6 +49,7 @@ export const previewSizes: Record<string, number> = {
export const availableComponents: ComponentConfig[] = [
{ type: "MechanicalButton", name: "机械按钮" },
{ type: "Switch", name: "开关" },
{ type: "EC11RotaryEncoder", name: "EC11旋转编码器" },
{ type: "Pin", name: "引脚" },
{ type: "SMT_LED", name: "贴片LED" },
{ type: "SevenSegmentDisplay", name: "数码管" },

View File

@ -0,0 +1,258 @@
<template>
<div class="ec11-container" :style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}">
<svg xmlns="http://www.w3.org/2000/svg" :width="width" :height="height" viewBox="0 0 100 100"
class="ec11-encoder">
<defs>
<!-- 发光效果滤镜 -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feFlood result="flood" flood-color="#00ff88" flood-opacity="1"></feFlood>
<feComposite in="flood" result="mask" in2="SourceGraphic" operator="in"></feComposite>
<feMorphology in="mask" result="dilated" operator="dilate" radius="1"></feMorphology>
<feGaussianBlur in="dilated" stdDeviation="2" result="blur1" />
<feGaussianBlur in="dilated" stdDeviation="4" result="blur2" />
<feGaussianBlur in="dilated" stdDeviation="8" result="blur3" />
<feMerge>
<feMergeNode in="blur3" />
<feMergeNode in="blur2" />
<feMergeNode in="blur1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 编码器主体渐变 -->
<radialGradient id="encoderGradient" cx="50%" cy="30%">
<stop offset="0%" stop-color="#666666" />
<stop offset="70%" stop-color="#333333" />
<stop offset="100%" stop-color="#1a1a1a" />
</radialGradient>
<!-- 旋钮渐变 -->
<radialGradient id="knobGradient" cx="30%" cy="30%">
<stop offset="0%" stop-color="#555555" />
<stop offset="70%" stop-color="#222222" />
<stop offset="100%" stop-color="#111111" />
</radialGradient>
<!-- 按下状态渐变 -->
<radialGradient id="knobPressedGradient" cx="50%" cy="50%">
<stop offset="0%" stop-color="#333333" />
<stop offset="70%" stop-color="#555555" />
<stop offset="100%" stop-color="#888888" />
</radialGradient>
</defs>
<!-- 编码器底座 -->
<rect x="10" y="30" width="80" height="60" rx="8" ry="8"
fill="#2a2a2a" stroke="#444444" stroke-width="1"/>
<!-- 编码器主体外壳 -->
<circle cx="50" cy="60" r="32" fill="url(#encoderGradient)" stroke="#555555" stroke-width="1"/>
<!-- 编码器接线端子 -->
<rect x="5" y="75" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="15" y="85" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="25" y="85" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="81" y="85" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="91" y="75" width="4" height="8" fill="#c9c9c9" rx="1"/>
<!-- 旋钮 -->
<circle cx="50" cy="60" r="22"
:fill="isPressed ? 'url(#knobPressedGradient)' : 'url(#knobGradient)'"
stroke="#666666" stroke-width="1"
:transform="`rotate(${rotation/2} 50 60)`"
class="interactive"
@mousedown="handleMouseDown"
@mouseup="handlePress(false)"
@mouseleave="handlePress(false)"/>
<!-- 旋钮指示器 -->
<line x1="50" y1="42" x2="50" y2="48"
stroke="#ffffff" stroke-width="2" stroke-linecap="round"
:transform="`rotate(${rotation} 50 60)`"/>
<!-- 旋钮上的纹理刻度 -->
<g :transform="`rotate(${rotation} 50 60)`">
<circle cx="50" cy="60" r="18" fill="none" stroke="#777777" stroke-width="0.5"/>
<!-- 刻度线 -->
<g v-for="i in 16" :key="i">
<line :x1="50 + 16 * Math.cos((i-1) * Math.PI / 8)"
:y1="60 + 16 * Math.sin((i-1) * Math.PI / 8)"
:x2="50 + 18 * Math.cos((i-1) * Math.PI / 8)"
:y2="60 + 18 * Math.sin((i-1) * Math.PI / 8)"
stroke="#999999" stroke-width="0.5"/>
</g>
</g>
<!-- 编码器编号标签 -->
<text x="50" y="15" text-anchor="middle" font-family="Arial" font-size="10"
fill="#cccccc" font-weight="bold">
EC11-{{ encoderNumber }}
</text>
<!-- 状态指示器 -->
<circle cx="85" cy="20" r="3" :fill="isPressed ? '#ff4444' : '#444444'"
:filter="isPressed ? 'url(#glow)' : ''"
stroke="#666666" stroke-width="0.5"/>
</svg>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
interface Props {
size?: number;
encoderNumber?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
encoderNumber: 1
});
//
const isPressed = ref(false);
const rotation = ref(0);
//
const isDragging = ref(false);
const dragStartX = ref(0);
const lastTriggerX = ref(0);
const dragThreshold = 20; // 20
const hasRotated = ref(false); //
//
const width = computed(() => 100 * props.size);
const height = computed(() => 100 * props.size);
//
const emit = defineEmits(['press', 'release', 'rotate-left', 'rotate-right']);
//
function handleMouseDown(event: MouseEvent) {
isDragging.value = true;
dragStartX.value = event.clientX;
lastTriggerX.value = event.clientX;
hasRotated.value = false; //
//
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
//
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value) return;
const currentX = event.clientX;
const deltaX = currentX - lastTriggerX.value;
//
if (Math.abs(deltaX) >= dragThreshold) {
hasRotated.value = true; //
if (deltaX > 0) {
// -
rotation.value += 15;
emit('rotate-right', {
encoderNumber: props.encoderNumber
});
} else {
// -
rotation.value -= 15;
emit('rotate-left', {
encoderNumber: props.encoderNumber
});
}
//
lastTriggerX.value = currentX;
// 0-360
rotation.value = rotation.value % 720;
if (rotation.value < 0) {
rotation.value += 720;
}
}
}
//
function handleMouseUp() {
isDragging.value = false;
//
if (!hasRotated.value) {
//
handlePress(true);
// 使setTimeout
setTimeout(() => {
handlePress(false);
}, 100);
}
//
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
//
function handlePress(pressed: boolean) {
if (pressed !== isPressed.value) {
isPressed.value = pressed;
if (pressed) {
emit('press', { encoderNumber: props.encoderNumber });
} else {
emit('release', { encoderNumber: props.encoderNumber });
}
}
}
//
defineExpose({
press: () => handlePress(true),
release: () => handlePress(false),
rotateLeft: () => {
rotation.value -= 15;
emit('rotate-left', {
encoderNumber: props.encoderNumber
});
},
rotateRight: () => {
rotation.value += 15;
emit('rotate-right', {
encoderNumber: props.encoderNumber
});
},
isPressed: () => isPressed.value
});
</script>
<script lang="ts">
// props
export function getDefaultProps() {
return {
size: 1,
encoderNumber: 1
};
}
</script>
<style scoped lang="postcss">
.ec11-container {
display: inline-block;
user-select: none;
}
.ec11-encoder {
display: block;
overflow: visible;
}
.interactive {
cursor: pointer;
}
</style>