331 lines
8.0 KiB
Python
331 lines
8.0 KiB
Python
import io
|
|
import typing
|
|
|
|
import struct
|
|
|
|
from PIL import Image as PILImage
|
|
|
|
from hydrus.core import HydrusConstants as HC
|
|
from hydrus.core import HydrusExceptions
|
|
from hydrus.core.images import HydrusImageHandling
|
|
from hydrus.core.images import HydrusImageOpening
|
|
|
|
def GetAnimationProperties( path, mime ):
|
|
|
|
pil_image = HydrusImageHandling.GeneratePILImage( path )
|
|
|
|
( width, height ) = pil_image.size
|
|
|
|
width = max( width, 1 )
|
|
height = max( height, 1 )
|
|
|
|
if mime == HC.ANIMATION_APNG:
|
|
|
|
( duration, num_frames ) = GetAPNGDurationAndNumFrames( path )
|
|
|
|
else:
|
|
|
|
( durations, times_to_play ) = GetFrameDurationsPILAnimation( path )
|
|
|
|
duration = sum( durations )
|
|
num_frames = len( durations )
|
|
|
|
|
|
return ( ( width, height ), duration, num_frames )
|
|
|
|
|
|
def GetAPNGChunks( file_header_bytes: bytes ) ->list:
|
|
|
|
# https://wiki.mozilla.org/APNG_Specification
|
|
# a chunk is:
|
|
# 4 bytes of data size, unsigned int
|
|
# 4 bytes of chunk name
|
|
# n bytes of data
|
|
# 4 bytes of CRC
|
|
|
|
# ok this method went super slow when given a 200MB giga png
|
|
# it turns out list slicing on a very large bytes object is extremely slow
|
|
# so we'll move to a file-like BytesIO and just read the stream like a file
|
|
|
|
# note lol that if you debug this you'll still get the mega slowdown as your IDE copies the giganto bytes around over and over for variable inspection
|
|
|
|
chunks = []
|
|
|
|
buffer = io.BytesIO( file_header_bytes )
|
|
|
|
# lop off 8 bytes of 'this is a PNG' at the top
|
|
buffer.read( 8 )
|
|
|
|
while True:
|
|
|
|
chunk_num_bytes = buffer.read( 4 )
|
|
|
|
chunk_name = buffer.read( 4 )
|
|
|
|
if len( chunk_num_bytes ) < 4 or len( chunk_name ) < 4:
|
|
|
|
break
|
|
|
|
|
|
( num_data_bytes, ) = struct.unpack( '>I', chunk_num_bytes )
|
|
|
|
chunk_data = buffer.read( num_data_bytes )
|
|
|
|
if len( chunk_data ) < num_data_bytes:
|
|
|
|
break
|
|
|
|
|
|
buffer.read( 4 )
|
|
|
|
chunks.append( ( chunk_name, chunk_data ) )
|
|
|
|
|
|
# old solution
|
|
'''
|
|
remaining_chunk_bytes = file_header_bytes[8:]
|
|
|
|
while len( remaining_chunk_bytes ) > 12:
|
|
|
|
( num_data_bytes, ) = struct.unpack( '>I', remaining_chunk_bytes[ : 4 ] )
|
|
|
|
chunk_name = remaining_chunk_bytes[ 4 : 8 ]
|
|
|
|
chunk_data = remaining_chunk_bytes[ 8 : 8 + num_data_bytes ]
|
|
|
|
chunks.append( ( chunk_name, chunk_data ) )
|
|
|
|
remaining_chunk_bytes = remaining_chunk_bytes[ 8 + num_data_bytes + 4 : ]
|
|
|
|
'''
|
|
|
|
return chunks
|
|
|
|
|
|
def GetAPNGACTLChunkData( file_header_bytes: bytes ) -> typing.Optional[ bytes ]:
|
|
|
|
# the acTL chunk can be in different places, but it has to be near the top
|
|
# although it is almost always in fixed position (I think byte 29), we have seen both pHYs and sRGB chunks appear before it
|
|
# so to be proper we need to parse chunks and find the right one
|
|
apng_actl_chunk_header = b'acTL'
|
|
|
|
chunks = GetAPNGChunks( file_header_bytes )
|
|
|
|
chunks = dict( chunks )
|
|
|
|
if apng_actl_chunk_header in chunks:
|
|
|
|
return chunks[ apng_actl_chunk_header ]
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
|
|
|
def GetAPNGDuration( apng_bytes: bytes ) -> float:
|
|
|
|
frame_control_chunk_name = b'fcTL'
|
|
|
|
chunks = GetAPNGChunks( apng_bytes )
|
|
|
|
total_duration = 0
|
|
|
|
CRAZY_FRAME_TIME = 0.1
|
|
MIN_FRAME_TIME = 0.001
|
|
|
|
for ( chunk_name, chunk_data ) in chunks:
|
|
|
|
if chunk_name == frame_control_chunk_name and len( chunk_data ) >= 24:
|
|
|
|
( delay_numerator, ) = struct.unpack( '>H', chunk_data[20:22] )
|
|
( delay_denominator, ) = struct.unpack( '>H', chunk_data[22:24] )
|
|
|
|
if delay_denominator == 0:
|
|
|
|
duration = CRAZY_FRAME_TIME
|
|
|
|
else:
|
|
|
|
duration = max( delay_numerator / delay_denominator, MIN_FRAME_TIME )
|
|
|
|
|
|
total_duration += duration
|
|
|
|
|
|
|
|
return total_duration
|
|
|
|
|
|
def GetAPNGNumFrames( apng_actl_bytes: bytes ) -> int:
|
|
|
|
( num_frames, ) = struct.unpack( '>I', apng_actl_bytes[ : 4 ] )
|
|
|
|
return num_frames
|
|
|
|
|
|
def GetAPNGDurationAndNumFrames( path ):
|
|
|
|
with open( path, 'rb' ) as f:
|
|
|
|
file_header_bytes = f.read( 256 )
|
|
|
|
|
|
apng_actl_bytes = GetAPNGACTLChunkData( file_header_bytes )
|
|
|
|
if apng_actl_bytes is None:
|
|
|
|
raise HydrusExceptions.DamagedOrUnusualFileException( 'This APNG had an unusual file header!' )
|
|
|
|
|
|
num_frames = GetAPNGNumFrames( apng_actl_bytes )
|
|
|
|
with open( path, 'rb' ) as f:
|
|
|
|
file_bytes = f.read()
|
|
|
|
|
|
duration = GetAPNGDuration( file_bytes )
|
|
|
|
duration_in_ms_float = duration * 1000
|
|
|
|
duration_in_ms = int( duration * 1000 )
|
|
|
|
if duration_in_ms == 0 and duration_in_ms_float > 0:
|
|
|
|
duration_in_ms = 1
|
|
|
|
|
|
return ( duration_in_ms, num_frames )
|
|
|
|
|
|
def GetFrameDurationsPILAnimation( path ):
|
|
|
|
pil_image = HydrusImageOpening.RawOpenPILImage( path )
|
|
|
|
times_to_play = GetTimesToPlayPILAnimationFromPIL( pil_image )
|
|
|
|
frame_durations = []
|
|
|
|
i = 0
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
pil_image.seek( i )
|
|
|
|
except:
|
|
|
|
break
|
|
|
|
|
|
if 'duration' not in pil_image.info:
|
|
|
|
duration = 83 # (83ms -- 1000 / 12) Set a 12 fps default when duration is missing or too funky to extract. most stuff looks ok at this.
|
|
|
|
else:
|
|
|
|
duration = pil_image.info[ 'duration' ]
|
|
|
|
# In the gif frame header, 10 is stored as 1ms. This 1 is commonly as utterly wrong as 0.
|
|
if duration in ( 0, 10 ):
|
|
|
|
duration = 83
|
|
|
|
|
|
|
|
frame_durations.append( duration )
|
|
|
|
i += 1
|
|
|
|
|
|
return ( frame_durations, times_to_play )
|
|
|
|
|
|
def GetTimesToPlayAPNG( path: str ) -> int:
|
|
|
|
with open( path, 'rb' ) as f:
|
|
|
|
file_header_bytes = f.read( 256 )
|
|
|
|
|
|
apng_actl_bytes = GetAPNGACTLChunkData( file_header_bytes )
|
|
|
|
if apng_actl_bytes is None:
|
|
|
|
return 0
|
|
|
|
|
|
( num_plays, ) = struct.unpack( '>I', apng_actl_bytes[ 4 : 8 ] )
|
|
|
|
return num_plays
|
|
|
|
|
|
def GetTimesToPlayPILAnimation( path ) -> int:
|
|
|
|
try:
|
|
|
|
pil_image = HydrusImageOpening.RawOpenPILImage( path )
|
|
|
|
except HydrusExceptions.UnsupportedFileException:
|
|
|
|
return 1
|
|
|
|
|
|
return GetTimesToPlayPILAnimationFromPIL( pil_image )
|
|
|
|
|
|
def GetTimesToPlayPILAnimationFromPIL( pil_image: PILImage.Image ) -> int:
|
|
|
|
if 'loop' in pil_image.info:
|
|
|
|
times_to_play = pil_image.info[ 'loop' ]
|
|
|
|
else:
|
|
|
|
times_to_play = 1
|
|
|
|
|
|
return times_to_play
|
|
|
|
|
|
def PILAnimationHasDuration( path ):
|
|
|
|
pil_image = HydrusImageHandling.GeneratePILImage( path, dequantize = False )
|
|
|
|
try:
|
|
|
|
pil_image.seek( 1 )
|
|
pil_image.seek( 0 )
|
|
|
|
return True
|
|
|
|
except:
|
|
|
|
return False
|
|
|
|
|
|
|
|
def IsPNGAnimated( file_header_bytes ):
|
|
|
|
apng_actl_bytes = GetAPNGACTLChunkData( file_header_bytes )
|
|
|
|
if apng_actl_bytes is not None:
|
|
|
|
# this is an animated png
|
|
|
|
# acTL chunk in an animated png is 4 bytes of num frames, then 4 bytes of num times to loop
|
|
# https://wiki.mozilla.org/APNG_Specification#.60acTL.60:_The_Animation_Control_Chunk
|
|
|
|
num_frames = GetAPNGNumFrames( apng_actl_bytes )
|
|
|
|
if num_frames > 1:
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
|
|