分类: 学习笔记

  • 【摄影】Nikon F90X/N90s F90/N90系列胶片相机串口通信协议逆向项目

    基于尼康官方软件AC-PW,提取其核心 Pcf9032.dll逆向
    由 DeepSeek 4.0 Pro 辅助完成。


    第一章 概述

    1.1 适用范围

    本报告完整描述 F90X/N90s F90/N90 胶片单反相机通过串行接口与计算机通信的底层协议、命令集、寄存器映射、功能编号及所有操作序列。所有内容均来自对官方控制库 Pcf9032.dll 及配套软件 PhotoSecretary的逆向分析,并通过资源提取进行验证。

    1.2 通信架构

    • 物理层:RS-232C 串行通信,5V 电平
    • 波特率:初始 1200 bps,握手后切换至 9600 bps
    • 数据格式:8 数据位,无校验,1 停止位 (8N1)
    • 端口选择:COM1~COM4,由功能 ID 0 设定
    • DLL 文件:Pcf9032.dll(Nikon 相机通信库),由主程序动态加载

    1.3 相机型号识别

    型号标识相机返回字符串地址字节对应型号
    1F90N900x10F90系列/N90(北美发售型号)系列
    2F90xN90s0x20F90X系列/N90S(北美发售型号)系列

    第二章 通信初始化

    2.1 唤醒与型号识别

    1. 打开串口 COM%d,配置为 1200,8N1。
    2. 发送唤醒字节:00(1 字节),等待 200ms,清空接收缓冲区。
    3. 发送型号查询:53 31 30 30 30 05S1000 + ENQ,共 6 字节)。
    4. 接收相机返回的标识字符串(例如 31 30 32 30 46 39 30 58 2F 4E 39 30 53 00 03 06 表示 1020F90X/N90S)。
    5. 根据字符串确定型号:
      • 若包含 F90N90 → 型号 1,地址字节 0x10。
      • 若包含 F90xN90s → 型号 2,地址字节 0x20。

    2.2 波特率升级至 9600

    1. 发送 9 字节命令(型号 1 用 0x10,型号 2 用 0x20):
      01 10 87 05 00 00 00 00 03 或 01 20 87 05 00 00 00 00 03
    2. 等待相机应答 06 00(ACK)。
    3. 将本地串口切换为 9600 bps,等待 200ms 稳定。

    2.3 结束 9600 会话(返回 1200)

    • 发送 04 04(EOT 两次),相机返回 04 04 确认,然后串口恢复到 1200。
    • 若相机自动休眠,波特率也会自动降回 1200。

    第三章 通信帧格式

    校验和仅计算纯数据部分,即从 STX (0x02) 之后的第一个字节开始,到 ETX (0x03) 之前的所有字节,不包括帧头(SOH)、地址、命令码、长度字段等任何头部字节。

    波特率切换等特殊握手指令会使用 0x87 作为操作码,此类特殊指令属于硬编码控制帧,不完全遵循常规寄存器读写帧的载荷结构。

    3.1 标准命令帧(命令码 ≤ 100)

    读请求(无数据)

    偏移 大小 值       说明
    0 1 0x01 帧头 (SOH)
    1 1 ADDR 设备地址 (0x10 或 0x20)
    2 1 0x80 读操作
    3 1 0x00 保留
    4 1 v6 寄存器地址高字节
    5 1 v7 寄存器地址低字节
    6 1 0x00 保留
    7 1 len 期望接收的数据长度
    8 1 0x03 帧尾 (ETX)

    总长 9 字节,无校验。

    写请求(有数据)

    偏移 大小 值       说明
    0    1    0x01     帧头
    1    1    ADDR     设备地址
    2    1    0x81     写操作
    3    1    0x00     保留
    4    1    v6       寄存器地址高字节
    5    1    v7       寄存器地址低字节
    6    1    0x00     保留
    7    1    len      数据长度(实际发送字节数)
    8    1    0x02     数据起始标识 (STX)
    9~   len   DATA    数据体
    9+len  1   CHK     校验和(所有 DATA 字节累加和的低 8 位)
    10+len 1   0x03     帧尾 (ETX)
    11+len 1   0x00     填充

    总长 12 + len 字节。

    3.2 扩展命令帧(命令码 > 100)

    读请求

    偏移 大小 值       说明
    0    1    0x01     帧头
    1    1    ADDR     设备地址
    2    1    0x1B     命令组标识
    3    1    0x90     命令组标识
    4    1    0x82     读操作
    5    1    v6       参数(由命令码决定)
    6    1    len      期望接收长度
    7    1    0x00     保留
    8    1    0x03     帧尾

    总长 9 字节。

    写请求

    偏移 大小 值       说明
    0    1    0x01     帧头
    1    1    ADDR     设备地址
    2    1    0x1B     命令组标识
    3    1    0x90     命令组标识
    4    1    0x81     写操作
    5    1    v6       参数
    6    1    len      数据长度
    7~   len   DATA    数据体
    7+len 1    CHK     校验和
    8+len 1    0x03     帧尾
    9+len 1    0x00     填充

    总长 10 + len 字节。

    3.3 应答帧

    • 短应答(ACK)06 00 表示成功。
    • 特殊同步应答04 04 用于同步。
    • 长应答(数据包)
      • 字节 0:状态码(通常忽略,或为 0x01)
      • 字节 1~(n-2):数据
      • 字节 (n-1):校验和(字节 1 到字节 n-2 的累加和低 8 位)
      • 字节 n:0x03 (ETX)

    3.4 重试机制

    发送命令后,最多重试 4 次(外层循环)× 4 次(内层发送尝试)。每次发送失败等待 200ms,若连续 4 次内层失败则退出内层;内层结束后等待 150ms 再进入下一次外循环。


    第四章 命令码与寄存器地址映射表

    4.1 标准命令码表(≤100)

    从 DLL 函数 sub_1000DB73 的 switch 分支提取。v6v7 以十六进制补码表示(负值转换为无符号字节,例 -3 = 0xFD)。

    命令码v6 (hex)v7 (hex)寄存器地址常用数据长度方向关联功能ID
    0FD19FD191读/写
    1FD17FD172(读)/6(写)读/写
    2FD1AFD1A1读/写
    3FD1CFD1C1读/写
    4FD1BFD1B1读/写
    5FD16FD161读/写
    6FD28FD28113
    7FD26FD26114,15,16
    8FD27FD27114,15,16
    9FD29FD29118
    10FD2BFD2B120
    11FD2AFD2A119
    12FD2DFD2D121
    13FD2EFD2E126
    14FD21FD211
    15FD2CFD2C122,23
    16FE44FE442读/写
    17FEC9/C1FE?1型号相关
    18FD20FD201读/写0x64,0x66
    19FD39FD391读/写
    20FD3AFD3A1读/写0x64,0x66
    21FD3BFD3B1读/写0x64
    22FD3CFD3C2读/写0x64
    23FED0FED01用户设置
    24FDF2FDF22用户设置
    25FDF3FDF31读/写
    26FE94FE942用户设置
    27FE95FE951读/写
    28FE9EFE9E1用户设置
    29FD25FD25117
    30FD36FD363读/写90-95
    31FEC8FEC81用户设置
    32FD33FD333(型号1)读/写0x46-0x52
    33FD34FD341状态检测
    34FD40FD401读/写131
    35FD30FD306(型号2)读/写0x46-0x52
    36FD41FD413读/写
    37FD24FD241状态查询
    38FE50FE501状态检测
    39FE20FE201读/写
    40FEDB/D4FE?1型号相关
    41FD93/8EFD?1型号相关
    42FD9D/90FD?1型号相关
    43FE06FE061
    44FD1FFD1F10x64

    4.2 扩展命令码表(>100)

    从 sub_1000DED3 的 switch 提取,命令码对应 ASCII 字符,v6 为帧内参数。

    命令码ASCIIv6 (hex)常用数据长度方向
    101‘e’003
    102‘f’065
    103‘g’101
    118‘v’701读/写
    119‘w’722读/写
    122‘z’801

    第五章 功能 ID 完整映射

    5.1 设定/读取函数导出索引

    导出序号函数名用途
    1FeatureExists检查功能ID是否有效
    2GetIndexString获取功能选项文本
    3GetMaxIndex获取选项最大索引
    8GetValueBool读取布尔型参数
    9GetValueFloat未实现
    10GetValueIndex读取索引型参数
    11GetValueInteger读取整型参数
    12GetValueString读取字符串参数
    13GetValueStruct读取结构体参数
    14InvalidateReads清除读取缓存
    15SetValueBool设置布尔型参数
    16SetValueFloat未实现
    17SetValueIndex设置索引型参数
    18SetValueInteger设置整型参数
    19SetValueString设置字符串参数
    20SetValueStruct设置结构体参数
    21StartProcess执行动作(拍摄、对焦、删除等)
    23WriteDataToCamera将修改的参数写入相机

    5.2 功能 ID → 命令/寄存器映射

    以下列出全部功能 ID(0~147)的精确技术映射,包括类型、设置/读取函数、所属 WriteDataToCamera 的 case、具体的命令码、寄存器地址(v6, v7)或扩展命令标识,以及在配置块中的位操作说明。

    功能 ID类型设置函数读取函数Case命令码 / 寄存器操作描述
    0IntegerSetValueInteger直接设置全局变量 word_10015218通信端口号 (1~4)
    1未使用
    2StringGetValueString直接返回相机型号字符串指针相机型号字符串 (“F90N90” 或 “F90xN90s”)
    3BoolSetValueBool直接设置 word_100122D4相机型号选择 (0=型号1, 1=型号2)
    4IndexGetValueIndex特殊:读取 word_10012170 数组电子手帐数据读取状态/进度
    5IntegerGetValueInteger直接读取 dword_10012000内部状态值(只读)
    6BoolGetValueBool5通过 sub_10008905 读取 word_100122BC未知布尔状态(只读)
    7~12未使用
    13IndexSetValueIndexGetValueIndex1命令 6 → FD28测光方式 (0=矩阵, 1=中央重点, 2=点)
    14IndexSetValueIndexGetValueIndex1命令 7/8 → FD26, FD27;位操作曝光模式 (0=P,1=S,2=A,3=M,4=CP,5-11=变程序)
    15IndexSetValueIndexGetValueIndex1命令 7/8 → FD26, FD27;位操作曝光模式(与14共用寄存器,不同位)
    16BoolSetValueBoolGetValueBool1命令 7/8 → FD26, FD27;位操作曝光模式扩展布尔
    17IndexSetValueIndexGetValueIndex1命令 29 → FD25快门速度 (B门/定时相关)
    18IndexSetValueIndexGetValueIndex1命令 9 → FD29卷片模式 (0=单张, 1=低速连拍, 2=高速连拍)
    19IndexSetValueIndexGetValueIndex1命令 11 → FD2A闪光同步模式 (0=前帘,1=慢速,2=后帘,3=防红眼)
    20IndexSetValueIndexGetValueIndex1命令 10 → FD2B对焦区域 (0=宽区, 1=点)
    21IndexSetValueIndexGetValueIndex1命令 12 → FD2D曝光补偿范围 (-5.0~+5.0 EV)
    22BoolSetValueBoolGetValueBool1命令 15 → FD2C;位操作DX/手动 ISO 选择
    23IndexSetValueIndexGetValueIndex1命令 15 → FD2CISO 感光度 (0=ISO 6, 1=ISO 8 … 30=ISO 6400)
    24IndexGetValueIndex1命令 15 读回 FD2CISO 扩展读取
    26IndexSetValueIndexGetValueIndex1命令 13 → FD2E闪光补偿/特殊设定
    27~28未使用
    29BoolGetValueBool3word_100123F2(通过 sub_100058C7 读取)MF-26 状态(只读)
    30BoolGetValueBool3word_100123F4(通过 sub_100058C7 读取)MF-26 状态(只读)
    31IndexSetValueIndexGetValueIndex3命令 18,20 等,位操作 FD20, FD3A 等MF-26 曝光模式
    32IndexSetValueIndexGetValueIndex3同上MF-26 曝光模式
    33IndexSetValueIndexGetValueIndex3命令 6 → FD28测光方式 (MF-26)
    34IndexSetValueIndexGetValueIndex3命令 9 → FD29卷片模式 (MF-26)
    35IndexSetValueIndexGetValueIndex3命令 11 → FD2A闪光同步模式 (MF-26)
    36IndexSetValueIndexGetValueIndex3命令 10 → FD2B对焦区域 (MF-26)
    37~39未使用
    40IndexSetValueIndexGetValueIndex1/8命令 1 → FD17用户设置库名称
    41IndexGetValueIndex1/8命令 1/7 读取用户设置库当前选择
    42IndexGetValueIndex8命令 43 → FE06镜头光圈值 (F1~F90)
    43IndexSetValueIndexGetValueIndex8命令 43 → FE06 等镜头光圈值(设定)
    44IndexSetValueIndexGetValueIndex1命令 9 → FD29卷片模式(另一入口)
    45IndexSetValueIndexGetValueIndex8命令 45闪光同步(另一设定)
    46BoolSetValueBoolGetValueBool8命令 15 → FD2C位操作对焦模式 (0=手动, 1=AF 等)
    47IndexGetValueIndex8命令 47ISO 显示(备用)
    48IndexSetValueIndexGetValueIndex8命令 10 → FD2B对焦区域(另一入口)
    49IndexSetValueIndexGetValueIndex8命令 49AF 方式 (0=释放优先, 1=对焦优先, 2=手动)
    50IndexGetValueIndex8命令 50对焦状态选项
    51IndexSetValueIndexGetValueIndex8命令 51对焦状态 (0=前焦,1=后焦,2=合焦,3=无法合焦)
    52IndexSetValueIndexGetValueIndex8命令 52ON/OFF 开关
    53IndexSetValueIndexGetValueIndex8命令 53曝光补偿值 (-20.0~+20.0)
    54IndexSetValueIndexGetValueIndex8命令 54曝光补偿值(另一组)
    55IndexSetValueIndexGetValueIndex8命令 55胶片计数器 (E, 0~99)
    56IndexSetValueIndexGetValueIndex8命令 56电池状态 (0=满电, 1=低电量)
    57IndexGetValueIndex8命令 57特定索引
    58IndexGetValueIndex8命令 58曝光补偿范围 (-5.0~+5.0 EV)
    59IndexSetValueIndexGetValueIndex8命令 59镜头焦距 (5mm~7600mm)
    60IndexSetValueIndexGetValueIndex8命令 60镜头焦距(另一组)
    61IndexSetValueIndexGetValueIndex8命令 61镜头焦距(另一组)
    62IndexGetValueIndex8命令 62光圈值(另一组)
    63IndexGetValueIndex8命令 63特定索引
    64IndexGetValueIndex8命令 64光圈值(另一组)
    65IndexGetValueIndex1/8命令 1 读取用户库读取
    66IndexSetValueIndexGetValueIndex1/8命令 6 → FD28测光方式(另一入口)
    67IndexSetValueIndexGetValueIndex8命令 67闪光同步(另一组)
    68IndexSetValueIndexGetValueIndex8命令 68镜头焦距(另一组)
    69BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)蜂鸣音开关(恢复默认用)
    70 (0x46)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)合焦音开关
    71 (0x47)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)DX 优先设定
    72 (0x48)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)AE/AF 同时锁定
    73 (0x49)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)AF-C 对焦优先
    74 (0x4A)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)AF-S 释放优先
    75 (0x4B)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)AF-S 帧间 AF 驱动
    76 (0x4C)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)0 帧数据印记
    77 (0x4D)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)测光偏差显示
    78 (0x4E)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)A 模式简易曝光补偿
    79 (0x4F)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)(保留)
    80 (0x50)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)闪光灯相关设定
    81 (0x51)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)闪光灯相关设定
    82 (0x52)BoolSetValueBoolGetValueBool4配置块位操作 (命令32/35)闪光灯相关设定
    83IndexSetValueIndexGetValueIndex4配置块 (命令32/35)闪光灯模式
    84IndexSetValueIndexGetValueIndex4配置块 (命令32/35)长时间曝光时间 (4秒~60秒/B门)
    85IndexSetValueIndexGetValueIndex4配置块 (命令32/35)同步释放模式
    86IndexSetValueIndexGetValueIndex4配置块 (命令32/35)(预留)
    87IndexSetValueIndexGetValueIndex4配置块 (命令32/35)自拍/间隔时间
    88StartProcess(0x58) 调用恢复默认设置(操作码)
    89StartProcess(0x89) 调用触发拍摄(操作码)
    90IndexSetValueIndexGetValueIndex7命令 30 → FD36闪光灯/自定义程序点 (快门)
    91IndexSetValueIndexGetValueIndex7命令 30 → FD36闪光灯/自定义程序点 (光圈)
    92IndexSetValueIndexGetValueIndex7命令 30 → FD36同上
    93IndexSetValueIndexGetValueIndex7命令 30 → FD36同上
    94IndexSetValueIndexGetValueIndex7命令 30 → FD36同上
    95IndexSetValueIndexGetValueIndex7命令 30 → FD36同上
    96StartProcess(0x60) 调用闪光灯操作(操作码)
    97IndexGetValueIndex8命令 97微调值
    98IndexSetValueIndexGetValueIndex8/11命令 98微调值 (0.3~2.0)
    99IndexSetValueIndexGetValueIndex8/11命令 99曝光补偿步长 (-3.0~+1.0)
    100Bool8(保留)
    101IntegerGetValueInteger8读取多个配置寄存器配置块值(只读)
    102~105未使用
    106IndexSetValueIndexGetValueIndex9扩展命令 103, 122 等时区城市 (1~24)
    107未使用
    108Index9扩展命令时区城市(备用)
    109IndexSetValueIndexGetValueIndex10扩展命令时差数值 (-10~+12, -11)
    110IndexSetValueIndexGetValueIndex10扩展命令时差数值 (-11)
    111~114未使用
    115IndexGetValueIndex11扩展命令模式选择
    116IndexSetValueIndexGetValueIndex11扩展命令微调值 (另一入口)
    117未使用
    118IndexSetValueIndexGetValueIndex11扩展命令曝光补偿步长 (同 99)
    119IndexSetValueIndexGetValueIndex11扩展命令特殊设定(自拍/闪光相关)
    120~123未使用
    124StructGetValueStruct11扩展命令读取结构体 (2 字段)
    125StructGetValueStruct11扩展命令读取结构体 (3 字段)
    126未使用
    127~12912扩展命令保留
    130StructGetValueStruct11扩展命令读取结构体 (3 字段)
    131IndexSetValueIndexGetValueIndex5命令 34 → FD40数据存储模式 (0/69/78/95)
    132IndexSetValueIndexGetValueIndex5命令 5 → FD34 bit7数据印记 / 特殊设定位
    133命令 133(内部全消去)内部使用
    134IntegerGetMinMaxInteger只读,范围 0-9999胶片编号
    135未使用
    136StartProcess(0x88) 调用指针复位/状态初始化
    137StartProcess(0x89) 调用触发拍摄(同 89)
    138未使用
    139IndexGetIndexString6字符串资源自定义功能名称/选项
    140~145未使用
    146StructGetValueStruct6扩展命令相机信息(型号、序列号等)
    147StructGetValueStruct6/5扩展命令对焦数据读取

    注释

    • 配置块位操作:功能 ID 69~82 的具体位定义已在第七章详细列出。每个布尔值对应配置块(命令32/35)的某一位,通过读-修改-写流程改变。
    • 扩展命令:功能 ID 106~130 使用命令码 >100 的扩展命令(格式 1B 90 cmd v6 len),具体 v6 和 len 见第四章扩展命令表。
    • 操作码:88, 89, 96, 136, 137 等不直接对应设置/读取函数,而是由 StartProcess 分发执行,内部命令序列已在前文各章详述。
    • 表中粗体为软件中直接面向用户的主要功能。
    • 未使用的功能 ID 在 DLL 中返回错误 2(不支持)。
    • 对于131:
    • 这些值直接写入 FD40 寄存器,对应相机的“数据存储模式”0 (0x00) → 不存储数据(Disabled)
    • 69 (0x45) → 存储最小数据量(Minimum)
    • 78 (0x4E) → 存储中间数据量(Intermediate)
    • 95 (0x5F) → 存储最大数据量(Maximum)
      该映射与我们之前整理的 FD40 寄存器功能一致,且与 DLL 资源中对应的字符串(“記憶しない”、“最小情報量記憶”等)吻合。

    第六章 特殊操作流程

    6.1 拍摄(快门释放)

    • 操作码:StartProcess(0x89)
    • DLL 函数:sub_10007BB0 → 型号1调用 sub_10007C4B,型号2调用 sub_10007CE0
    • 命令序列(以型号1为例):
      1. 读 FD20FD24FD40 获取状态。
      2. 构造写命令 01 10 81 00 FD 41 00 01 02 <data> <cs> 03 00,数据为 0x42(’B’)及校验。
      3. 等待 ACK,再读 FD20 和 FD24 完成清理。

    6.2 自动对焦

    • DLL 中未作为独立导出命令,但 EXE 的“AFの実行”菜单调用 StartProcess,其内部可触发对焦操作,对应的相机命令为 01 20 86 00 00 00 00 00 03(也可被直接发送)。

    6.3 用户设置库(保存/调用)

    • 保存到库:StartProcess(0x25) → sub_10005B79
    • 从库读取:StartProcess(0x26) → sub_10005A2EStartProcess(0x27) → sub_10005D47
    • 这些函数内部发送命令 18、20、31、119 以及 sub_1000E07D/sub_1000E183 等位操作,并读写 FD17、FD26、FD27 等寄存器。

    6.4 恢复默认设置

    • StartProcess(0x58)
    • 先将内部变量设为一组出厂默认值(详见代码中的连续赋值),然后调用 sub_100065A7,发送命令 119 或 18,最后写入配置块(命令 32 或 35)和公共配置(命令 20)。

    6.5 胶卷数据读取

    • 入口:sub_10006F09 → 型号1调用 sub_10007042,型号2调用 sub_1000749C
    • 步骤:
      1. 读 FD20FD24FD40 验证状态和存储模式。
      2. 读 FD41 获取帧数 v19 和相关信息。
      3. 根据 v19 计算动态地址:addr = 2 * v19(可能进行回绕修正)。
      4. 调用 sub_10007A14 发送动态读命令,接收数据包。
      5. 数据以 0xFF 作为卷结束标记,ISO 索引在最后。
      6. 解析帧数据(每帧 3/4/6 字节,取决于存储模式),字段包括快门速度、光圈、曝光模式、测光方式、焦距、曝光补偿等。
      7. 发送确认命令 0x42(’B’)完成本次读取。

    6.6 删除胶卷数据

    • 全消去StartProcess(0x1C) → sub_1000A9CD
      • 检查相机内是否有数据,若无则发送命令 133,相机清空整个环形缓冲区。
    • 部分删除(移动读取指针)StartProcess(0x1B) → sub_1000A7E1
      • 发送命令 134,并操作命令 19(FD39)的位 3,使相机内部的待读指针前移,等效于删除已读取的卷。
    • 指针/状态复位StartProcess(0x88) → sub_10008403
      • 型号1:写 FD41 特定值,清除 FED6 的 bit0,清零 FD40。
      • 型号2:读 FD00,写命令 ‘B’,清除 FECE 的 bit0,清零 FD40。

    第七章 配置块位定义

    从 sub_1000694E 提取(以型号1配置块命令32为例):

    • v34 低字节 = a3[10]
    • v34 高字节:
      • bit0: a3[13] 控制
      • bit1: a3[2] 控制
      • bit2: a3[11] 控制
      • bit3: a3[3] 控制
      • bit5: a3[9] 控制
      • bit6: a3[8] 控制
    • v35 低字节:
      • bit2: a3[12] (1或3置1)
      • bit3: a3[12] (2或3置1)
      • bit4: a3[5] 控制
      • bit5: a3[7] 控制
      • bit7: a3[6] 控制

    型号2配置块(命令35)有类似的位定义,详见原始反编译代码。


    第八章 错误码表

    错误码 (hex)含义
    0x19 (25)参数无效
    0x1D (29)数据块读取校验失败
    0x25 (37)对焦扫描初始化失败
    0x28 (40)功能不可用/状态错误
    0x29 (41)功能不支持
    0x32 (50)通用“不支持此操作”
    0x33 (51)参数冲突
    0x34 (52)参数范围错误
    0x35 (53)参数范围错误
    0x37 (55)模式不匹配
    0x45 (69)初始化失败
    -18校验和不匹配
    -27未收到 ACK (0x06)
    -30接收超时
    -35相机状态错误

    所有错误均通过全局变量 dword_10015220 返回。


    第九章 数值编码与资源字符串

    相机参数值到显示文本的映射完全来自 DLL/EXE 资源,无需第三方数据。例如:

    • 快门速度:EXE 资源 17408 开始 "30"",连续列出 Bulb、30″、25″、… 1/8000。
    • 光圈:资源 17664 起 F1、F1.1、… F90。
    • ISO:资源 19968 起 ISO 6、ISO 8、… ISO 6400。
    • 曝光补偿:资源 19712 起 0.0、-0.2、… +20.0。
    • 测光方式:资源 22190 起 “多分区测光”、”中央重点测光”、”点测光”。
    • 曝光模式:资源 17920 起 “P:多程序自动”、”S:快门优先” 等。

    所有字符串均已在 EXE 和 DLL 的资源提取中完整列出,并翻译为中文。


    第十章 与第三方文档的主要差异说明(主要指f90x-serial-documentation/f90x-serial-documentation.md at trunk · antarktikali/f90x-serial-documentation · GitHubgIcon source 项目等)

    • 地址字节:第三方固定使用 0x20,我们根据 DLL 逻辑动态使用 0x10 或 0x20
    • 扩展命令格式:第三方可能省略 0x90,我们严格采用 DLL 的 1B 90 cmd 格式。
    • 环形缓冲区指针:第三方直接操作 FD44/FD46,DLL 通过动态地址计算和 FED6/FECE 等寄存器间接实现,功能等效。
    • 部分只读状态寄存器(如 FE20、FE22)在 DLL 中未出现立即数,但其信息已通过功能 ID 提供,不影响使用。
    • 所有命令码和寄存器映射完全基于 DLL 的 sub_1000DB73sub_1000DED3,比第三方仅列举的片段更为完整和准确。

    附录 A 数据块格式(胶卷记录)

    卷头:2 字节 0x5A58(“ZX”)
         2 字节 元数据
         2 字节 0x0000
         2 字节 卷号(BCD)
    帧数据:N × (3/4/6 字节) 取决于存储模式
         1 字节 0xFF(结束标记)
         1 字节 ISO 索引

    每帧数据字段(6 字节最大模式):快门速度、光圈、曝光模式、测光模式、闪光同步、焦距、曝光补偿、闪光补偿。具体编码与命令寄存器返回的原始值一致,可参照资源字符串表解析。

    补充:

    • 在 sub_10007042 中,读取胶卷数据时使用的动态地址 v28 = 2 * v19,并且有如下回绕处理:
    v15 = 总字节数 + v28;
    if ( v15 >= 0x200u )
        v15 -= 444;   // 回绕修正,444 = 0x1BC
    • 这说明环形缓冲区的大小为 512 (0x200) 字节,当读取指针超过 0x1FF 时,会回绕到 0x200 - 444 = 0x44(实际基址由相机固定)。DLL 内部自动处理了回绕,开发者只需按此逻辑实现即可。
  • 【ESP32】ESP32S3通过USB+WIFI透传串口GPS信息并自动切换波特率

    基于微雪ESP32-S3-Touch-LCD-1.47,基于ESP-IDF 5.5.x,该代码目的适配ATGM和UBLOX部分GPS模块,可以热切换115200、38400波特率,实测115200波特率下可以实现5Hz下的NMEA语句持续平稳透传,10Hz下不太稳定。

    #include <stdio.h>
    #include <string.h>
    #include <stdbool.h>
    
    #include "freertos/FreeRTOS.h"
    #include "freertos/task.h"
    #include "freertos/semphr.h"
    #include "freertos/ringbuf.h"
    
    #include "driver/uart.h"
    #include "driver/usb_serial_jtag.h"
    
    #include "esp_log.h"
    #include "esp_wifi.h"
    #include "esp_event.h"
    #include "nvs_flash.h"
    #include "lwip/sockets.h"
    #include "esp_timer.h"
    
    /* ================== 配置常量 ================== */
    #define TAG                "GPS_BRIDGE"
    
    // 物理串口
    #define GPS_UART_NUM       UART_NUM_1
    #define GPS_TX_PIN         43
    #define GPS_RX_PIN         44
    
    // 缓存大小
    #define UART_BUF_SIZE      2048
    #define RINGBUF_SIZE       (16 * 1024)
    
    // 网络配置
    #define TCP_PORT           8080
    #define WIFI_SSID          "ESP32S3_GPS"
    #define WIFI_PASS          "12345678"
    
    // GPS参数
    static const int baud_list[] = {115200, 38400};
    #define BAUD_NUM            (sizeof(baud_list) / sizeof(baud_list[0]))
    #define DETECT_TIMEOUT_MS   1500  // 每个波特率停留尝试时间
    #define LOSS_TIMEOUT_SEC    5     // 5秒无NMEA数据则判定丢失
    
    /* ================== 全局句柄 ================== */
    static RingbufHandle_t uart_rb;
    static SemaphoreHandle_t sock_mutex;
    static SemaphoreHandle_t uart_mutex;
    
    static int  client_sock = -1;
    static int  current_baud_idx = 0;
    static bool baud_locked = false;
    
    /* ================== 工具函数 ================== */
    
    // 安全地重新初始化 UART
    static void uart_reinit(int baudrate)
    {
        xSemaphoreTake(uart_mutex, portMAX_DELAY);
        
        if (uart_is_driver_installed(GPS_UART_NUM)) {
            uart_driver_delete(GPS_UART_NUM);
        }
    
        uart_config_t cfg = {
            .baud_rate = baudrate,
            .data_bits = UART_DATA_8_BITS,
            .parity    = UART_PARITY_DISABLE,
            .stop_bits = UART_STOP_BITS_1,
            .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
            .source_clk = UART_SCLK_DEFAULT,
        };
    
        // 安装驱动:缓冲区设为 UART_BUF_SIZE 的 2 倍
        uart_driver_install(GPS_UART_NUM, UART_BUF_SIZE * 2, 0, 0, NULL, 0);
        uart_param_config(GPS_UART_NUM, &cfg);
        uart_set_pin(GPS_UART_NUM, GPS_TX_PIN, GPS_RX_PIN, -1, -1);
    
        ESP_LOGW(TAG, "UART re-init: %d baud, scanning for GPS...", baudrate);
        
        xSemaphoreGive(uart_mutex);
    }
    
    // 协议特征检测
    static bool is_valid_nmea(const uint8_t *buf, int len)
    {
        if (len < 6) return false;
        // NMEA 标准报文必须以 $ 开头
        if (buf[0] != '$') return false;
    
        // 检查常见的 NMEA 类型标识符
        if (memmem(buf, len, "GGA", 3) || 
            memmem(buf, len, "RMC", 3) || 
            memmem(buf, len, "GSA", 3) ||
            memmem(buf, len, "GSV", 3)) {
            return true;
        }
        return false;
    }
    
    /* ================== 核心任务 ================== */
    
    /**
     * 任务1: UART 接收与波特率自动匹配
     * 优先级: 高 (10)
     */
    static void gps_rx_task(void *arg)
    {
        static uint8_t rx_buf[UART_BUF_SIZE];
        int64_t last_baud_switch = 0;
        int64_t last_valid_data  = 0;
    
        uart_reinit(baud_list[current_baud_idx]);
        last_baud_switch = esp_timer_get_time();
    
        while (1) {
            // 使用锁保护 UART 读取过程
            xSemaphoreTake(uart_mutex, portMAX_DELAY);
            int len = uart_read_bytes(GPS_UART_NUM, rx_buf, sizeof(rx_buf), pdMS_TO_TICKS(20));
            xSemaphoreGive(uart_mutex);
    
            int64_t now = esp_timer_get_time();
    
            if (len > 0) {
                if (is_valid_nmea(rx_buf, len)) {
                    last_valid_data = now;
                    if (!baud_locked) {
                        baud_locked = true;
                        ESP_LOGI(TAG, ">>> GPS Locked @ %d baud <<<", baud_list[current_baud_idx]);
                    }
                }
                
                // 只有锁定了正确的波特率才转发数据,过滤扫描时的乱码
                if (baud_locked) {
                    xRingbufferSend(uart_rb, rx_buf, len, 0);
                }
            }
    
            /* 状态机切换逻辑 */
            if (!baud_locked) {
                // 未锁定时:超时轮换波特率
                if ((now - last_baud_switch) / 1000 > DETECT_TIMEOUT_MS) {
                    current_baud_idx = (current_baud_idx + 1) % BAUD_NUM;
                    uart_reinit(baud_list[current_baud_idx]);
                    last_baud_switch = now;
                }
            } else {
                // 已锁定时:长时间无有效 NMEA 信号判定为丢失
                if ((now - last_valid_data) / 1000000 > LOSS_TIMEOUT_SEC) {
                    ESP_LOGE(TAG, "GPS signal lost, restarting scan...");
                    baud_locked = false;
                    last_baud_switch = now;
                }
            }
        }
    }
    
    /**
     * 任务2: 数据多路分发 (UART -> USB & WiFi)
     * 优先级: 中 (6)
     */
    static void tx_dispatcher_task(void *arg)
    {
        size_t len;
        uint8_t *data;
    
        while (1) {
            // 从环形缓冲区提取数据,最大等待
            data = (uint8_t *)xRingbufferReceive(uart_rb, &len, portMAX_DELAY);
            if (!data) continue;
    
            // 1. 发送到物理 USB (CDC)
            if (usb_serial_jtag_is_connected()) {
                usb_serial_jtag_write_bytes(data, len, 0);
            }
    
            // 2. 发送到 TCP 客户端
            if (xSemaphoreTake(sock_mutex, 0) == pdTRUE) {
                if (client_sock != -1) {
                    // 使用非阻塞发送,防止 WiFi 拥塞卡住串口读写
                    int sent = send(client_sock, data, len, MSG_DONTWAIT);
                    if (sent < 0 && (errno != EAGAIN && errno != EWOULDBLOCK)) {
                        ESP_LOGW(TAG, "TCP send error, client might be dead");
                    }
                }
                xSemaphoreGive(sock_mutex);
            }
    
            vRingbufferReturnItem(uart_rb, data);
        }
    }
    
    /**
     * 任务3: 反向路径 USB -> UART
     */
    static void usb_rx_task(void *arg)
    {
        static uint8_t buf[512];
        while (1) {
            int len = usb_serial_jtag_read_bytes(buf, sizeof(buf), pdMS_TO_TICKS(10));
            if (len > 0 && baud_locked) {
                xSemaphoreTake(uart_mutex, portMAX_DELAY);
                uart_write_bytes(GPS_UART_NUM, (char *)buf, len);
                xSemaphoreGive(uart_mutex);
            }
        }
    }
    
    /**
     * 任务4: TCP 服务端 (WiFi -> UART)
     */
    static void tcp_server_task(void *arg)
    {
        struct sockaddr_in addr = {
            .sin_family = AF_INET,
            .sin_port = htons(TCP_PORT),
            .sin_addr.s_addr = htonl(INADDR_ANY)
        };
    
        int listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
        int opt = 1;
        setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        bind(listen_sock, (struct sockaddr *)&addr, sizeof(addr));
        listen(listen_sock, 1);
    
        static uint8_t buf[512];
        while (1) {
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int sock = accept(listen_sock, (struct sockaddr *)&client, &len);
            if (sock < 0) continue;
    
            xSemaphoreTake(sock_mutex, portMAX_DELAY);
            if (client_sock != -1) close(client_sock);
            client_sock = sock;
            xSemaphoreGive(sock_mutex);
    
            ESP_LOGI(TAG, "New TCP client connected");
    
            while (1) {
                int r = recv(sock, buf, sizeof(buf), 0);
                if (r <= 0) break;
    
                if (baud_locked) {
                    xSemaphoreTake(uart_mutex, portMAX_DELAY);
                    uart_write_bytes(GPS_UART_NUM, (char *)buf, r);
                    xSemaphoreGive(uart_mutex);
                }
            }
    
            ESP_LOGI(TAG, "TCP client disconnected");
            xSemaphoreTake(sock_mutex, portMAX_DELAY);
            if (client_sock == sock) client_sock = -1;
            close(sock);
            xSemaphoreGive(sock_mutex);
        }
    }
    
    /* ================== 系统初始化 ================== */
    
    static void wifi_init_softap(void)
    {
        ESP_ERROR_CHECK(esp_netif_init());
        ESP_ERROR_CHECK(esp_event_loop_create_default());
        esp_netif_create_default_wifi_ap();
    
        wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
        ESP_ERROR_CHECK(esp_wifi_init(&cfg));
    
        wifi_config_t ap_cfg = {
            .ap = {
                .ssid = WIFI_SSID,
                .ssid_len = strlen(WIFI_SSID),
                .password = WIFI_PASS,
                .channel = 1,
                .max_connection = 4,
                .authmode = WIFI_AUTH_WPA2_PSK
            }
        };
    
        ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
        ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg));
        ESP_ERROR_CHECK(esp_wifi_start());
    }
    
    void app_main(void)
    {
        // 1. 初始化存储
        esp_err_t ret = nvs_flash_init();
        if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
            ESP_ERROR_CHECK(nvs_flash_erase());
            ret = nvs_flash_init();
        }
        ESP_ERROR_CHECK(ret);
    
        // 2. 初始化同步组件与缓冲区
        uart_rb    = xRingbufferCreate(RINGBUF_SIZE, RINGBUF_TYPE_BYTEBUF);
        sock_mutex = xSemaphoreCreateMutex();
        uart_mutex = xSemaphoreCreateMutex();
    
        // 3. 初始化 USB 驱动
        usb_serial_jtag_driver_config_t usb_cfg = {
            .rx_buffer_size = 2048,
            .tx_buffer_size = 2048
        };
        usb_serial_jtag_driver_install(&usb_cfg);
    
        // 4. 初始化 WiFi
        wifi_init_softap();
    
        // 5. 创建任务集群
        // 核心 0 处理实时性最强的串口 IO
        xTaskCreatePinnedToCore(gps_rx_task,        "gps_rx",  4096, NULL, 10, NULL, 0);
        xTaskCreatePinnedToCore(usb_rx_task,        "usb_rx",  4096, NULL, 5,  NULL, 0);
    
        // 核心 1 处理数据分发和网络服务
        xTaskCreatePinnedToCore(tx_dispatcher_task, "tx_dis",  4096, NULL, 6,  NULL, 1);
        xTaskCreatePinnedToCore(tcp_server_task,    "tcp_srv", 4096, NULL, 4,  NULL, 1);
    
        ESP_LOGI(TAG, "Bridge System Started. Use TCP 192.168.4.1:8080 or USB JTAG Serial.");
    }

  • 【Ai】在Windows系统本地部署DeepSeek-R1的极简步骤

    惊闻B站有人出售百元本地部署教程,NND给我看笑了,什么都能卖钱是吧,当然不排除人家手把手教,提供足够的情绪价值。

    但是如果你不想花钱,同时想提升一下英文水平和计算机熟练度,请跟着官方文档一步步进行,目前的文档已经相当详细且可行。

    我们如果在Windows上进行部署和调试,推荐使用Open WebUi+Ollama的方式进行部署。

    PS:其实更建议在Linux上进行部署,一键部署更加便利~

    1、选择后端Ollama

    在Ollama官网选择Win版本下载,会自动部署相关环境,在CMD或者中powershell可以按照对应的模型拉取到本地。

    请根据自己的硬件量力而行。我的显卡为RTX3070 8G,按照ollama默认设置,运行7B已经亚历山大。

    2、选择前端Open-WebUi

    请根据您的系统,在Open-WebUi的官方文档,按照步骤,一步步来进行部署。

    Win版本下按照官方建议,可以使用uv,在powershell中进行拉取和部署,其中对网络环境有一定要求。

    3、启动Open-WebUi后的注意事项

    Open-WebUi原版毕竟是国外软件,在国内这个环境启动还是有一点网络困扰的,尤其是有些文件是通过Github的地址获取的,请对自己的网络环境做出一些针对性的优化调整。

    另外完成本地部署后,如果是启动在127.0.0.1上,那么检查ollama的端口是否running,一般按照官方部署都可以进行顺畅进行(只有国内这个网不太顺畅)

    此外Open-WebUi默认检查OpenAi的API,这个选项可以在首次登陆后去管理员面板关闭,这样不会每次启动都遇到拉取模型缓慢、超时的情况。

    4、其他分享

    我的电脑采用13700K 32G DDR5 RTX3070 8G,但大模型运行需要大显存,8G显存只能算起步,参数量只能流畅运行7B左右的模型,因此一般的家用和办公电脑跑大模型都存在很大的限制。

    RTX3070 8G跑DeepSeek-R1:7B的速度——显存爆满,ollama默认设置,显示CPU处理占用10%,GPU处理占用90%,常规问答的response token/s在39左右 ,prompt_token/s在2500左右。但是大模型性能存在一定的短板,长上下文效果不尽如人意。

    RTX3070 8G跑DeepSeek-R1:14B,ollama默认设置命令行中速度可以接受,在WebUi中短回答response token/s约为14,较长的上下文降低到7不到,一半跑在GPU一半跑在CPU,长文本效果很差。

    RTX3070 8G跑DeepSeek-R1:32B,ollama默认设置在命令行中速度还行,缓慢但可以简单对话,处理长文本速度基本不可用。若用13700K跑在CPU则32G内容跑满,速度也是非常慢。

    在跑完Ai测试后,请关闭Ollama的进程,否则你将面临满占用的显存或内存~

    5、体验

    搭配Open-WebUi可以实现本地部署,多端使用,但是对体验影响更大的限制——模型本身——我们个人、及小公司的计算性能均没法有效支撑,本来大模型就是为了提升效率,本地部署一个跑的死慢的模型,对效率的提升实在是存疑。

    当然我鼓励大家都去本地部署体验一下,从中也可以获得一些乐趣,但是如果到实际应用层面,大一些的模型硬件需求激增,小公司玩这个自建后端的硬件成本还是太高了。

    因此,对小公司而言,可能选择一个大树,使用API,保护好自己的数据(真的是有价值的数据吗?)进行针对性的训练,拓展自己的RAG系统,做好本地化的情况下拥抱云计算,才是提升小公司效率的一条路吧。

    但话说回来,小公司真的愿意为这个人工和软件成本付费吗?

  • 【Python】提取视频画面并生成PPT

    比较笨的方法,用来提取PPT课程视频画面,并生成对应的PPT,代码检测黑屏但没有检测白屏,没有检测重复画面(因为有些人讲课会来回翻PPT),因此还有优化空间。内存占用会逐渐增多,不过测试没有出现崩溃的情况。

    PS:做完发现可以直接问讲课人要PPT原件,我,,,

    import cv2
    import os
    import numpy as np
    from pptx import Presentation
    from pptx.util import Inches
    from skimage.metrics import structural_similarity as ssim
    import tkinter as tk
    from tkinter import filedialog, messagebox
    
    # 选择视频和输出目录
    def select_video_and_output():
        video_path = filedialog.askopenfilename(title="选择视频文件", filetypes=[("MP4 files", "*.mp4")])
        if not video_path:
            messagebox.showwarning("选择视频", "未选择视频文件")
            return None, None
        
        output_dir = filedialog.askdirectory(title="选择输出目录")
        if not output_dir:
            messagebox.showwarning("选择输出目录", "未选择输出目录")
            return None, None
    
        pptx_path = os.path.join(output_dir, "output_presentation.pptx")
        return video_path, pptx_path
    
    # 处理视频并生成 PPT
    def process_video_to_ppt(video_path, pptx_path):
        os.makedirs("ppt_images", exist_ok=True)
        
        cap = cv2.VideoCapture(video_path)
        _, prev_frame = cap.read()
        prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
    
        frame_count = 0
        slide_count = 0
        images = []
        similarity_threshold = 0.95  # 提高 SSIM 阈值,减少相似图片
        brightness_threshold = 10  # 黑屏检测(平均亮度 < 10 认为是黑屏)
    
        def process_frame(frame):
            """ 计算 SSIM 相似度,判断是否保存该帧 """
            nonlocal prev_gray, slide_count
            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            score = ssim(prev_gray, gray)
    
            # 计算平均亮度,过滤黑屏
            avg_brightness = np.mean(gray)
            if avg_brightness < brightness_threshold:
                return  # 跳过黑屏帧
    
            if score < similarity_threshold:  
                img_path = os.path.join("ppt_images", f"slide_{slide_count}.jpg")
    
                # 确保不同的幻灯片才保存
                if len(images) == 0 or images[-1] != img_path:  
                    cv2.imwrite(img_path, frame)
                    images.append(img_path)
                    slide_count += 1
                    prev_gray = gray  # 只在确认变化时更新参考帧
    
        while cap.isOpened():
            ret, frame = cap.read()
            if not ret:
                break
    
            # 仅每隔 15 帧处理一次
            if frame_count % 15 == 0:
                process_frame(frame)
    
            frame_count += 1
    
        cap.release()
        # cv2.destroyAllWindows()
    
        # 创建 PPT
        prs = Presentation()
        for img in images:
            slide = prs.slides.add_slide(prs.slide_layouts[5])  # 空白幻灯片
            left, top, width, height = Inches(0), Inches(0), Inches(10), Inches(7.5)
            slide.shapes.add_picture(img, left, top, width, height)
    
        prs.save(pptx_path)
        messagebox.showinfo("完成", f"PPTX 生成完成: {pptx_path}")
    
    # 主函数
    def main():
        root = tk.Tk()
        root.withdraw()  # 隐藏主窗口
        video_path, pptx_path = select_video_and_output()
        if video_path and pptx_path:
            process_video_to_ppt(video_path, pptx_path)
    
    if __name__ == "__main__":
        main()
    
  • 【Python】使用cwebp、gif2webp、exiftool实现保留exif信息的WebP转换

    此前写了个使用cwebp、gif2webp的脚本,但是由于cwebp目前在win的元数据提取存在问题,因此我们可以使用已经支持exif提取和写入的exiftool进行最后一步的转换,这样我们的图片压缩、转码都在官方库得以实现。

    前置条件:

    cwebp、gif2webp、exiftool三个组件都注册到系统环境变量。python则使用pil库用于分辨率获取。

    实现效果:

    使用pil库对分辨率进行获取,但是不介入压缩过程,因为cwebp目前没法获取图片分辨率,使用pil库进行是否执行resize的判断。

    使用cwebp处理静态png、jpg,使用gif2webp处理gif图,启用mt多线程,压缩质量85,resize到2560最长、宽边,exiftool采用”-overwrite_original”来避免生成两个图片。

    测试效果:

    该图片原图7M多,压缩质量选择85,可能由于细节较为丰富,压缩到WebP大小仍为1M左右,还是比较大,不过细节保留充分,同时保留了EXIF信息。

    import tkinter as tk
    from tkinter import filedialog, messagebox
    import os
    import subprocess
    from PIL import Image
    
    def validate_file(input_path):
        input_path = os.path.abspath(input_path)
        if not os.path.exists(input_path):
            raise FileNotFoundError(f"文件 {input_path} 不存在,请检查路径。")
        return input_path
    
    def get_resized_dimensions(width, height, max_size):
        if width > height:
            new_width = max_size
            new_height = int((new_width / width) * height)
        else:
            new_height = max_size
            new_width = int((new_height / height) * width)
        return new_width, new_height
    
    def convert_image(input_path, output_path, new_width=None, new_height=None):
        try:
            file_extension = os.path.splitext(input_path)[1].lower()
            if file_extension == ".gif":
                command = ["gif2webp","mt", input_path, "-o", output_path]
            else:
                if new_width and new_height:
                    command = ["cwebp","mt", "-q", "85", "-resize", str(new_width), str(new_height), input_path, "-o", output_path]
                else:
                    command = ["cwebp","mt", "-q", "85", input_path, "-o", output_path]
            subprocess.run(command, check=True)
        except subprocess.CalledProcessError as e:
            raise RuntimeError(f"转换工具运行出错: {e}")
    
    def embed_exif(input_path, output_path):
        try:
            command = ["exiftool", "-overwrite_original", "-tagsfromfile", input_path, "-all:all", output_path]
            subprocess.run(command, check=True)
        except subprocess.CalledProcessError as e:
            raise RuntimeError(f"EXIF 数据嵌入失败: {e}")
    
    def convert_to_webp(input_path, max_size=2560):
        try:
            # 验证文件路径
            input_path = validate_file(input_path)
            output_path = os.path.splitext(input_path)[0] + ".webp"
    
            # 使用 PIL 获取图像分辨率
            with Image.open(input_path) as img:
                width, height = img.size
                if width <= max_size and height <= max_size:
                    convert_image(input_path, output_path)
                else:
                    new_width, new_height = get_resized_dimensions(width, height, max_size)
                    convert_image(input_path, output_path, new_width, new_height)
    
            # 嵌入 EXIF 数据
            embed_exif(input_path, output_path)
    
            return f"图片已转换并保存为 {output_path}"
    
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
            return str(e)
        except Exception as e:
            return f"处理文件时发生错误: {e}"
    
    def select_files():
        file_paths = filedialog.askopenfilenames(
            title="选择图片文件",
            filetypes=[("*所有图片格式", "*.jpg;*.jpeg;*.png;*.gif"),
                       ("JPEG 图片", "*.jpg;*.jpeg"),
                       ("PNG 图片", "*.png"),
                       ("GIF 图片", "*.gif")]
        )
        if file_paths:
            for path in file_paths:
                file_listbox.insert(tk.END, path)
    
    def convert_and_save_batch():
        files = file_listbox.get(0, tk.END)
        if not files:
            messagebox.showerror("错误", "请选择至少一个图片文件!")
            return
    
        results = [convert_to_webp(file_path) for file_path in files]
        messagebox.showinfo("完成", "\n".join(results))
    
    def clear_list():
        file_listbox.delete(0, tk.END)
    
    root = tk.Tk()
    root.title("批量图片转换为 WebP 工具")
    root.geometry("600x400")
    
    frame = tk.Frame(root)
    frame.pack(pady=10, padx=10, fill=tk.BOTH, expand=True)
    
    scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL)
    file_listbox = tk.Listbox(frame, selectmode=tk.EXTENDED, yscrollcommand=scrollbar.set)
    scrollbar.config(command=file_listbox.yview)
    scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
    file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
    
    button_frame = tk.Frame(root)
    button_frame.pack(pady=10)
    
    select_button = tk.Button(button_frame, text="选择文件", command=select_files, width=15)
    select_button.grid(row=0, column=0, padx=5)
    
    clear_button = tk.Button(button_frame, text="清空列表", command=clear_list, width=15)
    clear_button.grid(row=0, column=1, padx=5)
    
    convert_button = tk.Button(button_frame, text="批量转换", command=convert_and_save_batch, width=15)
    convert_button.grid(row=0, column=2, padx=5)
    
    root.mainloop()
    
  • 【Python】使用WebP官方库进行WebP转换

    此前的代码使用了Pillow库集成的库,这次使用WebP官方库,对GIF、PNG的处理也比较友好。需要添加WebP的库到系统环境变量后使用。

    功能实现:

    代码使用cwebp、gif2webp两种方式转换不同的格式图片,使用库本身的压缩分辨率方法,压缩图片到2560最长、宽。

    对于gif,该代码可以实现对原图动态的保持,此前使用pillow库则可以设定gif的持续或者最后一帧静帧。

    遗憾:

    没有能够传递exif,win平台中cwebp压根没法有效传递exif信息,显示——Warning: only ICC profile extraction is currently supported on this platform!元数据只有ICC才能支持传递,所以只依靠cwebp是没法很好在win中进行转换的。

    这个问题采用exiftool进行了解决,下篇文章可以看到。

    import tkinter as tk
    from tkinter import filedialog, messagebox
    import os
    import subprocess
    from PIL import Image
    
    def validate_file(input_path):
        input_path = os.path.abspath(input_path)
        if not os.path.exists(input_path):
            raise FileNotFoundError(f"文件 {input_path} 不存在,请检查路径。")
        return input_path
    
    def get_resized_dimensions(width, height, max_size):
        if width > height:
            new_width = max_size
            new_height = int((new_width / width) * height)
        else:
            new_height = max_size
            new_width = int((new_height / height) * width)
        return new_width, new_height
    
    # 使用 cwebp 或 gif2webp 进行转换
    def convert_image(input_path, output_path, new_width=None, new_height=None, cwebp_metadata="none", gif2webp_metadata="none"):
        try:
            file_extension = os.path.splitext(input_path)[1].lower()
            if file_extension == ".gif":
                command = ["gif2webp", "-metadata", gif2webp_metadata, "-mt", input_path, "-o", output_path]
            else:
                if new_width and new_height:
                    command = ["cwebp", "-q", "85", "-resize", str(new_width), str(new_height), "-metadata", cwebp_metadata, "-mt", input_path, "-o", output_path]
                else:
                    command = ["cwebp", "-q", "85", "-metadata", cwebp_metadata, "-mt", input_path, "-o", output_path]
            subprocess.run(command, check=True)
        except subprocess.CalledProcessError as e:
            raise RuntimeError(f"转换工具运行出错: {e}")
    
    def convert_to_webp(input_path, max_size=2560, cwebp_metadata="none", gif2webp_metadata="none"):
        try:
            # 验证文件路径
            input_path = validate_file(input_path)
            output_path = os.path.splitext(input_path)[0] + ".webp"
    
            # 使用 PIL 获取图像分辨率
            with Image.open(input_path) as img:
                width, height = img.size
                if width <= max_size and height <= max_size:
                    convert_image(input_path, output_path, cwebp_metadata=cwebp_metadata, gif2webp_metadata=gif2webp_metadata)
                else:
                    new_width, new_height = get_resized_dimensions(width, height, max_size)
                    convert_image(input_path, output_path, new_width, new_height, cwebp_metadata=cwebp_metadata, gif2webp_metadata=gif2webp_metadata)
    
            return f"图片已转换并保存为 {output_path}"
    
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
            return str(e)
        except Exception as e:
            return f"处理文件时发生错误: {e}"
    
    def select_files():
        file_paths = filedialog.askopenfilenames(
            title="选择图片文件",
            filetypes=[("*所有图片格式", "*.jpg;*.jpeg;*.png;*.gif"),
                       ("JPEG 图片", "*.jpg;*.jpeg"),
                       ("PNG 图片", "*.png"),
                       ("GIF 图片", "*.gif")]
        )
        if file_paths:
            for path in file_paths:
                file_listbox.insert(tk.END, path)
    
    def convert_and_save_batch():
        files = file_listbox.get(0, tk.END)
        if not files:
            messagebox.showerror("错误", "请选择至少一个图片文件!")
            return
    
        cwebp_metadata = "exif"  # 设置cwebp要复制的元数据类型为exif
        gif2webp_metadata = "xmp"  # 设置gif2webp要复制的元数据类型为xmp
        results = [convert_to_webp(file_path, cwebp_metadata=cwebp_metadata, gif2webp_metadata=gif2webp_metadata) for file_path in files]
        messagebox.showinfo("完成", "\n".join(results))
    
    def clear_list():
        file_listbox.delete(0, tk.END)
    
    # 创建主窗口
    root = tk.Tk()
    root.title("批量图片转换为 WebP 工具")
    root.geometry("600x400")
    
    frame = tk.Frame(root)
    frame.pack(pady=10, padx=10, fill=tk.BOTH, expand=True)
    
    scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL)
    file_listbox = tk.Listbox(frame, selectmode=tk.EXTENDED, yscrollcommand=scrollbar.set)
    scrollbar.config(command=file_listbox.yview)
    scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
    file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
    
    button_frame = tk.Frame(root)
    button_frame.pack(pady=10)
    
    select_button = tk.Button(button_frame, text="选择文件", command=select_files, width=15)
    select_button.grid(row=0, column=0, padx=5)
    
    clear_button = tk.Button(button_frame, text="清空列表", command=clear_list, width=15)
    clear_button.grid(row=0, column=1, padx=5)
    
    convert_button = tk.Button(button_frame, text="批量转换", command=convert_and_save_batch, width=15)
    convert_button.grid(row=0, column=2, padx=5)
    
    root.mainloop()
    
  • 【Python】使用PIL库进行多格式批量转换WebP并压缩分辨率

    网站将逐步切换到WebP格式图片,今天捣鼓了插件在服务器端替换图片,但WP的媒体库却怎么都搞不定了,媒体库会自动生成很多缩略图用于不同的场景,我不想碰它的缩略图生成效果,因此只写单一的转换代码是没法做出完整的效果的。

    退而求其次使用本地对图片进行处理,该脚本使用PIL库,图片分辨率限制为2560最长/宽,可以处理带透明通道的图片,也可以处理GIF,用下来效果还不错。

    1月8日更新:

    【Python】使用WebP官方库进行WebP转换
    【Python】使用cwebp、gif2webp、exiftool实现保留exif信息的WebP转换

    import tkinter as tk
    from tkinter import filedialog, messagebox
    from PIL import Image, ImageSequence
    import os
    
    def resize_image(img):
        max_size = 2560
        width, height = img.size
    
        if width > max_size or height > max_size:
            if width > height:
                new_width = max_size
                new_height = int((new_width / width) * height)
            else:
                new_height = max_size
                new_width = int((new_height / height) * width)
            
            img = img.resize((new_width, new_height), Image.LANCZOS)
        
        return img
    
    def convert_to_webp(input_path):
        try:
            file_extension = os.path.splitext(input_path)[1].lower()
            output_path = os.path.splitext(input_path)[0] + ".webp"
    
            if not os.path.exists(input_path):
                raise FileNotFoundError(f"文件 {input_path} 不存在,请检查路径。")
    
            if file_extension == '.webp':
                return f"文件 {input_path} 已是 WebP 格式,无需转换。"
    
            with Image.open(input_path) as img:
                if file_extension in ['.gif'] and getattr(img, "is_animated", False):
                    frames = []
                    durations = []
                    for frame in ImageSequence.Iterator(img):
                        # 处理透明度
                        if frame.mode == "P":
                            frame = frame.convert("RGBA")
                        
                        # 转换帧为 RGBA 并存储
                        new_frame = frame.copy()
                        frames.append(new_frame)
                        durations.append(frame.info.get('duration', 100))
    
                    # 重复最后一帧
                    if len(frames) > 1:
                        durations[-1] = max(durations[-1], 100) 
    
                    # 保存为动态 WebP
                    frames[0].save(
                        output_path,
                        format="WEBP",
                        save_all=True,
                        append_images=frames[1:],
                        duration=durations,
                        loop=img.info.get('loop', 0),  # 循环次数
                        transparency=0,  # 确保透明度保留
                        quality=85
                    )
                else:
                    # 静态图片处理
                    if img.mode == "P":
                        if "transparency" in img.info:
                            img = img.convert("RGBA")
                        else:
                            img = img.convert("RGB")
    
                    img = resize_image(img)
    
                    if img.mode != "RGBA":
                        img = img.convert("RGBA")
    
                    img.save(output_path, format="WEBP", quality=85)
    
            return f"图片已转换并保存为 {output_path}"
        except Exception as e:
            return f"处理文件时发生错误: {e}"
    
    
    def select_files():
        file_paths = filedialog.askopenfilenames(
            title="选择图片文件",
            filetypes=[("所有图片格式", "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp;*.tiff"), 
                       ("JPEG 图片", "*.jpg;*.jpeg"),
                       ("PNG 图片", "*.png"),
                       ("GIF 图片", "*.gif"),
                       ("WebP 图片", "*.webp"),
                       ("BMP 图片", "*.bmp"),
                       ("TIFF 图片", "*.tiff")]
        )
        if file_paths:
            for path in file_paths:
                file_listbox.insert(tk.END, path)
    
    def convert_and_save_batch():
        files = file_listbox.get(0, tk.END)
        if not files:
            messagebox.showerror("错误", "请选择至少一个图片文件!")
            return
    
        results = []
        for file_path in files:
            result = convert_to_webp(file_path)
            results.append(result)
    
        messagebox.showinfo("完成", "\n".join(results))
    
    def clear_list():
        file_listbox.delete(0, tk.END)
    
    root = tk.Tk()
    root.title("批量图片转换为 WebP 工具")
    root.geometry("600x400")
    
    frame = tk.Frame(root)
    frame.pack(pady=10, padx=10, fill=tk.BOTH, expand=True)
    
    scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL)
    file_listbox = tk.Listbox(frame, selectmode=tk.EXTENDED, yscrollcommand=scrollbar.set)
    scrollbar.config(command=file_listbox.yview)
    scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
    file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
    
    button_frame = tk.Frame(root)
    button_frame.pack(pady=10)
    
    select_button = tk.Button(button_frame, text="选择文件", command=select_files, width=15)
    select_button.grid(row=0, column=0, padx=5)
    
    clear_button = tk.Button(button_frame, text="清空列表", command=clear_list, width=15)
    clear_button.grid(row=0, column=1, padx=5)
    
    convert_button = tk.Button(button_frame, text="批量转换", command=convert_and_save_batch, width=15)
    convert_button.grid(row=0, column=2, padx=5)
    
    root.mainloop()
  • 【HTML】为网站添加WordPress官网飘雪效果

    WordPress官网最近加上了一个飘雪效果,感觉效果相当不错,经过研究后发现他们页面中使用了两个js,而前端只需要用html进行简单调整就可以自定义,可玩性很强。

    我们也可以在自己的网站中使用这个效果,首先在Wordpress官网下载到is-land及snow-fall这俩js。

    通过模板函数对is-land与snow-fall先后加载:

    // js来源自Wordpress官网
    function enqueue_island_assets() {
        wp_enqueue_script(
            'island-js',
            get_template_directory_uri() . '/island.js', 
            true
        );
    }
    add_action('wp_enqueue_scripts', 'enqueue_island_assets');
    wp_enqueue_script(
        'snow-fall-js',
        get_template_directory_uri() . '/snow-fall.js',
        array('island-js'), // 设置依赖
        true
    );

    前端通过html进行调用,我们通过后面的分析可以发现其为前端预留了多个参数调节,比如我想在页面呈现❄飘落,而不是原版的圆点,只需要调整text的值,写成如下即可:

    <is-land on:media="(prefers-reduced-motion: no-preference)" on:idle="">
        <snow-fall mode="page" text="❄"></snow-fall>
    </is-land>

    2025-1-6更新:如果不需要island里面的一些参数,那么直接引入snow-fall也是可以的:

    // island及snowfall的Js文件来自Wordpress官网
    function enqueue_snowfall() {
        wp_enqueue_script(
            'snow-fall-js',
            get_template_directory_uri() . '/snow-fall.js', 
            true
        );
    }
    add_action('wp_enqueue_scripts', 'enqueue_snowfall');
    <snow-fall mode="page" text="❄"></snow-fall>

    目前网站采用直接引入Snow-fall的方式展示。


    后续是源码及一些可以调用的参数信息:

    其中is-land的源码如下:

    class Island extends HTMLElement {
        static tagName = "is-land";
        static prefix = "is-land--";
        static attr = {
            template: "data-island",
            ready: "ready",
            defer: "defer-hydration"
        };
        static onceCache = new Map;
        static onReady = new Map;
        static fallback = {
            ":not(is-land,:defined,[defer-hydration])": (e, t, a) => {
                let n = document.createElement(a + t.localName);
                for (let e of t.getAttributeNames())
                    n.setAttribute(e, t.getAttribute(e));
                let i = t.shadowRoot;
                if (!i) {
                    let e = t.querySelector(":scope > template:is([shadowrootmode], [shadowroot])");
                    if (e) {
                        let a = e.getAttribute("shadowrootmode") || e.getAttribute("shadowroot") || "closed";
                        i = t.attachShadow({
                            mode: a
                        }),
                        i.appendChild(e.content.cloneNode(!0))
                    }
                }
                return i && n.attachShadow({
                    mode: i.mode
                }).append(...i.childNodes),
                n.append(...t.childNodes),
                t.replaceWith(n),
                e.then(( () => {
                    n.shadowRoot && t.shadowRoot.append(...n.shadowRoot.childNodes),
                    t.append(...n.childNodes),
                    n.replaceWith(t)
                }
                ))
            }
        };
        constructor() {
            super(),
            this.ready = new Promise((e => {
                this.readyResolve = e
            }
            ))
        }
        static getParents(e, t=!1) {
            let a = [];
            for (; e; ) {
                if (e.matches && e.matches(Island.tagName)) {
                    if (t && e === t)
                        break;
                    Conditions.hasConditions(e) && a.push(e)
                }
                e = e.parentNode
            }
            return a
        }
        static async ready(e, t) {
            if (t || (t = Island.getParents(e)),
            0 === t.length)
                return;
            let a = await Promise.all(t.map((e => e.wait())));
            return a.length ? a[0] : void 0
        }
        forceFallback() {
            window.Island && Object.assign(Island.fallback, window.Island.fallback);
            for (let e in Island.fallback) {
                let t = Array.from(this.querySelectorAll(e)).reverse();
                for (let a of t) {
                    if (!a.isConnected)
                        continue;
                    let t = Island.getParents(a);
                    if (1 === t.length) {
                        let n = Island.ready(a, t);
                        Island.fallback[e](n, a, Island.prefix)
                    }
                }
            }
        }
        wait() {
            return this.ready
        }
        async connectedCallback() {
            Conditions.hasConditions(this) && this.forceFallback(),
            await this.hydrate()
        }
        getTemplates() {
            return this.querySelectorAll(`template[${Island.attr.template}]`)
        }
        replaceTemplates(e) {
            for (let t of e) {
                if (Island.getParents(t, this).length > 0)
                    continue;
                let e = t.getAttribute(Island.attr.template);
                if ("replace" === e) {
                    let e = Array.from(this.childNodes);
                    for (let t of e)
                        this.removeChild(t);
                    this.appendChild(t.content);
                    break
                }
                {
                    let a = t.innerHTML;
                    if ("once" === e && a) {
                        if (Island.onceCache.has(a))
                            return void t.remove();
                        Island.onceCache.set(a, !0)
                    }
                    t.replaceWith(t.content)
                }
            }
        }
        async hydrate() {
            let e = [];
            this.parentNode && e.push(Island.ready(this.parentNode));
            let t = Conditions.getConditions(this);
            for (let a in t)
                Conditions.map[a] && e.push(Conditions.map[a](t[a], this));
            await Promise.all(e),
            this.replaceTemplates(this.getTemplates());
            for (let e of Island.onReady.values())
                await e.call(this, Island);
            this.readyResolve(),
            this.setAttribute(Island.attr.ready, ""),
            this.querySelectorAll(`[${Island.attr.defer}]`).forEach((e => e.removeAttribute(Island.attr.defer)))
        }
    }
    class Conditions {
        static map = {
            visible: Conditions.visible,
            idle: Conditions.idle,
            interaction: Conditions.interaction,
            media: Conditions.media,
            "save-data": Conditions.saveData
        };
        static hasConditions(e) {
            return Object.keys(Conditions.getConditions(e)).length > 0
        }
        static getConditions(e) {
            let t = {};
            for (let a of Object.keys(Conditions.map))
                e.hasAttribute(`on:${a}`) && (t[a] = e.getAttribute(`on:${a}`));
            return t
        }
        static visible(e, t) {
            if ("IntersectionObserver"in window)
                return new Promise((e => {
                    let a = new IntersectionObserver((t => {
                        let[n] = t;
                        n.isIntersecting && (a.unobserve(n.target),
                        e())
                    }
                    ));
                    a.observe(t)
                }
                ))
        }
        static idle() {
            let e = new Promise((e => {
                "complete" !== document.readyState ? window.addEventListener("load", ( () => e()), {
                    once: !0
                }) : e()
            }
            ));
            return "requestIdleCallback"in window ? Promise.all([new Promise((e => {
                requestIdleCallback(( () => {
                    e()
                }
                ))
            }
            )), e]) : e
        }
        static interaction(e, t) {
            let a = ["click", "touchstart"];
            return e && (a = (e || "").split(",").map((e => e.trim()))),
            new Promise((e => {
                function n(i) {
                    e();
                    for (let e of a)
                        t.removeEventListener(e, n)
                }
                for (let e of a)
                    t.addEventListener(e, n, {
                        once: !0
                    })
            }
            ))
        }
        static media(e) {
            let t = {
                matches: !0
            };
            if (e && "matchMedia"in window && (t = window.matchMedia(e)),
            !t.matches)
                return new Promise((e => {
                    t.addListener((t => {
                        t.matches && e()
                    }
                    ))
                }
                ))
        }
        static saveData(e) {
            if ("connection"in navigator && navigator.connection.saveData !== ("false" !== e))
                return new Promise(( () => {}
                ))
        }
    }
    "customElements"in window && (window.customElements.define(Island.tagName, Island),
    window.Island = Island);
    export {Island, Island as component};
    export const ready = Island.ready;

    通过前端代码调用:

    <is-land on:media="(prefers-reduced-motion: no-preference)" on:idle="" ready="">
    <snow-fall mode="page"></snow-fall>
    </is-land>

    其中可以定义:

    on:media:当满足媒体查询条件时加载内容。

    on:idle:在浏览器空闲时间或页面完全加载后执行。

    on:visible:当标签内容滚动到视口中时加载。

    on:interaction:在用户交互(如点击、触摸)后加载。

    on:save-data:根据设备的省流量模式决定是否加载内容。

    对于snow-fall.js,源码如下:

    class Snow extends HTMLElement {
        static random(t, e) {
            return t + Math.floor(Math.random() * (e - t) + 1)
        }
        static attrs = {
            count: "count",
            mode: "mode",
            text: "text"
        };
        generateCss(t, e) {
            let n = [];
            n.push('\n:host([mode="element"]) {\n\tdisplay: block;\n\tposition: relative;\n\toverflow: hidden;\n}\n:host([mode="page"]) {\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\tright: 0;\n}\n:host([mode="page"]),\n:host([mode="element"]) > * {\n\tpointer-events: none;\n}\n:host([mode="element"]) ::slotted(*) {\n\tpointer-events: all;\n}\n* {\n\tposition: absolute;\n}\n:host([text]) * {\n\tfont-size: var(--snow-fall-size, 1em);\n}\n:host(:not([text])) * {\n\twidth: var(--snow-fall-size, 10px);\n\theight: var(--snow-fall-size, 10px);\n\tbackground: var(--snow-fall-color, rgba(255,255,255,.5));\n\tborder-radius: 50%;\n}\n');
            let o = {
                width: 100,
                height: 100
            }
              , a = {
                x: "vw",
                y: "vh"
            };
            "element" === t && (o = {
                width: this.firstElementChild.clientWidth,
                height: this.firstElementChild.clientHeight
            },
            a = {
                x: "px",
                y: "px"
            });
            for (let t = 1; t <= e; t++) {
                let e = Snow.random(1, 100) * o.width / 100
                  , s = Snow.random(-10, 10) * o.width / 100
                  , i = Math.round(Snow.random(30, 100))
                  , l = i * o.height / 100
                  , r = o.height
                  , h = 1e-4 * Snow.random(1, 1e4)
                  , d = Snow.random(10, 30)
                  , m = -1 * Snow.random(0, 30);
                n.push(`\n:nth-child(${t}) {\n\topacity: ${.001 * Snow.random(0, 1e3)};\n\ttransform: translate(${e}${a.x}, -10px) scale(${h});\n\tanimation: fall-${t} ${d}s ${m}s linear infinite;\n}\n\n@keyframes fall-${t} {\n\t${i}% {\n\t\ttransform: translate(${e + s}${a.x}, ${l}${a.y}) scale(${h});\n\t}\n\n\tto {\n\t\ttransform: translate(${e + s / 2}${a.x}, ${r}${a.y}) scale(${h});\n\t}\n}`)
            }
            return n.join("\n")
        }
        connectedCallback() {
            if (this.shadowRoot || !("replaceSync"in CSSStyleSheet.prototype))
                return;
            let t, e = parseInt(this.getAttribute(Snow.attrs.count)) || 100;
            this.hasAttribute(Snow.attrs.mode) ? t = this.getAttribute(Snow.attrs.mode) : (t = this.firstElementChild ? "element" : "page",
            this.setAttribute(Snow.attrs.mode, t));
            let n = new CSSStyleSheet;
            n.replaceSync(this.generateCss(t, e));
            let o = this.attachShadow({
                mode: "open"
            });
            o.adoptedStyleSheets = [n];
            let a = document.createElement("div")
              , s = this.getAttribute(Snow.attrs.text);
            a.innerText = s || "";
            for (let t = 0, n = e; t < n; t++)
                o.appendChild(a.cloneNode(!0));
            o.appendChild(document.createElement("slot"))
        }
    }
    customElements.define("snow-fall", Snow);
    

    其定义了一个自定义 HTML 元素 <snow-fall>,用于实现落雪效果。

    我们可以通过改变js中的一些数值来变更效果,例如

    static attrs = { count: “count”, mode: “mode”, text: “text” };

    count:雪花的数量,默认值是 100。

    mode:两种模式:

    1、element:作用于某个特定元素。

    2、page:覆盖整个页面。

    text:雪花的文本内容(默认为空,显示为圆形点)。

    page模式目前已经满足使用,如果想使用element模式目前似乎会出现firstElementChild 为空或未能正确获取其 clientWidth,导致 generateCss 方法出错。如何正确使用element模式还需要探究。

    通过 generateCss(t, e) { 动态生成CSS,可以定义:

    1、:host([mode=”element”]) 和 :host([mode=”page”]) 用于适配不同模式。

    2、雪花的大小由 –snow-fall-size 决定,颜色由 –snow-fall-color 决定。

    动态生成每个雪花的动画样式:

    1、起始位置:translate(${e}${a.x}, -10px)

    2、随机透明度:opacity

    3、动画时长:animation: fall-${t} ${d}s ${m}s linear infinite;

    4、动画帧 @keyframes: 中间位置:随机 x 偏移,随机 y 偏移。最终位置:完全落到底部。

  • 【Python】对本地网页进行元素提取并输出Excel

    一些网页通过加载Js来保护页面元素,当我们突破Js得到本地页面时,可以使用BS4库对页面进行分析,提取对应的元素来综合有价值的内容。

    示例代码:

    import requests
    from bs4 import BeautifulSoup
    import pandas as pd
    
    # 发送请求并获取网页内容
    url = 'your_local_or_online_page_url'
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'html.parser')
    
    # 定义一个空的列表来存储提取的数据
    data = []
    
    # 遍历页面中的项目列表,假设项目数据都在某个父元素中
    projects = soup.find_all('tr', class_='project-id')  # 根据实际情况修改选择器
    
    # 提取每个项目的各项数据
    for project in projects:
        # 获取项目ID
        project_id = project.find('td', class_='project-id-class').get_text(strip=True)  # 修改为实际选择器
        
        # 将提取的数据添加到列表中
        data.append([project_id]) # 按实际修改
    
    # 创建 DataFrame 并保存为 Excel
    df = pd.DataFrame(data, columns=['ID']) # 按实际修改
    df.to_excel('projects_data.xlsx', index=False)
    
    print("Data has been successfully extracted and saved to 'projects_data.xlsx'.")

    主要用到了BS4库。

    示意代码:

    from bs4 import BeautifulSoup
    
    # 假设有一个HTML文档
    html_doc = """
    <html>
      <head><title>Example Page</title></head>
      <body>
        <p class="title"><b>Sample Page</b></p>
        <p class="story">This is a test story. <a href="http://example.com/1" class="link">link1</a> <a href="http://example.com/2" class="link">link2</a></p>
        <p class="story">Another test story.</p>
      </body>
    </html>
    """
    
    # 使用 BeautifulSoup 解析 HTML 文档
    soup = BeautifulSoup(html_doc, 'html.parser')
    
    # 提取<title>标签的内容
    title = soup.title.string
    print(f"Title: {title}")
    
    # 提取所有的链接(<a> 标签)
    links = soup.find_all('a')
    for link in links:
        print(f"Link text: {link.string}, URL: {link['href']}")
    
    # 查找特定类的<p>标签
    story_paragraphs = soup.find_all('p', class_='story')
    for p in story_paragraphs:
        print(f"Story paragraph: {p.get_text()}")

  • 【HTML】iframe小工具——提取嵌入链接并重设参数

    一般类似YouTube、Bilibili的分享链接,都设置了各自网站的相应参数,为了快速提取其src内容并自定义部分参数,可以使用该小工具进行快速设置。

    操作区
    预览展示区
    查看代码

    <style>
            textarea, input, select {
                width: 100%;
                margin-bottom: 10px;
                padding: 5px;
                font-size: 14px;
            }
            button {
                font-size: 16px;
                margin-bottom: 10px;
                padding: 5px 10px;
            }
            .link-container {
                margin-top: 10px;
            }
            .link-item {
                margin-bottom: 5px;
            }
            .iframe-preview {
                margin-top: 20px;
                padding: 10px;
                border: 1px solid #ddd;
                background: #f9f9f9;
            }
            .iframe-preview pre {
                font-size: 14px;
                background: #e9e9e9;
                padding: 10px;
                border-radius: 5px;
            }
            .row {
                display: flex;
                flex-wrap: wrap;
                gap: 10px;
            }
            .col {
                flex: 1 1 20%;
            }
            .col input, .col select {
                width: 100%;
            }
            .unit-select {
                width: 10px; /*缩小单位选择框宽度*/
            }
            .empty-option {
                font-size: 14px;
            }
            #iframePreviewContainer {
                margin-top: 20px;
            }
        </style>
    </head>
    <body>
        <!-- 输入框 -->
        <textarea id="iframeInput" placeholder="在此输入多个 iframe 代码"></textarea>
        <button id="extractButton">提取链接</button>
    
        <!-- 链接展示区 -->
        <div id="result" class="link-container"></div>
    
        <!-- 单个链接操作区 -->
        <h2>操作区</h2>
        <input id="selectedLink" type="text" placeholder="点击复制按钮后,链接将填入此处" readonly>
        
        <!-- iframe 属性设置 -->
        <div class="row">
            <div class="col">
                <label for="iframeTitle">标题:</label>
                <input id="iframeTitle" type="text" placeholder="请输入 iframe 标题">
            </div>
            <div class="col">
                <label for="iframeWidth">宽度:</label>
                <input id="iframeWidth" type="text" placeholder="例如 560">
            </div>
            <div class="col">
                <label for="iframeWidthUnit">宽度单位:</label>
                <select id="iframeWidthUnit" class="unit-select">
                    <option value="px">px</option>
                    <option value="%">%</option>
                    <option value="vw">vw</option>
                </select>
            </div>
            <div class="col">
                <label for="iframeHeight">高度:</label>
                <input id="iframeHeight" type="text" placeholder="例如 315">
            </div>
            <div class="col">
                <label for="iframeHeightUnit">高度单位:</label>
                <select id="iframeHeightUnit" class="unit-select">
                    <option value="px">px</option>
                    <option value="%">%</option>
                    <option value="vh">vh</option>
                </select>
            </div>
        </div>
    
        <div class="row">
            <div class="col">
                <label for="iframeFullscreen">允许全屏:</label>
                <select id="iframeFullscreen">
                    <option value="allowfullscreen">是</option>
                    <option value="">否</option>
                </select>
            </div>
            <div class="col">
                <label for="iframeReferrer">Referrer Policy:</label>
                <select id="iframeReferrer">
                    <option value="no-referrer">不发送</option>
                    <option value="no-referrer-when-downgrade">仅同源</option>
                    <option value="origin">仅发送源</option>
                    <option value="origin-when-cross-origin">跨源时发送源</option>
                    <option value="same-origin">同源发送完整路径</option>
                    <option value="strict-origin">严格同源发送源</option>
                    <option value="strict-origin-when-cross-origin">默认(严格同源)</option>
                    <option value="unsafe-url">发送完整 URL</option>
                    <option value="">保持空值</option>
                </select>
            </div>
            <div class="col">
                <label for="iframeLoading">加载方式:</label>
                <select id="iframeLoading">
                    <option value="eager">立即加载</option>
                    <option value="lazy">懒加载</option>
                    <option value="">保持空值</option>
                </select>
            </div>
            <div class="col">
                <label for="iframeAutoplay">自动播放:</label>
                <select id="iframeAutoplay">
                    <option value="autoplay">是</option>
                    <option value="">否</option>
                </select>
            </div>
        </div>
    
        <div class="row">
            <div class="col">
                <label for="iframeEncrypted">加密媒体:</label>
                <select id="iframeEncrypted">
                    <option value="encrypted-media">是</option>
                    <option value="">否</option>
                </select>
            </div>
            <div class="col">
                <label for="iframePictureInPicture">画中画:</label>
                <select id="iframePictureInPicture">
                    <option value="picture-in-picture">是</option>
                    <option value="">否</option>
                </select>
            </div>
            <div class="col">
                <label for="iframeWebShare">Web分享:</label>
                <select id="iframeWebShare">
                    <option value="web-share">是</option>
                    <option value="">否</option>
                </select>
            </div>
        </div>
    
        <!-- 生成 iframe 和复制按钮 -->
        <div>
            <button id="generateIframeButton">生成 iframe 嵌入代码</button>
            <button id="copyIframeButton">复制生成代码</button>
        </div>
    
        <!-- iframe 代码展示 -->
        <div id="generatedIframe" class="iframe-preview">
            <textarea id="iframeCodeText" readonly rows="10"></textarea>
        </div>
    
        <!-- iframe 预览展示区 -->
        <div id="iframePreviewContainer" class="iframe-preview">
            <h2>预览展示区</h2>
            <iframe id="iframePreview" src="" width="560" height="315" style="border: none;"></iframe>
        </div>
    
        <script>
            // 缓存常用的 DOM 元素
            const iframeWidthInput = document.getElementById('iframeWidth');
            const iframeHeightInput = document.getElementById('iframeHeight');
            const iframeWidthUnit = document.getElementById('iframeWidthUnit');
            const iframeHeightUnit = document.getElementById('iframeHeightUnit');
            const selectedLinkInput = document.getElementById('selectedLink');
            const iframeFullscreenSelect = document.getElementById('iframeFullscreen');
            const iframeReferrerSelect = document.getElementById('iframeReferrer');
            const iframeLoadingSelect = document.getElementById('iframeLoading');
            const iframeAutoplaySelect = document.getElementById('iframeAutoplay');
            const iframeEncryptedSelect = document.getElementById('iframeEncrypted');
            const iframePictureInPictureSelect = document.getElementById('iframePictureInPicture');
            const iframeWebShareSelect = document.getElementById('iframeWebShare');
            const iframeTitleInput = document.getElementById('iframeTitle');
            const generateIframeButton = document.getElementById('generateIframeButton');
            const copyIframeButton = document.getElementById('copyIframeButton');
            const iframeCodeText = document.getElementById('iframeCodeText');
            const iframePreview = document.getElementById('iframePreview');
            const resultDiv = document.getElementById('result');
    
            // 提取 iframe src 链接
            function extractSrc() {
                const input = document.getElementById('iframeInput').value;
                resultDiv.innerHTML = ''; // 清空之前的结果
    
                const srcMatches = [...input.matchAll(/src="([^"]+)"/g)];
                if (srcMatches.length > 0) {
                    const srcLinks = srcMatches.map(match => match[1]);
    
                    srcLinks.forEach(link => {
                        const linkItem = createLinkItem(link);
                        resultDiv.appendChild(linkItem);
                    });
                } else {
                    resultDiv.textContent = '没有找到有效的 iframe 链接';
                }
            }
    
            // 创建链接项
            function createLinkItem(link) {
                const div = document.createElement('div');
                div.classList.add('link-item');
    
                const textNode = document.createTextNode(link);
                const copyButton = document.createElement('button');
                copyButton.textContent = '复制';
                copyButton.onclick = function() {
                    selectedLinkInput.value = link;
                };
    
                div.appendChild(textNode);
                div.appendChild(copyButton);
                return div;
            }
    
            // 生成 iframe 代码
            function generateIframeCode() {
                const width = iframeWidthInput.value;
                const height = iframeHeightInput.value;
                const widthUnit = iframeWidthUnit.value;
                const heightUnit = iframeHeightUnit.value;
                const title = iframeTitleInput.value;
                const src = selectedLinkInput.value;
                const fullscreen = iframeFullscreenSelect.value;
                const referrer = iframeReferrerSelect.value;
                const loading = iframeLoadingSelect.value;
                const autoplay = iframeAutoplaySelect.value;
                const encrypted = iframeEncryptedSelect.value;
                const pictureInPicture = iframePictureInPictureSelect.value;
                const webShare = iframeWebShareSelect.value;
    
                let iframeCode = `<iframe src="${src}"`;
    
                if (title) {
                    iframeCode += ` title="${title}"`;
                }
                iframeCode += ` width="${width}${widthUnit}" height="${height}${heightUnit}"`;
    
                if (fullscreen) {
                    iframeCode += ` ${fullscreen}`;
                }
    
                if (referrer) {
                    iframeCode += ` referrerpolicy="${referrer}"`;
                }
    
                if (loading) {
                    iframeCode += ` loading="${loading}"`;
                }
    
                if (autoplay) {
                    iframeCode += ` ${autoplay}`;
                }
    
                if (encrypted) {
                    iframeCode += ` ${encrypted}`;
                }
    
                if (pictureInPicture) {
                    iframeCode += ` ${pictureInPicture}`;
                }
    
                if (webShare) {
                    iframeCode += ` ${webShare}`;
                }
    
                iframeCode += '></iframe>';
    
                iframeCodeText.value = iframeCode;
                iframePreview.src = src;
            }
    
            // 复制代码到剪贴板
            function copyIframeCode() {
                iframeCodeText.select();
                document.execCommand('copy');
            }
    
            // 事件监听
            document.getElementById('extractButton').addEventListener('click', extractSrc);
            generateIframeButton.addEventListener('click', generateIframeCode);
            copyIframeButton.addEventListener('click', copyIframeCode);
        </script>
    </body>