Skip to content

Commit

Permalink
Move keyframe application to the C-based bitmap decompression library.
Browse files Browse the repository at this point in the history
  • Loading branch information
npjg committed Aug 3, 2024
1 parent 967c198 commit 0a509da
Show file tree
Hide file tree
Showing 4 changed files with 89 additions and 113 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
ext_modules = [bitmap_decompression, ima_adpcm_decompression])
except:
# RELY ON THE PYTHON FALLBACK.
warnings.warn('The C bitmap decompression binary is not available on this installation. Expect image decompression to be SLOW.')
warnings.warn('The C decompression binaries are not available on this installation. Sounds and bitmaps might not export.')
setup(name = 'MediaStation')

# BUILD THE IMA ADPCM DECOMPRESSOR.
Expand Down
18 changes: 4 additions & 14 deletions src/MediaStation/Assets/Bitmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,6 @@ def __init__(self, chunk, header_class = BitmapHeader):
self._height = self.header.dimensions.y
self.should_export = True

# Only nonempty for images that have keyframes that need to
# intersect
self.transparency_region = []

# READ THE RAW IMAGE DATA.
self._data_start_pointer = chunk.stream.tell()
if self.header._is_compressed:
Expand Down Expand Up @@ -91,10 +87,6 @@ def __init__(self, chunk, header_class = BitmapHeader):
else:
print(f'WARNING: Found mismatched width in uncompressed bitmap. Header: {self.header.unk2}. Width: {self._width}. This image might not be exported correctly.')

@property
def has_transparency(self):
return (len(self.transparency_region) > 0)

## Calculates the total number of bytes the uncompressed image
## (pixels) should occupy, rounded up to the closest whole byte.
@property
Expand All @@ -103,14 +95,12 @@ def _expected_bitmap_length_in_bytes(self) -> int:

def export(self, root_directory_path: str, command_line_arguments):
if self.pixels is not None:
# VERIFY THE SIZE.
has_expected_length = (len(self.pixels) == self._expected_bitmap_length_in_bytes)
if not has_expected_length:
print(f'WARNING: [{self.name} - Compression Type: {self.header.compression_type}] Expected pixels length in bytes 0x{len(self.pixels):02x}, got 0x{self._expected_bitmap_length_in_bytes:02x}')

# DO THE EXPORT.
super().export(root_directory_path, command_line_arguments)

def decompress_bitmap(self):
self._pixels = MediaStationBitmapRle.decompress(self._raw, self.width, self.height)

## \return The decompressed pixels that represent this image.
## The number of bytes is the same as the product of the width and the height.
@property
Expand All @@ -120,7 +110,7 @@ def pixels(self) -> bytes:
if self.header.compression_type == Bitmap.CompressionType.RLE_COMPRESSED:
# DECOMPRESS THE BITMAP.
if rle_c_loaded:
self._pixels, self.transparency_mask = MediaStationBitmapRle.decompress(self._raw, self.width, self.height)
self.decompress_bitmap()

