refactor(HDMI-Tool): 划分 src/assets/release 三区目录, 补齐人读/AI 读双文档与发布流水线

This commit is contained in:
2026-09-10 23:44:56 +08:00
parent 0977b275ee
commit a1f6fdd34a
35 changed files with 517 additions and 81 deletions
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 hdmi_gui.py 打包成单文件 exe(工具名: HDMI-Tool)
================================
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter
全部打进一个 exe, 目标机器无需安装任何依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体(--dump 可用)
产物: release/HDMI-Tool.exe(及 --debug 时的 HDMI-Tool-console.exe)
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/(与 pattern_slots.json 同目录)
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
ICO = os.path.join(SRC, 'logos', 'logo_ph_orange.ico')
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
import time
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, extra_args):
dist = os.path.join(BASE, 'release')
work = os.path.join(SRC, 'build')
remove_stale(os.path.join(dist, name + '.exe'))
cmd = [sys.executable, '-m', 'PyInstaller',
'--onefile', '--clean', '--name', name,
'--distpath', dist, '--workpath', work, '--specpath', work,
] + extra_args + [os.path.join(SRC, 'hdmi_gui.py')]
if os.path.exists(ICO):
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
else:
print('警告: 未找到 logos/logo_ph_orange.ico(先运行 logos/make_ico.py), 本次打包不带图标')
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(dist, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框(GUI/图案/自定义栏位全部可用, --dump 除外)
build('HDMI-Tool', ['--noconsole'])
if build_console:
# 调试版: 带控制台, --dump / 异常栈可见
build('HDMI-Tool-console', [])
if __name__ == '__main__':
main()
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 640x360 黑白测试图, 供 HDMI 源端工具的自定义栏位使用
================================================================
输出到 ../assets/(仓库资产目录, 发布打包时随 zip 分发); 预置栏位配置
release/pattern_slots.json 指向前 3 张, 其余两张可用 "⚙" 换上。
图案清单(均为 640x360 黑白, 1:1 投屏无缩放):
bw_star.png 西门子星: 60 楔放射星, 看极限分辨率与中心偏移
bw_sweep.png 频率扫描条纹: 周期 24px 渐减到 3px(上竖线/下横线)
bw_checker_sweep.png 渐变棋盘: 格子尺寸 2px 递增到 32px 共 9 段
bw_zoneplate.png 同心波带片: 环距向中心递减, 看各向混叠
bw_testcard.png 综合测试卡: 灰阶/刻度/细棋盘/粗细线/标记
用法: python gen_bw_patterns.py
"""
import math
import os
from PIL import Image, ImageDraw
W, H = 640, 360
SRC = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(SRC), 'assets')
def save(img, name):
os.makedirs(OUT, exist_ok=True)
path = os.path.join(OUT, name)
img.save(path)
print(f'写出 {path}')
def gen_star():
"""西门子星: 60 个黑白交替楔形, 边缘楔宽约 17px, 向中心无限加密"""
img = Image.new('L', (W, H), 255)
d = ImageDraw.Draw(img)
cx, cy, R = W // 2, H // 2, 165
n = 60
for i in range(0, n, 2): # 黑楔在偶数位, 底为白
d.pieslice([cx - R, cy - R, cx + R, cy + R],
i * 360.0 / n, (i + 1) * 360.0 / n, fill=0)
d.ellipse([cx - R - 6, cy - R - 6, cx + R + 6, cy + R + 6],
outline=0, width=3)
d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2)
save(img, 'bw_star.png')
def gen_sweep():
"""频率扫描条纹: 左到右黑白条纹周期 24px 渐减到 3px; 上半竖线, 下半横线"""
img = Image.new('L', (W, H), 255)
px = img.load()
# 上半: 竖条纹
ph = 0.0
for x in range(W):
t = x / (W - 1)
ph += 1.0 / (24 - 21 * t)
if (ph % 1.0) < 0.5:
for y in range(H // 2):
px[x, y] = 0
# 下半: 横条纹
ph = 0.0
for y in range(H // 2, H):
t = (y - H // 2) / (H // 2 - 1)
ph += 1.0 / (24 - 21 * t)
if (ph % 1.0) < 0.5:
for x in range(W):
px[x, y] = 0
d = ImageDraw.Draw(img)
d.line([0, H // 2, W, H // 2], fill=0, width=2)
d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2)
d.text((8, 6), 'H sweep 24px->3px', fill=0)
d.text((8, H // 2 + 6), 'V sweep 24px->3px', fill=0)
save(img, 'bw_sweep.png')
def gen_checker_sweep():
"""渐变棋盘: 格子尺寸从 2px 逐段加倍到 32px, 看多大格子开始可分辨"""
img = Image.new('L', (W, H), 0)
d = ImageDraw.Draw(img)
sizes = [2, 3, 4, 6, 8, 12, 16, 24, 32]
seg = W / len(sizes)
for i, s in enumerate(sizes):
x0, x1 = int(round(i * seg)), int(round((i + 1) * seg))
for yy in range(0, H, s):
for xx in range(x0, x1, s):
if ((xx // s) + (yy // s)) % 2 == 0:
d.rectangle([xx, yy, min(xx + s, x1) - 1, min(yy + s, H) - 1],
fill=255)
d.line([x1, 0, x1, H], fill=255, width=2)
d.text((4, 4), 'checker 2..32px', fill=255)
save(img, 'bw_checker_sweep.png')
def gen_zoneplate():
"""同心波带片: 环距中心处约 31px、边缘约 3.5px, 观察各方向混叠"""
img = Image.new('L', (W, H), 255)
px = img.load()
cx, cy, k = W / 2, H / 2, 0.0008
for y in range(H):
dy = y - cy
for x in range(W):
dx = x - cx
if math.cos(k * (dx * dx + dy * dy)) > 0:
px[x, y] = 0
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2)
save(img, 'bw_zoneplate.png')
def gen_testcard():
"""综合测试卡: 灰阶条 + 刻度 + 细棋盘 + 粗细线 + 角标"""
img = Image.new('L', (W, H), 0)
d = ImageDraw.Draw(img)
# 外框与 40px 刻度(边缘短刻线)
d.rectangle([0, 0, W - 1, H - 1], outline=255, width=2)
for x in range(40, W, 40):
d.line([x, 0, x, 10], fill=255, width=1)
d.line([x, H, x, H - 10], fill=255, width=1)
for y in range(40, H, 40):
d.line([0, y, 10, y], fill=255, width=1)
d.line([W, y, W - 10, y], fill=255, width=1)
# 顶部 16 级灰阶条
gw = W // 16
for i in range(16):
v = round(i * 255 / 15)
d.rectangle([i * gw, 20, (i + 1) * gw - 1, 44], fill=v, outline=255)
# 左下: 1px 棋盘块 (640/4=160 宽 x 72 高)
bx, by, bs = 16, H - 96, 2
for yy in range(by, by + 72, bs):
for xx in range(bx, bx + 160, bs):
if ((xx // bs) + (yy // bs)) % 2 == 0:
d.rectangle([xx, yy, xx + bs - 1, yy + bs - 1], fill=255)
d.text((bx, by - 14), '2px checker', fill=255)
# 右下: 1/2/4px 白线组(横线), 看线条再现
lx = W - 210
for row, lw in enumerate((1, 2, 4)):
y0 = H - 100 + row * 26
for off in range(0, 13, lw * 2):
d.line([lx, y0 + off, lx + 190, y0 + off], fill=255, width=lw)
d.text((lx, H - 114), '1/2/4px lines', fill=255)
# 中心十字(1px)
cx, cy = W // 2, H // 2
d.line([cx, 60, cx, H - 60], fill=255, width=1)
d.line([W // 4, cy, W - W // 4, cy], fill=255, width=1)
# 四角 L 标记
m, L = 16, 28
for sx, sy in ((m, m), (W - 1 - m, m), (m, H - 1 - m), (W - 1 - m, H - 1 - m)):
dx = -1 if sx > cx else 1
dy = -1 if sy > cy else 1
d.line([sx, sy, sx + dx * L, sy], fill=255, width=2)
d.line([sx, sy, sx, sy + dy * L], fill=255, width=2)
d.text((W // 2 - 60, 52), '640x360 TEST', fill=255)
save(img, 'bw_testcard.png')
if __name__ == '__main__':
gen_star()
gen_sweep()
gen_checker_sweep()
gen_zoneplate()
gen_testcard()
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 800x600 纯色测试图, 供 RGB 位交换(0x606E)验证使用
================================================================
配合 demo 板当前 EDID 首选时序 800x600@60, 全屏纯色输出。
用法: python gen_solid_colors.py → 输出到 ../assets/solid_*.png
在 HDMI-Tool 中通过自定义栏位(⚙ 换图)加载, 发布包随附。
"""
import os
from PIL import Image
W, H = 800, 600 # 与 demo 板 EDID 首选时序一致, 全屏铺满
SRC = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(SRC), 'assets')
# (文件名后缀, RGB 值, 用途)
COLORS = [
('FF0000', (0xFF, 0x00, 0x00), '通道映射: R=D[23:16]'),
('00FF00', (0x00, 0xFF, 0x00), '通道映射: G=D[15:8]'),
('0000FF', (0x00, 0x00, 0xFF), '通道映射: B=D[7:0]'),
('AA0000', (0xAA, 0x00, 0x00), 'R 交换检测: 0xAA(10101010)'),
('550000', (0x55, 0x00, 0x00), 'R 交换检测: 0x55(01010101)'),
('00AA00', (0x00, 0xAA, 0x00), 'G 交换检测'),
('005500', (0x00, 0x55, 0x00), 'G 交换检测'),
('0000AA', (0x00, 0x00, 0xAA), 'B 交换检测'),
('000055', (0x00, 0x00, 0x55), 'B 交换检测'),
]
def main():
os.makedirs(OUT, exist_ok=True)
for suffix, rgb, note in COLORS:
img = Image.new('RGB', (W, H), rgb)
path = os.path.join(OUT, f'solid_{suffix}.png')
img.save(path)
print(f'写出 {path} # {note}')
print(f'\n共 {len(COLORS)} 张, 分辨率 {W}x{H}(匹配 demo 板 800x600 EDID)。')
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
@echo off
rem HDMI source test tool launcher (bypasses PowerShell execution policy)
rem Examples:
rem hdmi_source.bat -List
rem hdmi_source.bat -Mode 640x360
rem hdmi_source.bat -Mode 640x360 -Pattern -Restore
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0hdmi_source.ps1" %*
+487
View File
@@ -0,0 +1,487 @@
<#
.SYNOPSIS
HDMI 源端测试工具 —— 枚举/切换输出分辨率 + 全屏测试图案
.DESCRIPTION
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
原理:电脑显卡通过 HDMI DDC 读取转接板 EDID 来枚举可用分辨率,
本脚本调用 Win32 API 完成模式枚举与切换,再用 WinForms 全屏窗口
输出测试图案。板载 EDID 只声明了 640x360@60(25.2MHz 像素时钟),
插上 HDMI 后显卡应只能发这一个模式;-List 可直接验证 EDID 是否生效。
测试图案用途:
Bars 100% 彩条 —— 肉眼/示波器验证 RGB 通道顺序与极性
GraySteps 16 级灰阶 —— 验证 RGB888 各 bit 连通性
Gradient R/G/B 三段渐变 —— 验证每通道 8bit 线性度
Checker 黑白棋盘格 —— 验证像素时钟 PCLK 与 DE 对位
Grid 网格+中心十字 —— 验证像素映射/偏移/缩放
Red/Green/Blue/White/Black/Gray50 纯色 —— 万用表/示波器测单通道电平
全屏窗口操作:空格或→下一图案,←上一图案,ESC 退出。
.EXAMPLE
tools\hdmi_source.bat -List
枚举所有活动显示器与显卡支持的分辨率(验证 EDID)
.EXAMPLE
tools\hdmi_source.bat -Monitor 1 -Mode 640x360
把显示器 1 切到 640x360(缺省 60Hz,可写 640x360@60)
.EXAMPLE
tools\hdmi_source.bat -Mode 640x360 -Pattern -Restore
切到 640x360 并全屏出图案,退出图案后恢复原分辨率
.EXAMPLE
tools\hdmi_source.bat -Pattern -PatternName Checker -Seconds 5
当前分辨率下全屏棋盘格,5 秒后自动退出(自动化测试用)
#>
param(
[switch]$List,
[int]$Monitor = 1,
[string]$Mode = '',
[switch]$Pattern,
[ValidateSet('', 'Bars', 'GraySteps', 'Gradient', 'Checker', 'Grid',
'Red', 'Green', 'Blue', 'White', 'Black', 'Gray50')]
[string]$PatternName = '',
[int]$Seconds = 0,
[switch]$Restore
)
$ErrorActionPreference = 'Stop'
# ---------------- Win32 显示 API ----------------
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
namespace HdmiSrc
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct DISPLAY_DEVICE
{
public int cb;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceString;
public int StateFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceID;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceKey;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct DEVMODE
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName;
public short dmSpecVersion;
public short dmDriverVersion;
public short dmSize;
public short dmDriverExtra;
public int dmFields;
public int dmPositionX;
public int dmPositionY;
public int dmDisplayOrientation;
public int dmDisplayFixedOutput;
public short dmColor;
public short dmDuplex;
public short dmYResolution;
public short dmTTOption;
public short dmCollate;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName;
public short dmLogPixels;
public int dmBitsPerPel;
public int dmPelsWidth;
public int dmPelsHeight;
public int dmDisplayFlags;
public int dmDisplayFrequency;
public int dmICMMethod;
public int dmICMIntent;
public int dmMediaType;
public int dmDitherType;
public int dmReserved1;
public int dmReserved2;
public int dmPanningWidth;
public int dmPanningHeight;
}
public static class Native
{
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum,
ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);
// PowerShell 会把 $null marshal 成空串而非 NULL,由 C# 侧保证传 NULL
public static bool EnumDisplayAdapters(uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice)
{
return EnumDisplayDevices(null, iDevNum, ref lpDisplayDevice, 0);
}
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum,
ref DEVMODE lpDevMode);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
public static extern int ChangeDisplaySettingsEx(string lpszDeviceName,
ref DEVMODE lpDevMode, IntPtr hwnd, int dwflags, IntPtr lParam);
}
}
"@
# 板载 EDID 声明的目标模式,-List 时用于自检
$TargetW = 640
$TargetH = 360
function New-DevMode {
$dm = New-Object HdmiSrc.DEVMODE
$dm.dmDeviceName = ''
$dm.dmFormName = ''
$dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)
return $dm
}
function New-DisplayDevice {
$dd = New-Object HdmiSrc.DISPLAY_DEVICE
$dd.DeviceName = ''
$dd.DeviceString = ''
$dd.DeviceID = ''
$dd.DeviceKey = ''
$dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)
return $dd
}
# 枚举所有"已连接到桌面"的显示器(即插着 HDMI 的输出口)
function Get-Displays {
$list = @()
$i = 0
while ($true) {
$dd = New-DisplayDevice
if (-not [HdmiSrc.Native]::EnumDisplayAdapters($i, [ref]$dd)) { break }
# StateFlags bit0 = ATTACHED_TO_DESKTOP
if ($dd.StateFlags -band 0x1) {
$mon = New-DisplayDevice
[void][HdmiSrc.Native]::EnumDisplayDevices($dd.DeviceName, 0, [ref]$mon, 0)
$list += [pscustomobject]@{
Index = $list.Count + 1
Device = $dd.DeviceName
Adapter = $dd.DeviceString
MonName = $mon.DeviceString
}
}
$i++
}
return $list
}
# 显卡枚举到的全部显示模式(去重)
function Get-Modes {
param([string]$Device)
$modes = @()
$i = 0
while ($true) {
$dm = New-DevMode
if (-not [HdmiSrc.Native]::EnumDisplaySettings($Device, $i, [ref]$dm)) { break }
$modes += [pscustomobject]@{
W = $dm.dmPelsWidth
H = $dm.dmPelsHeight
Hz = $dm.dmDisplayFrequency
Bpp = $dm.dmBitsPerPel
}
$i++
}
return ($modes | Sort-Object W, H, Hz, Bpp -Unique)
}
function Get-CurrentDevMode {
param([string]$Device)
$dm = New-DevMode
[void][HdmiSrc.Native]::EnumDisplaySettings($Device, -1, [ref]$dm) # ENUM_CURRENT_SETTINGS
return $dm
}
# 应用一个 DEVMODE(先 CDS_TEST 再真正生效),返回 $true/$false
function Set-DevMode {
param([string]$Device, $dm)
$dm.dmFields = 0x00040000 -bor 0x00080000 -bor 0x00100000 -bor 0x00400000
# DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY
$rc = [HdmiSrc.Native]::ChangeDisplaySettingsEx($Device, [ref]$dm, [IntPtr]::Zero, 2, [IntPtr]::Zero)
if ($rc -ne 0) {
Write-Host (" 错误: 显卡拒绝该模式 (CDS_TEST 返回 {0})" -f $rc) -ForegroundColor Red
return $false
}
$rc = [HdmiSrc.Native]::ChangeDisplaySettingsEx($Device, [ref]$dm, [IntPtr]::Zero, 0, [IntPtr]::Zero)
if ($rc -ne 0) {
Write-Host (" 错误: 模式应用失败 (返回 {0})" -f $rc) -ForegroundColor Red
return $false
}
return $true
}
# 在显卡枚举的模式里找 W x H [@Hz] 并切换
function Switch-Mode {
param([string]$Device, [int]$W, [int]$H, [int]$Hz)
$all = @(Get-Modes $Device)
$hit = @($all | Where-Object { $_.W -eq $W -and $_.H -eq $H -and ($Hz -le 0 -or $_.Hz -eq $Hz) })
if (-not $hit) {
Write-Host (" 错误: 显卡未枚举到 {0}x{1} 模式" -f $W, $H) -ForegroundColor Red
Write-Host " 可能原因:" -ForegroundColor Yellow
Write-Host " 1. 转接板 EDID 未被读取(检查 HPD / DDC / LT8619C 供电)"
Write-Host " 2. EDID 中未包含该分辨率(当前板载 EDID 只有 640x360@60)"
Write-Host " 3. 显卡驱动拒收该 DTD(可用 CRU 手动添加自定义分辨率)"
Write-Host " 先运行 -List 查看显卡实际枚举到了哪些模式。"
return $false
}
$dm = New-DevMode
$dm.dmPelsWidth = $W
$dm.dmPelsHeight = $H
$dm.dmDisplayFrequency = $hit[0].Hz
$dm.dmBitsPerPel = 32
$ok = Set-DevMode $Device $dm
if ($ok) {
Write-Host (" 已切换 {0} -> {1}x{2}@{3}" -f $Device, $W, $H, $hit[0].Hz) -ForegroundColor Green
}
return $ok
}
# ---------------- 全屏测试图案 ----------------
function Show-PatternWindow {
param(
[string]$Device = '',
[string]$StartPattern = '',
[int]$AutoSeconds = 0
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# 窗口放到目标设备名对应的屏幕上(如 \\.\DISPLAY2),找不到则回退主屏
$screen = [System.Windows.Forms.Screen]::AllScreens |
Where-Object { $_.DeviceName -eq $Device } |
Select-Object -First 1
if (-not $screen) { $screen = [System.Windows.Forms.Screen]::PrimaryScreen }
$script:patterns = @('Bars', 'GraySteps', 'Gradient', 'Checker', 'Grid',
'Red', 'Green', 'Blue', 'White', 'Black', 'Gray50')
$script:pi = 0
if ($StartPattern) {
$idx = [array]::IndexOf($script:patterns, $StartPattern)
if ($idx -ge 0) { $script:pi = $idx }
}
$script:form = New-Object System.Windows.Forms.Form
$script:form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
$script:form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
$script:form.Bounds = $screen.Bounds
$script:form.TopMost = $true
$script:form.KeyPreview = $true
# 双缓冲避免棋盘格/网格闪烁
$script:form.GetType().GetProperty('DoubleBuffered',
[System.Reflection.BindingFlags]'Instance,NonPublic').SetValue($script:form, $true, $null)
$osdFont = New-Object System.Drawing.Font('Consolas', 11)
$paintHandler = {
param($s, $e)
$g = $e.Graphics
$w = $e.ClipRectangle.Width
$h = $e.ClipRectangle.Height
$name = $script:patterns[$script:pi]
switch ($name) {
'Bars' {
# 100% 饱和度竖彩条:白黄青绿品红红蓝黑
$c = @(@(255,255,255), @(255,255,0), @(0,255,255), @(0,255,0),
@(255,0,255), @(255,0,0), @(0,0,255), @(0,0,0))
$n = $c.Count
for ($k = 0; $k -lt $n; $k++) {
$bw = [Math]::Ceiling($w / $n)
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb($c[$k][0], $c[$k][1], $c[$k][2]))
$g.FillRectangle($brush, [int]($k * $bw), 0, [int]$bw + 1, $h)
$brush.Dispose()
}
}
'GraySteps' {
# 16 级灰阶,验证 RGB888 各 bit
$n = 16
for ($k = 0; $k -lt $n; $k++) {
$v = [int]($k * 255 / ($n - 1))
$bw = [Math]::Ceiling($w / $n)
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb($v, $v, $v))
$g.FillRectangle($brush, [int]($k * $bw), 0, [int]$bw + 1, $h)
$brush.Dispose()
}
}
'Gradient' {
# R/G/B 三段水平渐变,验证每通道 8bit 线性度
$seg = [Math]::Ceiling($w / 3.0)
$rgbIdx = @(0, 1, 2)
foreach ($ch in $rgbIdx) {
for ($x = 0; $x -lt $seg; $x++) {
$v = [int](255 * $x / [Math]::Max(1.0, ($seg - 1)))
$r = 0; $gg = 0; $b = 0
if ($ch -eq 0) { $r = $v } elseif ($ch -eq 1) { $gg = $v } else { $b = $v }
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb($r, $gg, $b))
$g.FillRectangle($brush, [int]($ch * $seg + $x), 0, 1, $h)
$brush.Dispose()
}
}
}
'Checker' {
# 黑白棋盘格,验证 PCLK/DE 对位
$g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h)
$cell = 16
for ($y = 0; $y -lt $h; $y += $cell) {
for ($x = 0; $x -lt $w; $x += $cell) {
if ((([int]($x / $cell) + [int]($y / $cell)) % 2) -eq 0) {
$g.FillRectangle([System.Drawing.Brushes]::White, $x, $y, $cell, $cell)
}
}
}
}
'Grid' {
# 黑底 + 40px 网格 + 中心十字 + 白色边框,验证像素映射
$g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h)
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(0, 128, 0), 1)
for ($x = 40; $x -lt $w; $x += 40) { $g.DrawLine($pen, $x, 0, $x, $h) }
for ($y = 40; $y -lt $h; $y += 40) { $g.DrawLine($pen, 0, $y, $w, $y) }
$pen.Dispose()
$wp = New-Object System.Drawing.Pen ([System.Drawing.Color]::White, 2)
$cx = [int]($w / 2); $cy = [int]($h / 2)
$g.DrawLine($wp, $cx, 0, $cx, $h)
$g.DrawLine($wp, 0, $cy, $w, $cy)
$g.DrawRectangle($wp, 1, 1, $w - 3, $h - 3)
$wp.Dispose()
}
'Red' { $g.FillRectangle([System.Drawing.Brushes]::Red, 0, 0, $w, $h) }
'Green' { $g.FillRectangle([System.Drawing.Brushes]::Green, 0, 0, $w, $h) }
'Blue' { $g.FillRectangle([System.Drawing.Brushes]::Blue, 0, 0, $w, $h) }
'White' { $g.FillRectangle([System.Drawing.Brushes]::White, 0, 0, $w, $h) }
'Black' { $g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h) }
'Gray50' {
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb(128, 128, 128))
$g.FillRectangle($brush, 0, 0, $w, $h)
$brush.Dispose()
}
}
# 左上角 OSD:图案名 + 分辨率 + 按键提示
$text = "[{0}] {1}x{2} 空格/→:下一图案 ←:上一图案 ESC:退出" -f $name, $w, $h
$sz = $g.MeasureString($text, $osdFont)
$bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(150, 0, 0, 0))
$g.FillRectangle($bg, 6, 6, $sz.Width + 12, $sz.Height + 8)
$bg.Dispose()
$g.DrawString($text, $osdFont, [System.Drawing.Brushes]::White, 12, 10)
}
$script:form.Add_Paint($paintHandler)
$script:form.Add_KeyDown({
param($s, $e)
switch ($e.KeyCode) {
'Escape' { $s.Close() }
'Space' { $script:pi = ($script:pi + 1) % $script:patterns.Count; $s.Invalidate() }
'Right' { $script:pi = ($script:pi + 1) % $script:patterns.Count; $s.Invalidate() }
'Left' { $script:pi = ($script:pi - 1 + $script:patterns.Count) % $script:patterns.Count; $s.Invalidate() }
}
})
$script:form.Add_Activated({ [System.Windows.Forms.Cursor]::Hide() })
$script:form.Add_FormClosed({ [System.Windows.Forms.Cursor]::Show() })
if ($AutoSeconds -gt 0) {
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = $AutoSeconds * 1000
$timer.Add_Tick({ $script:form.Close() })
$timer.Start()
}
Write-Host ("全屏图案已启动于 {0} ({1}x{2})" -f $screen.DeviceName, $screen.Bounds.Width, $screen.Bounds.Height)
Write-Host "空格/→:下一图案 ←:上一图案 ESC:退出"
[void]$script:form.ShowDialog()
}
function Show-Usage {
Write-Host @"
用法:
hdmi_source.bat -List 枚举显示器与可用分辨率(验证 EDID)
hdmi_source.bat -Monitor <n> -Mode <WxH[@Hz]> 切换分辨率, 如 -Mode 640x360 或 640x360@60
hdmi_source.bat [-Monitor <n>] -Pattern [-PatternName <名>] [-Seconds <n>]
全屏测试图案(Bar/GraySteps/Gradient/Checker/Grid/纯色)
可选: -Restore 退出图案窗口后恢复切换前的分辨率
-Seconds <n> 图案 n 秒后自动退出(自动化测试)
典型调试流程:
1. HDMI 插上转接板 -> -List 确认出现 640x360(EDID 生效)
2. -Mode 640x360 -Pattern -> 全屏图案, 逐项验证 RGB 通道/位序/像素映射
"@
}
# ---------------- 主流程 ----------------
$displays = @(Get-Displays) # @() 保证单显示器时不退化为标量
if ($displays.Count -eq 0) {
Write-Host "错误: 未找到活动显示器" -ForegroundColor Red
exit 1
}
if ($List) {
foreach ($d in $displays) {
Write-Host ""
Write-Host ("== 显示器 {0} {1} 显卡[{2}] 监视器[{3}]" -f $d.Index, $d.Device, $d.Adapter, $d.MonName) -ForegroundColor Cyan
$cur = Get-CurrentDevMode $d.Device
Write-Host (" 当前模式: {0}x{1}@{2} {3}bpp" -f $cur.dmPelsWidth, $cur.dmPelsHeight, $cur.dmDisplayFrequency, $cur.dmBitsPerPel)
$modes = Get-Modes $d.Device
$modes | Format-Table W, H, Hz, Bpp -AutoSize
$hasTarget = $modes | Where-Object { $_.W -eq $TargetW -and $_.H -eq $TargetH }
if ($hasTarget) {
Write-Host (" [OK] 找到板载 EDID 目标模式 {0}x{1} -> EDID 已被显卡读取" -f $TargetW, $TargetH) -ForegroundColor Green
} else {
Write-Host (" [--] 未找到 {0}x{1} (若此口接的是转接板, 说明 EDID/HPD 未生效)" -f $TargetW, $TargetH) -ForegroundColor Yellow
}
}
exit 0
}
if (-not $Mode -and -not $Pattern) {
Show-Usage
exit 0
}
if ($Monitor -gt $displays.Count) {
Write-Host ("错误: -Monitor 超出范围(共 {0} 个活动显示器, 运行 -List 查看)" -f $displays.Count) -ForegroundColor Red
exit 1
}
$disp = $displays[$Monitor - 1]
Write-Host ("目标: 显示器 {0} = {1}" -f $disp.Index, $disp.Device)
$prevMode = $null
if ($Mode) {
if ($Mode -notmatch '^\s*(\d+)\s*[xX]\s*(\d+)\s*(?:@\s*(\d+)\s*)?$') {
Write-Host "错误: -Mode 格式应为 WxH 或 WxH@Hz, 例如 640x360 或 640x360@60" -ForegroundColor Red
exit 1
}
$w = [int]$Matches[1]; $h = [int]$Matches[2]
$hz = 0
if ($Matches[3]) { $hz = [int]$Matches[3] }
$prevMode = Get-CurrentDevMode $disp.Device
$ok = Switch-Mode $disp.Device $w $h $hz
if (-not $ok) { exit 1 }
Start-Sleep -Milliseconds 300 # 等显卡/显示器完成模式切换
}
if ($Pattern) {
if ($Restore -and -not $prevMode) {
Write-Host "提示: -Restore 仅在与 -Mode 同用时生效" -ForegroundColor Yellow
}
try {
Show-PatternWindow -Device $disp.Device -StartPattern $PatternName -AutoSeconds $Seconds
}
finally {
if ($Restore -and $prevMode) {
Write-Host ("恢复原分辨率 {0}x{1}@{2} ..." -f $prevMode.dmPelsWidth, $prevMode.dmPelsHeight, $prevMode.dmDisplayFrequency)
[void](Set-DevMode $disp.Device $prevMode)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
# Kingst VIS 压缩边沿流分析: 数字波形按占空比签名自动分段, 输出行结构/占空比/消隐明细
# 用法: python la_wave_analyzer.py <capture.csv>
# CSV 列: Time[s], 通道A, 通道B (时间分辨率 10ns; 默认抓 B 通道 D0/D1)
# 判读基线见 doc/architecture/rgb_bit_swap_test.md §1.1
import csv, statistics, time, sys
PATH = sys.argv[1] if len(sys.argv) > 1 else \
r"E:\Hulk-Coding\LT8619C_DLPC3421_HDMI\tools\wave\B-D0-D7.csv"
CLK_NS = 25.0 # 40MHz 像素时钟
t0 = time.time()
rows = []
with open(PATH, encoding='utf-8-sig', errors='replace') as f:
r = csv.reader(f)
next(r) # header
for row in r:
if len(row) < 3:
continue
rows.append((float(row[0]) * 1e9, int(row[1]), int(row[2]))) # ns
n = len(rows)
t_start, t_stop = rows[0][0], rows[-1][0] + 1000.0 # 末行状态延续按1us估
print(f"rows={n} span={t_stop - t_start / 1e9:.9f}s")
# ---- 按行区间累计高低时间, 50ms 窗口计算占空比 ----
W = 50_000_000.0 # ns
win = {'t0': t_start, 'hi0': 0.0, 'hi1': 0.0}
wins = []
for i in range(n - 1):
t, d0, d1 = rows[i]
dt = min(rows[i + 1][0], t_stop) - t
if dt <= 0:
continue
win['hi0'] += dt if d0 else 0.0
win['hi1'] += dt if d1 else 0.0
if rows[i + 1][0] - win['t0'] >= W:
span = rows[i + 1][0] - win['t0']
wins.append((win['t0'], win['hi0'] / span, win['hi1'] / span))
win = {'t0': rows[i + 1][0], 'hi0': 0.0, 'hi1': 0.0}
# ---- 窗口分类: 依 (d0duty, d1duty) 签名 ----
def classify(a, b):
hi = lambda x: x > 0.5
if hi(a) and hi(b):
return 'FF'
if hi(a) and not hi(b):
return '55'
if not hi(a) and hi(b):
return 'AA'
return '00'
segs = []
for t, a, b in wins:
c = classify(a, b)
if segs and segs[-1][0] == c:
segs[-1][1].append((t, a, b))
else:
segs.append((c, [(t, a, b)]))
print(f"\n{'段':<4}{'签名':<6}{'时长(s)':<10}{'D0占空':<10}{'D1占空':<10}")
for i, (c, ws) in enumerate(segs):
dur = (ws[-1][0] - ws[0][0]) / 1e9
a = statistics.mean(x[1] for x in ws)
b = statistics.mean(x[2] for x in ws)
print(f"{i:<6}{c:<8}{dur:<12.2f}{a:<12.1%}{b:<12.1%}")
# ---- 每段: 行周期 + 有效区宽度(长高电平段) ----
print(f"\n按段结构分析 (时钟数 @40MHz):")
for i, (c, ws) in enumerate(segs):
seg_t0 = ws[0][0] - W / 2
seg_t1 = ws[-1][0] + W / 2
ch = 0 if c in ('FF', '55') else 1 # 选有效区为高的通道
# 找长高电平段(>=15us)及其起点
starts, widths = [], []
for j in range(n - 1):
t, d0, d1 = rows[j]
if t < seg_t0 or t > seg_t1:
continue
lvl = d0 if ch == 0 else d1
if lvl and rows[j + 1][0] - t >= 15_000.0:
starts.append(t)
widths.append(rows[j + 1][0] - t)
if len(starts) < 3:
print(f" 段{i} [{c}]: 线段不足({len(starts)}), 跳过")
continue
periods = [b - a for a, b in zip(starts, starts[1:]) if 20_000 < b - a < 35_000]
mp = statistics.median(periods)
mw = statistics.median(widths)
print(f" 段{i} [{c}]: 行数~{len(starts)} 行周期={mp:.0f}ns={mp/CLK_NS:.1f}clk "
f"有效高={mw:.0f}ns={mw/CLK_NS:.1f}clk 消隐={(mp-mw)/CLK_NS:.1f}clk")
# ---- 每段抽一行: 消隐区的边沿明细 ----
print(f"\n消隐区边沿明细(每段第一行, ns 相对行起点, clk=时钟数):")
for i, (c, ws) in enumerate(segs):
seg_t0 = ws[0][0] - W / 2
ch = 0 if c in ('FF', '55') else 1
line_start = None
for j in range(n - 1):
t, d0, d1 = rows[j]
if t < seg_t0:
continue
lvl = d0 if ch == 0 else d1
if lvl and rows[j + 1][0] - t >= 15_000.0:
line_start = t
break
if line_start is None:
continue
print(f" 段{i} [{c}] 行起点+有效区之后:")
j0 = next(k for k in range(n - 1) if rows[k][0] >= line_start)
out = []
for k in range(j0, min(j0 + 30, n - 1)):
t, d0, d1 = rows[k]
dt = rows[k + 1][0] - t
rel = t - line_start
out.append(f" +{rel/1000:8.2f}us D0={d0} D1={d1} 持续{dt/CLK_NS:6.1f}clk")
if rel > 30_000:
break
print('\n'.join(out[:18]))
print(f"\n耗时 {time.time()-t0:.1f}s")
+74
View File
@@ -0,0 +1,74 @@
# Kingst VIS 3 通道边沿流分段: 按占空比签名分段, 输出各通道占空比
# 用法: python la_wave_analyzer3.py <capture.csv> (列: Time[s], ch8, ch9, ch10)
import csv, time, statistics
import sys
PATH = sys.argv[1] if len(sys.argv) else r"E:\Hulk-Coding\LT8619C_DLPC3421_HDMI\tools\wave\A1_RGB-SWAP.csv"
t0 = time.time()
rows = []
with open(PATH, encoding='utf-8-sig', errors='replace') as f:
r = csv.reader(f)
hdr = next(r)
print('header:', hdr)
for row in r:
if len(row) < 4:
continue
rows.append((float(row[0]) * 1e9, int(row[1]), int(row[2]), int(row[3])))
n = len(rows)
t_last = rows[-1][0] + 1000.0
print('rows=%d span=%.2fs' % (n, (t_last - rows[0][0]) / 1e9))
W = 50_000_000.0
wins = []
acc = {'t0': rows[0][0], 'hi': [0.0, 0.0, 0.0]}
for i in range(n - 1):
t, a, b, c = rows[i]
dt = min(rows[i + 1][0], t_last) - t
if dt <= 0:
continue
acc['hi'][0] += dt if a else 0.0
acc['hi'][1] += dt if b else 0.0
acc['hi'][2] += dt if c else 0.0
if rows[i + 1][0] - acc['t0'] >= W:
span = rows[i + 1][0] - acc['t0']
wins.append((acc['t0'], tuple(x / span for x in acc['hi'])))
acc = {'t0': rows[i + 1][0], 'hi': [0.0, 0.0, 0.0]}
def sig(d):
hi = [x > 0.5 for x in d]
return ('D7' if hi[0] else '--') + '/' + ('D15' if hi[1] else '--') + '/' + ('D23' if hi[2] else '--')
segs = []
for t, d in wins:
s = sig(d)
if segs and segs[-1][0] == s:
segs[-1][1].append((t, d))
else:
segs.append((s, [(t, d)]))
print('\n%-16s %10s %9s %9s %9s' % ('活动组签名', '时长(s)', 'D7占空', 'D15占空', 'D23占空'))
for s, ws in segs:
dur = ws[-1][0] - ws[0][0]
m = [statistics.mean(x[1][k] for x in ws) for k in range(3)]
print('%-16s %10.2f %8.1f%% %8.1f%% %8.1f%%' % (s, dur, m[0] * 100, m[1] * 100, m[2] * 100))
# 每段行结构抽查(第一段一次即可)
s0, ws0 = segs[0]
ch = 0
starts = []
seg_t0 = ws0[0][0] - W / 2
for j in range(n - 1):
t = rows[j][0]
if t < seg_t0:
continue
if rows[j][1] and rows[j + 1][0] - t >= 15000.0:
starts.append(t)
if len(starts) >= 5 or t > seg_t0 + 0.3e9:
break
if len(starts) >= 2:
p = statistics.median([b - a for a, b in zip(starts, starts[1:])])
print('\n行周期抽查: %.0fns (%.1f clk @40MHz)' % (p, p / 25.0))
print('耗时 %.1fs' % (time.time() - t0))
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#000000"/>
<rect x="38" y="64" width="436" height="186" rx="34" fill="#FF9900"/>
<text x="256" y="210" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#000000" text-anchor="middle">HDMI</text>
<text x="256" y="421" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#FFFFFF" text-anchor="middle">Tool</text>
</svg>

After

Width:  |  Height:  |  Size: 506 B

+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 logo_ph_orange.svg 同款设计栅格化为多尺寸 .ico(供 exe 图标)
================================================================
设计参数与 SVG 严格一致: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34,
HDMI 146px Arial Bold 黑字(框内居中), Tool 146px 白字(基线 421)。
输出: logos/logo_ph_orange.png(512, 预览/复用)
logos/logo_ph_orange.ico(16~256 七档, exe 图标用)
用法: python make_ico.py
"""
import os
from PIL import Image, ImageDraw, ImageFont
BASE = os.path.dirname(os.path.abspath(__file__))
FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配
def render(size=512):
s = size / 512.0
img = Image.new('RGB', (size, size), (0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s],
radius=34 * s, fill=(255, 153, 0))
if not os.path.exists(FONT):
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
f = ImageFont.truetype(FONT, max(8, round(146 * s)))
cx = size / 2
# HDMI: 居中于橙色框
d.text((cx, 64 * s + 186 * s / 2), 'HDMI', font=f, fill=(0, 0, 0), anchor='mm')
# Tool: 与 SVG 基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368)
d.text((cx, 368 * s), 'Tool', font=f, fill=(255, 255, 255), anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'logo_ph_orange.png')
ico = os.path.join(BASE, 'logo_ph_orange.ico')
img.save(png)
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)])
print(f'写出 {png}')
print(f'写出 {ico}')
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 HDMI-Tool 发布包: exe + 附属资产 → 一个 zip(解压即用)
================================================================
发布内容(zip 解压后根目录为 HDMI-Tool/, 与 exe 运行目录结构一致):
HDMI-Tool.exe 主程序(先运行 build_exe.py 打包)
pattern_slots.json 预置自定义栏位(指向前 3 张 bw 图案)
bw_*.png 640x360 黑白测试图 ×5(自定义栏位用)
solid_*.png 800x600 纯色测试图 ×10(RGB 位交换测试用)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(HDMI-Tool-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/HDMI-Tool-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import re
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
ASSETS = os.path.join(BASE, 'assets')
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png')
def detect_version():
"""从最近的 HDMI-Tool-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'HDMI-Tool-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('HDMI-Tool-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'HDMI-Tool.exe')
slots = os.path.join(RELEASE, 'pattern_slots.json')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/HDMI-Tool.exe, 先运行: python build_exe.py')
for must in (slots, readme):
if not os.path.exists(must):
sys.exit(f'缺少 {must}')
# 依 pattern_slots.json 校验栏位引用的图片都在 assets/ 里
slot_files = re.findall(r'"file":\s*"([^"]+)"', open(slots, encoding='utf-8').read())
for f in slot_files:
if not os.path.exists(os.path.join(ASSETS, f)):
sys.exit(f'pattern_slots.json 引用的 {f} 不在 assets/ 中')
patterns = sorted(p for g in PATTERN_GLOBS
for p in os.listdir(ASSETS)
if re.fullmatch(g.replace('*', '.*'), p))
zip_name = f'HDMI-Tool-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
for src, arc in ([(exe, 'HDMI-Tool.exe'), (slots, 'pattern_slots.json'),
(readme, 'README.md')]
+ [(os.path.join(ASSETS, p), p) for p in patterns]):
z.write(src, f'HDMI-Tool/{arc}')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('内容: HDMI-Tool.exe, pattern_slots.json, README.md +', f'{len(patterns)} 张图案')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py HDMI-Tool-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag HDMI-Tool-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(HDMI-Tool-vX.Y.Z-win64.zip → HDMI-Tool-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## HDMI-Tool {version}
HDMI 源端调试工具(LT8619C → DLPC3421 转接板配套): EDID 查看/逐字节解析、
分辨率切换、纯色与测试图案全屏投屏。
### 安装
下载 `HDMI-Tool-v{version}-win64.zip`, 解压后双击 `HDMI-Tool.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `HDMI-Tool.exe` — 主程序
- `pattern_slots.json` — 预置自定义栏位(指向 bw_*.png)
- `bw_*.png` ×5 / `solid_*.png` ×10 — 测试图案
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
for asset in (rel.get('assets') or {}).get('items', []):
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('HDMI-Tool-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'HDMI-Tool v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()