mirror of
https://mikeslab.dix.asia/gogs/My-Mods/ANI2Cape.git
synced 2025-10-01 23:14:57 +00:00
Features, packaging + sample config, cf. README.md
This commit is contained in:
parent
8ded24d9f2
commit
d8a9ba0713
4 changed files with 237 additions and 76 deletions
13
README.md
13
README.md
|
@ -1,3 +1,16 @@
|
||||||
|
# Forked ANI2Cape
|
||||||
|
|
||||||
|
- 添加了一个完整的[config.py](./config.py)范例
|
||||||
|
- 添加了对普通静态.cur文件的支持(部分.cur是直接用.ico重命名得到的,在此特别支持)
|
||||||
|
- HiDPI打开时(范例中默认打开),将源文件自动视为两倍大小版本导入,并同时生成普通大小版本
|
||||||
|
- 添加了自动计算长宽的功能
|
||||||
|
a. 长和宽均为-1,则直接从原图取得长宽数值
|
||||||
|
b. 长、宽二者之一为-1,则从给定数值的边长计算出缩放倍率并等比缩放
|
||||||
|
|
||||||
|
注:
|
||||||
|
- `ani2cape.py`不接受参数,直接运行即读取同目录下`config.py`内容,其中`Cursors`子项目`Path`属性使用相对路径时基于当前切换到的目录(而不是程序目录)
|
||||||
|
- 建议先创建并启用venv后,用`pip install -e .`安装,编辑`config.py`内容后,再切换到包含指针的目录直接运行`ani2cape`命令
|
||||||
|
|
||||||
# ANI2Cape
|
# ANI2Cape
|
||||||
A tool that can convert Windows Animated Cursors (*.ani) to GIF/Pillow Images/Cape format
|
A tool that can convert Windows Animated Cursors (*.ani) to GIF/Pillow Images/Cape format
|
||||||
|
|
||||||
|
|
170
ani2cape.py
170
ani2cape.py
|
@ -1,56 +1,75 @@
|
||||||
from config import capeConfig
|
import io
|
||||||
from plistlib import dumps,loads,dump,FMT_XML,load
|
|
||||||
from PIL import Image
|
|
||||||
import io,os,sys,base64
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
logging.basicConfig(format='%(asctime)s - %(pathname)s[line:%(lineno)d] - %(levelname)s: %(message)s',level=logging.INFO)
|
logging.basicConfig(format='%(asctime)s - %(pathname)s[line:%(lineno)d] - %(levelname)s: %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
def analyzeANIFile(filePath):
|
|
||||||
with open(filePath,'rb') as f:
|
|
||||||
if f.read(4) != b'RIFF':
|
|
||||||
return {"code":-1,"msg":"File is not a ANI File!"}
|
|
||||||
logging.debug('文件头检查完成!')
|
|
||||||
fileSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
|
||||||
# if os.path.getsize(filePath) != fileSize:
|
|
||||||
# return {"code":-2,"msg":"File is damaged!"}
|
|
||||||
logging.debug('文件长度检查完成!')
|
|
||||||
if f.read(4) != b'ACON':
|
|
||||||
return {"code":-1,"msg":"File is not a ANI File!"}
|
|
||||||
logging.debug('魔数检查完成!')
|
|
||||||
frameRate = (1/60)*1000
|
|
||||||
while(True):
|
|
||||||
chunkName = f.read(4)
|
|
||||||
if chunkName == b'LIST':
|
|
||||||
break
|
|
||||||
chunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
|
||||||
if chunkName.lower() == b'rate':
|
|
||||||
logging.debug('发现自定义速率!')
|
|
||||||
frameRate = frameRate * int.from_bytes(f.read(4), byteorder='little', signed=False)
|
|
||||||
logging.warning('发现自定义速率!由于GIF限制,将取第一帧与第二帧的速率作为整体速率!')
|
|
||||||
f.read(chunkSize - 4)
|
|
||||||
else:
|
|
||||||
logging.debug('发现自定义Chunk!')
|
|
||||||
f.read(chunkSize)
|
|
||||||
listChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
|
||||||
if f.read(4) != b'fram':
|
|
||||||
return {"code":-3,"msg":"File not a ANI File!(No Frames)"}
|
|
||||||
logging.debug('frame头检查完成!')
|
|
||||||
frameList = []
|
|
||||||
nowSize = 4
|
|
||||||
while(nowSize < listChunkSize):
|
|
||||||
if f.read(4) != b'icon':
|
|
||||||
return {"code":-4,"msg":"File not a ANI File!(Other Kind Frames)"}
|
|
||||||
nowSize += 4
|
|
||||||
subChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
|
||||||
nowSize += 4
|
|
||||||
frameList.append(f.read(subChunkSize))
|
|
||||||
nowSize += subChunkSize
|
|
||||||
return {"code":0,"msg":frameList,"frameRate":frameRate}
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def scaleImage(img, scale):
|
||||||
|
if img is None:
|
||||||
|
return
|
||||||
|
return img.resize((img.width * scale, img.height * scale))
|
||||||
|
|
||||||
|
|
||||||
|
def readCUR(f, width=-1.0, height=-1.0):
|
||||||
|
frameImage = Image.open(f, formats=['cur', 'ico']).convert('RGBA')
|
||||||
|
if (width, height) == (-1.0, -1.0):
|
||||||
|
return frameImage, (float(frameImage.width), float(frameImage.height))
|
||||||
|
if -1 in (width, height):
|
||||||
|
width = height / frameImage.height * frameImage.width if width == -1 else width
|
||||||
|
height = width / frameImage.width * frameImage.height if height == -1 else height
|
||||||
|
return frameImage.resize((int(width), int(height))), (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def analyzeANI(f):
|
||||||
|
if f.read(4) != b'RIFF':
|
||||||
|
return {'code': -1, 'msg': 'File is not a ANI File!'}
|
||||||
|
logging.debug('文件头检查完成!')
|
||||||
|
fileSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
||||||
|
# if os.path.getsize(filePath) != fileSize:
|
||||||
|
# return {'code':-2,'msg':'File is damaged!'}
|
||||||
|
logging.debug('文件长度检查完成!')
|
||||||
|
if f.read(4) != b'ACON':
|
||||||
|
return {'code': -1, 'msg': 'File is not a ANI File!'}
|
||||||
|
logging.debug('魔数检查完成!')
|
||||||
|
frameRate = (1/60)*1000
|
||||||
|
while (True):
|
||||||
|
chunkName = f.read(4)
|
||||||
|
if chunkName == b'LIST':
|
||||||
|
break
|
||||||
|
chunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
||||||
|
if chunkName.lower() == b'rate':
|
||||||
|
logging.debug('发现自定义速率!')
|
||||||
|
frameRate = frameRate * int.from_bytes(f.read(4), byteorder='little', signed=False)
|
||||||
|
logging.warning('发现自定义速率!由于GIF限制,将取第一帧与第二帧的速率作为整体速率!')
|
||||||
|
f.read(chunkSize - 4)
|
||||||
|
else:
|
||||||
|
logging.debug('发现自定义Chunk!')
|
||||||
|
f.read(chunkSize)
|
||||||
|
listChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
||||||
|
if f.read(4) != b'fram':
|
||||||
|
return {'code': -3, 'msg': 'File not a ANI File!(No Frames)'}
|
||||||
|
logging.debug('frame头检查完成!')
|
||||||
|
frameList = []
|
||||||
|
nowSize = 4
|
||||||
|
while (nowSize < listChunkSize):
|
||||||
|
if f.read(4) != b'icon':
|
||||||
|
return {'code': -4, 'msg': 'File not a ANI File!(Other Kind Frames)'}
|
||||||
|
nowSize += 4
|
||||||
|
subChunkSize = int.from_bytes(f.read(4), byteorder='little', signed=False)
|
||||||
|
nowSize += 4
|
||||||
|
frameList.append(f.read(subChunkSize))
|
||||||
|
nowSize += subChunkSize
|
||||||
|
return {'code': 0, 'msg': frameList, 'frameRate': frameRate}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from config import capeConfig
|
||||||
|
uniqueId = (f'local.{capeConfig['Author'] or 'unknown'}'
|
||||||
|
f'.{capeConfig['CapeName'] or 'untitled'}'
|
||||||
|
f'.{time.time()}.{str(uuid.uuid4()).upper()}')
|
||||||
capeData = {
|
capeData = {
|
||||||
'Author': capeConfig['Author'],
|
'Author': capeConfig['Author'],
|
||||||
'CapeName': capeConfig['CapeName'],
|
'CapeName': capeConfig['CapeName'],
|
||||||
|
@ -58,35 +77,48 @@ if __name__ == '__main__':
|
||||||
'Cloud': False,
|
'Cloud': False,
|
||||||
'Cursors': {},
|
'Cursors': {},
|
||||||
'HiDPI': capeConfig['HiDPI'],
|
'HiDPI': capeConfig['HiDPI'],
|
||||||
'Identifier': f"local.{capeConfig['Author']}.{capeConfig['CapeName']}.{time.time()}.{str(uuid.uuid4()).upper()}.{time.time()}",
|
'Identifier': capeConfig['Identifier'] or uniqueId,
|
||||||
'MinimumVersion': 2.0,
|
'MinimumVersion': 2.0,
|
||||||
'Version': 2.0
|
'Version': 2.0
|
||||||
}
|
}
|
||||||
for cursorType in capeConfig['Cursors'].keys():
|
for cursorType, cursorConfig in capeConfig['Cursors'].items():
|
||||||
cursorSetting = {
|
cursorSetting = {
|
||||||
'FrameCount': 1,
|
'FrameCount': 1,
|
||||||
'FrameDuration': capeConfig['Cursors'][cursorType]['FrameDuration'],
|
'FrameDuration': cursorConfig['FrameDuration'],
|
||||||
'HotSpotX': capeConfig['Cursors'][cursorType]['HotSpot'][0],
|
'HotSpotX': cursorConfig['HotSpot'][0],
|
||||||
'HotSpotY': capeConfig['Cursors'][cursorType]['HotSpot'][1],
|
'HotSpotY': cursorConfig['HotSpot'][1],
|
||||||
'PointsHigh': capeConfig['Cursors'][cursorType]['Size'][0],
|
|
||||||
'PointsWide': capeConfig['Cursors'][cursorType]['Size'][1],
|
|
||||||
'Representations': []
|
'Representations': []
|
||||||
}
|
}
|
||||||
res = analyzeANIFile(capeConfig['Cursors'][cursorType]['ANIPath'])
|
hidpiRatio = 2 if capeConfig['HiDPI'] else 1
|
||||||
if res["code"] == 0:
|
width, height = cursorConfig.get('Size', (-1.0, -1.0))
|
||||||
logging.info('ANI文件分析完成,帧提取完成!')
|
|
||||||
cursorSetting['FrameCount'] = len(res["msg"])
|
|
||||||
spriteSheet = Image.new("RGBA", (int(cursorSetting['PointsHigh']), int(cursorSetting['PointsWide'] * len(res["msg"]))))
|
|
||||||
for frameIndex in range(len(res["msg"])):
|
|
||||||
frameImage = Image.open(io.BytesIO(res["msg"][frameIndex]),formats=['cur']).convert('RGBA')
|
|
||||||
extracted_frame = frameImage.resize((int(cursorSetting['PointsHigh']), int(cursorSetting['PointsWide'])))
|
|
||||||
position = (0, int(cursorSetting['PointsHigh'] * frameIndex))
|
|
||||||
spriteSheet.paste(extracted_frame, position)
|
|
||||||
|
|
||||||
byteBuffer = io.BytesIO()
|
with open(cursorConfig['Path'], 'rb') as f:
|
||||||
spriteSheet.save(byteBuffer,format="TIFF")
|
spriteSheet = None
|
||||||
cursorSetting['Representations'].append(byteBuffer.getvalue())
|
if (res := analyzeANI(f))['code'] == 0:
|
||||||
capeData['Cursors'][cursorType] = cursorSetting
|
logging.info('ANI文件分析完成,帧提取完成!')
|
||||||
|
cursorSetting['FrameCount'] = len(res['msg'])
|
||||||
|
for frameIndex in range(len(res['msg'])):
|
||||||
|
b = io.BytesIO(res['msg'][frameIndex])
|
||||||
|
frame, (width, height) = readCUR(b, width, height)
|
||||||
|
position = (0, int(width * frameIndex))
|
||||||
|
if frameIndex == 0:
|
||||||
|
spriteSheet = Image.new('RGBA', (int(width), int(height) * len(res['msg'])))
|
||||||
|
spriteSheet.paste(frame, position)
|
||||||
|
else:
|
||||||
|
logging.info('尝试作为CUR读入')
|
||||||
|
spriteSheet, (width, height) = readCUR(f, width, height)
|
||||||
|
logging.info(f'目标尺寸:{width}x{height}@{hidpiRatio}x')
|
||||||
|
cursorSetting['PointsHigh'], cursorSetting['PointsWide'] = width, height
|
||||||
|
for scale in (1, 2) if capeConfig['HiDPI'] else (1,):
|
||||||
|
byteBuffer = io.BytesIO()
|
||||||
|
scaleImage(spriteSheet, scale).save(byteBuffer, format='tiff', compression='tiff_lzw')
|
||||||
|
cursorSetting['Representations'].append(byteBuffer.getvalue())
|
||||||
|
capeData['Cursors'][cursorType] = cursorSetting
|
||||||
|
|
||||||
with open(f"{capeData['Identifier']}.cape",'wb') as f:
|
from plistlib import dump, FMT_XML
|
||||||
|
with open(f'{capeData['Identifier']}.cape', 'wb') as f:
|
||||||
dump(capeData, f, fmt=FMT_XML, sort_keys=True, skipkeys=False)
|
dump(capeData, f, fmt=FMT_XML, sort_keys=True, skipkeys=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
113
config.py
113
config.py
|
@ -1,14 +1,117 @@
|
||||||
capeConfig = {
|
capeConfig = {
|
||||||
'Author': '小蓝蓝',
|
'Author': '作者名',
|
||||||
'CapeName': '蓝蓝的测试图标',
|
'CapeName': '鼠标指针',
|
||||||
'CapeVersion': 1.0,
|
'CapeVersion': 1.0,
|
||||||
|
'Identifier': 'com.mihuashi.authornickname.cursors',
|
||||||
'Cursors': {
|
'Cursors': {
|
||||||
'com.apple.coregraphics.Arrow': {
|
'com.apple.coregraphics.Arrow': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (0.0, 0.0),
|
||||||
|
'Size': (32.0, -1.0),
|
||||||
|
'Path': "./Normal.ani"
|
||||||
|
},
|
||||||
|
'com.apple.coregraphics.Move': {
|
||||||
'FrameDuration': 0.1,
|
'FrameDuration': 0.1,
|
||||||
'HotSpot': (0.0, 0.0),
|
'HotSpot': (0.0, 0.0),
|
||||||
'Size': (32.0, 32.0),
|
'Size': (32.0, 32.0),
|
||||||
'ANIPath': "./busy.ani"
|
'Path': "./Normal.ani"
|
||||||
}
|
},
|
||||||
|
'com.apple.cursor.4': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (11.0, 11.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Busy.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.34': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Diagonal1.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.30': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Diagonal2.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.40': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (0.0, 0.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Help.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.19': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Horizontal.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.28': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Horizontal.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.2': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (0.0, 0.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Link.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.13': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (0.0, 0.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Link.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.39': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Move.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.5': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (7.0, 7.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Precision.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.20': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (7.0, 7.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Precision.ani"
|
||||||
|
},
|
||||||
|
'com.apple.coregraphics.IBeam': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (5.0, 7.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Text.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.23': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Vertical.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.32': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (15.0, 15.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Vertical.ani"
|
||||||
|
},
|
||||||
|
'com.apple.coregraphics.Wait': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (6.0, 6.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Working.ani"
|
||||||
|
},
|
||||||
|
'com.apple.cursor.3': {
|
||||||
|
'FrameDuration': 0.1,
|
||||||
|
'HotSpot': (8.0, 8.0),
|
||||||
|
'Size': (32.0, 32.0),
|
||||||
|
'Path': "./Unavailable.ani"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'HiDPI': False
|
'HiDPI': True
|
||||||
}
|
}
|
||||||
|
|
13
setup.py
Normal file
13
setup.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='ani2cape',
|
||||||
|
version='0.0.2',
|
||||||
|
install_requires=['pillow>=10.0'],
|
||||||
|
py_modules=['ani2cape', 'config'],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'ani2cape = ani2cape:main',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
Loading…
Add table
Add a link
Reference in a new issue