else:
# ISSUE A WARNING.
Expand Down
89 changes: 45 additions & 44 deletions src/MediaStation/Assets/BitmapRle.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ static PyObject *method_decompress_media_station_rle(PyObject *self, PyObject *a
// height (if applicable).
unsigned int frame_left_x_coordinate = 0;
unsigned int frame_top_y_coordinate = 0;
if(!PyArg_ParseTuple(args, "y#II|IIII", &compressed_image, &compressed_image_data_size_in_bytes, &frame_width, &frame_height, &full_width, &full_height, &frame_left_x_coordinate, &frame_top_y_coordinate)) {
// The keyframe that we want to apply to this image.
// It is expected to be the same size as the uncompressed image.
char *keyframe_image = NULL;
Py_ssize_t keyframe_image_size_in_bytes = 0;
if(!PyArg_ParseTuple(args, "y#II|IIIIy#", &compressed_image, &compressed_image_data_size_in_bytes, &frame_width, &frame_height, &full_width, &full_height, &frame_left_x_coordinate, &frame_top_y_coordinate, &keyframe_image, &keyframe_image_size_in_bytes)) {
PyErr_Format(PyExc_RuntimeError, "BitmapRle.c::PyArg_ParseTuple(): Failed to parse arguments.");
return NULL;
}
if (keyframe_image_size_in_bytes == 0) {
keyframe_image = NULL;
}

// MAKE SURE THE PARAMETERS ARE SANE.
// The full width and full height are optional, so if they are not provided
Expand Down Expand Up @@ -68,32 +75,22 @@ static PyObject *method_decompress_media_station_rle(PyObject *self, PyObject *a
// places we don't actually write pixels to.
memset(decompressed_image, 0x00, uncompressed_image_data_size_in_bytes);

// ALLOCATE THE TRANSPARENCY MASK BUFFER.
PyObject *transparency_mask_object = PyBytes_FromStringAndSize(NULL, uncompressed_image_data_size_in_bytes);
if (transparency_mask_object == NULL) {
// TODO: We really should use Py_DECREF here I think, but since the
// program will currently just quit it isn't a big deal.
PyErr_Format(PyExc_RuntimeError, "BitmapRle.c::PyList_New(): Failed to allocate transparency mask buffer.");
return NULL;
// MAKE SURE THE KEYFRAME IMAGE IS THE RIGHT SIZE.
if (keyframe_image != NULL) {
if (keyframe_image_size_in_bytes != uncompressed_image_data_size_in_bytes) {
PyErr_Format(PyExc_RuntimeError, "BitmapRle.c: keyframe_image_size_in_bytes (%u) != uncompressed_image_data_size_in_bytes (%u)", keyframe_image_size_in_bytes, uncompressed_image_data_size_in_bytes);
return NULL;
}
}
char *transparency_mask = PyBytes_AS_STRING(transparency_mask_object);
memset(transparency_mask, 0x00, uncompressed_image_data_size_in_bytes);

// CHECK FOR AN EMPTY COMPRESSED IMAGE.
if (compressed_image_data_size_in_bytes <= 2) {
// RETURN A BLANK IMAGE TO PYTHON.
PyObject *return_value = Py_BuildValue("(OO)", decompressed_image_object, transparency_mask_object);
// Decrease the reference counts, as Py_BuildValue increments them.
Py_DECREF(decompressed_image_object);
Py_DECREF(transparency_mask_object);
if (return_value == NULL) {
PyErr_Format(PyExc_RuntimeError, "BitmapRle.c::Py_BuildValue(): Failed to build return value.");
return NULL;
}
return return_value;
// We just return an empty decompressed image to Python.
return decompressed_image_object;
}

// DECOMPRESS THE RLE-COMPRESSED BITMAP STREAM.
int transparency_run_ever_read = 0;
size_t transparency_run_top_y_coordinate = 0;
size_t transparency_run_left_x_coordinate = 0;
int image_fully_read = 0;
Expand Down Expand Up @@ -122,10 +119,16 @@ static PyObject *method_decompress_media_station_rle(PyObject *self, PyObject *a
// as transparent. Otherwise, only the 0x00 color indices within transparency regions
// are considered transparent. Only intraframes (frames that are not keyframes) have been
// observed to have transparency regions, and these intraframes have them so the keyframe
// can extend outside the boundary of the intraframe and still be removed.
reading_transparency_run = 1;
transparency_run_top_y_coordinate = current_y_coordinate;
transparency_run_left_x_coordinate = current_x_coordinate;
// can extend outside the boundary of the intraframe and
// still be removed.
if (keyframe_image != NULL) {
reading_transparency_run = 1;
transparency_run_top_y_coordinate = current_y_coordinate;
transparency_run_left_x_coordinate = current_x_coordinate;
transparency_run_ever_read = 1;
} else {
// printf("WARNING: BitmapRle.c: Found transparency region, but no keyframe is provided. Transparency region will be ignored.\n");
}
} else if (operation == 0x03) {
// ADJUST THE PIXEL POSITION.
// This permits jumping to a different part of the same row without
Expand All @@ -144,7 +147,8 @@ static PyObject *method_decompress_media_station_rle(PyObject *self, PyObject *a
size_t y_offset = current_y_coordinate * full_width;
size_t run_starting_offset = y_offset + current_x_coordinate;
char* run_starting_pointer = decompressed_image + run_starting_offset;
memcpy(run_starting_pointer, compressed_image, operation);
uint8_t run_length = operation;
memcpy(run_starting_pointer, compressed_image, run_length);
compressed_image += operation;
current_x_coordinate += operation;

Expand All @@ -163,22 +167,18 @@ static PyObject *method_decompress_media_station_rle(PyObject *self, PyObject *a
current_x_coordinate += repetition_count;

if (reading_transparency_run) {
// MARK THIS PART OF THE TRANSPARENCY REGION.
// The "interior" of transparency regions is always encoded by a single run of
// pixels, usually 0x00 (white).

// GET THE TRANSPARENCY RUN STARTING OFFSET.
size_t transparency_run_y_offset = transparency_run_top_y_coordinate * full_width;
size_t transparency_run_start_offset = transparency_run_y_offset + transparency_run_left_x_coordinate;

// GET THE NUMBER OF PIXELS (BYTES) IN THE TRANSPARENCY RUN.
// This could be optimized using a bitfield, since the transparency mask is monochrome.
size_t transparency_run_ending_offset = y_offset + current_x_coordinate;
size_t transparency_run_length = transparency_run_ending_offset - transparency_run_start_offset;
char *transparency_run_starting_pointer = transparency_mask + run_starting_offset;
char *transparency_run_src_pointer = keyframe_image + run_starting_offset;
char *transparency_run_dest_pointer = decompressed_image + run_starting_offset;

// STORE THE TRANSPARENCY RUN.
memset(transparency_run_starting_pointer, 0xff, transparency_run_length);
// COPY THE TRANSPARENT AREA FROM THE KEYFRAME.
// The "interior" of transparency regions is always encoded by a single run of
// pixels, usually 0x00 (white).
memcpy(transparency_run_dest_pointer, transparency_run_src_pointer, transparency_run_length);
reading_transparency_run = 0;
}
}
Expand All @@ -190,16 +190,17 @@ static PyObject *method_decompress_media_station_rle(PyObject *self, PyObject *a
}
}

// RETURN THE FRAMED BITMAP TO PYTHON.
PyObject *return_value = Py_BuildValue("(OO)", decompressed_image_object, transparency_mask_object);
// Decrease the reference counts, as Py_BuildValue increments them.
Py_DECREF(decompressed_image_object);
Py_DECREF(transparency_mask_object);
if (return_value == NULL) {
PyErr_Format(PyExc_RuntimeError, "BitmapRle.c::Py_BuildValue(): Failed to build return value.");
return NULL;
// APPLY THE KEYFRAME TO THE DECOMPRESSED IMAGE.
if (keyframe_image != NULL && transparency_run_ever_read == 0) {
for (size_t i = 0; i < uncompressed_image_data_size_in_bytes; i++) {
if (decompressed_image[i] == 0x00) {
decompressed_image[i] = keyframe_image[i];
}
}
}
return return_value;

// RETURN THE FRAMED BITMAP TO PYTHON.
return decompressed_image_object;
}

/// Defines the Python methods callable in this module.
Expand Down
93 changes: 39 additions & 54 deletions src/MediaStation/Assets/Movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
from .Bitmap import Bitmap, BitmapHeader
from .Sound import Sound

# ATTEMPT TO IMPORT THE C-BASED DECOMPRESSION LIBRARY.
# We will fall back to the pure Python implementation if it doesn't work, but there is easily a
# 10x slowdown with pure Python.
try:
import MediaStationBitmapRle
rle_c_loaded = True
except ImportError:
# We don't need a warning here since it is already issued in the bitmap
# decompression library.
rle_c_loaded = False

## Metadata that occurs after each movie frame and most keyframes.
## The only instance where it does not have a keyframe is...
## For example:
Expand Down Expand Up @@ -88,8 +99,11 @@ def __init__(self, chunk):
# and the footer right after this frame might not actually correspond to
# this frame.
self.footer = None
# These coordinates are relative to the whole screen, not just to this movie.
self._left = 0
self._top = 0
self.full_width = None
self.full_height = None

@property
def _duration(self):
Expand All @@ -100,6 +114,25 @@ def set_footer(self, footer: MovieFrameFooter):
self._left = self.footer._left
self._top = self.footer._top

def decompress_bitmap(self, full_width, full_height, keyframe = b''):
self.full_width = full_width
self.full_height = full_height
if keyframe == None:
keyframe = b''
self._pixels = MediaStationBitmapRle.decompress(
self._raw, self.width, self.height, full_width, full_height, self._left, self._top, keyframe)

def export(self, root_directory_path: str, command_line_arguments):
# TODO: This is a nasty hack to get the animation-framed dimensions right!
if self.pixels is not None:
old_width = self._width
old_height = self._height
self._width = self.full_width
self._height = self.full_height
super().export(root_directory_path, command_line_arguments)
self._width = old_width
self._height = old_height

## A single animation.
## - A series of bitmaps.
## - Optional sound.
Expand Down Expand Up @@ -232,10 +265,11 @@ def _fix_keyframe_coordinates(self):
# The animation framing MUST be applied or there will be an error when applying the keyframing.
def _apply_keyframes(self):
timestamp = -1
current_keyframe = None
bounding_box = self._minimal_bounding_box
current_keyframe: MovieFrame = None
# TODO: Need to determine why some movies aren't exported.
for index, frame in enumerate(self.frames):
global_variables.application.logger.debug(f'[{self.name}] (frame {frame.header.index}) (timestamp: {timestamp}) (start: {frame.footer.start_in_milliseconds if frame.footer else None}) (end: {frame.footer.end_in_milliseconds if frame.footer else None})')

# CHECK IF WE SHOULD REGISTER THE NEXT KEYFRAME.
if frame.header.keyframe_end_in_milliseconds > timestamp:
timestamp = frame.header.keyframe_end_in_milliseconds
Expand All @@ -244,68 +278,19 @@ def _apply_keyframes(self):
# The keyframe is not intended to be included in the export.
# Though maybe we could include them as some sort of "K1.bmp" filename.
current_keyframe = frame
current_keyframe.decompress_bitmap(self._width, self._height)
current_keyframe._include_in_export = False
continue

# MAKE SURE THIS FRAME CAN BE EXPORTED.
if frame._exportable_image is None or current_keyframe._exportable_image is None:
continue

# CREATE THE TRANSPARENCY MASK FOR THIS FRAME.
# We will replace the areas of this frame marked "transparent" with
# the corresponding areas of the current keyframe.
# TODO: This whole business is rather inefficient since we have to
# go to a numpy array and back. Might be better to have a C
# extension that handles this.
keyframe = np.array(current_keyframe._exportable_image)
original_frame = np.array(frame._exportable_image)
if len(frame.transparency_region) == 0:
# CREATE A MASK FOR THE 0x00 REGIONS OF THIS FRAME.
# The "transparent" regions are the places where this frame has
# a color index of 0x00 (typically appears as white in most palettes).
mask = (original_frame == 0)
else:
# CREATE A MASK FOR THE TRANSPARENT REGIONS OF THIS FRAME.
mask = np.zeros(original_frame.shape, dtype=bool)
for transparency_region in frame.transparency_region:
# GET THE STARTING X COORDINATE.
x_relative_to_this_frame = transparency_region[0]
x = x_relative_to_this_frame + (frame.left - bounding_box.left)

# GET THE STARTING Y COORDINATE.
y_relative_to_this_frame = transparency_region[1]
y = y_relative_to_this_frame + (frame.top - bounding_box.top)

# GET THE ENDING X COORDINATE.
# The transparency regions aren't supposed to span more than
# a single line, so there isn't a corresponding y coordinate.
if len(transparency_region) == 3:
x_offset = transparency_region[2]
else:
# This mean the transparency region was never
# closed, since an ending point was never specified.
# So we will just assume a sane cutoff for now.
# TODO: Verify how often this actually happens.
x_offset = 10

# MARK THIS TRANSPARENCY REGION IN THE MASK.
mask[y, x : x + x_offset] = True

# APPLY THE MASK TO CREATE THE COMPLETE FRAME.
original_frame[mask] = keyframe[mask]
composite_frame = Image.fromarray(original_frame)
composite_frame.putpalette(current_keyframe._exportable_image.palette)
frame._exportable_image = composite_frame
frame.decompress_bitmap(self._width, self._height, current_keyframe.pixels)

def export(self, root_directory_path, command_line_arguments):
# TODO: Should the stills be exported like everything else? They look like they might be regular frames.
self._fix_keyframe_coordinates()
#self._reframe_to_animation_size(command_line_arguments)
#
# TODO: Provide an option to check for a request to not apply keyframes.
# TODO: Keyframe application is currently disabled becuase it is
# horribly inefficient and doesn't even work very well. It needs to be reworked.
#self._apply_keyframes()
self._apply_keyframes()
#
self.frames.sort(key = lambda x: x.footer.end_in_milliseconds if x.footer else x.header.keyframe_end_in_milliseconds)
super().export(root_directory_path, command_line_arguments)

0 comments on commit 0a509da

Please sign in to comment.