diff --git a/bemani/format/afp/__init__.py b/bemani/format/afp/__init__.py index 9ea22f5..db7e2fa 100644 --- a/bemani/format/afp/__init__.py +++ b/bemani/format/afp/__init__.py @@ -1,6 +1,7 @@ from .geo import Shape, DrawParams from .swf import SWF, NamedTagReference from .container import TXP2File, PMAN, Texture, TextureRegion, Unknown1, Unknown2 +from .render import AFPRenderer from .types import Matrix, Color, Point, Rectangle, AP2Tag, AP2Action, AP2Object, AP2Pointer, AP2Property @@ -15,6 +16,7 @@ __all__ = [ 'TextureRegion', 'Unknown1', 'Unknown2', + 'AFPRenderer', 'Matrix', 'Color', 'Point', diff --git a/bemani/format/afp/geo.py b/bemani/format/afp/geo.py index 81eb940..dafc5e5 100644 --- a/bemani/format/afp/geo.py +++ b/bemani/format/afp/geo.py @@ -31,6 +31,9 @@ class Shape: # Actual shape drawing parameters. self.draw_params: List[DrawParams] = [] + # Whether this is parsed. + self.parsed = False + def as_dict(self) -> Dict[str, Any]: return { 'name': self.name, @@ -186,6 +189,7 @@ class Shape: ) ) self.draw_params = draw_params + self.parsed = True class DrawParams: diff --git a/bemani/format/afp/render.py b/bemani/format/afp/render.py new file mode 100644 index 0000000..ea43b99 --- /dev/null +++ b/bemani/format/afp/render.py @@ -0,0 +1,266 @@ +from typing import Any, Dict, List, Tuple, Optional +from PIL import Image # type: ignore + +from .swf import SWF, Frame, Tag, AP2ShapeTag, AP2DefineSpriteTag, AP2PlaceObjectTag, AP2RemoveObjectTag +from .types import Color, Matrix, Point +from .geo import Shape +from .util import VerboseOutput + + +class Clip: + def __init__(self, tag_id: Optional[int], frames: List[Frame], tags: List[Tag]) -> None: + self.tag_id = tag_id + self.frames = frames + self.tags = tags + self.frameno = 0 + + def frame(self) -> Frame: + return self.frames[self.frameno] + + def advance(self) -> None: + if not self.finished(): + self.frameno += 1 + + def finished(self) -> bool: + return self.frameno == len(self.frames) + + def running(self) -> bool: + return not self.finished() + + +class PlacedObject: + def __init__(self, parent_sprite: Optional[int], tag: AP2PlaceObjectTag) -> None: + self.parent_sprite = parent_sprite + self.tag = tag + + +class AFPRenderer(VerboseOutput): + def __init__(self, shapes: Dict[str, Shape] = {}, textures: Dict[str, Any] = {}, swfs: Dict[str, SWF] = {}) -> None: + super().__init__() + + self.shapes: Dict[str, Shape] = shapes + self.textures: Dict[str, Any] = textures + self.swfs: Dict[str, SWF] = swfs + + # Internal render parameters + self.__visible_tag: Optional[int] = None + self.__ided_tags: Dict[int, Tag] = {} + self.__registered_shapes: Dict[int, Shape] = {} + self.__placed_objects: List[PlacedObject] = [] + + def add_shape(self, name: str, data: Shape) -> None: + if not data.parsed: + data.parse() + self.shapes[name] = data + + def add_texture(self, name: str, data: Any) -> None: + self.textures[name] = data + + def add_swf(self, name: str, data: SWF) -> None: + if not data.parsed: + data.parse() + self.swfs[name] = data + + def render_path(self, path: str, verbose: bool = False) -> Tuple[int, List[Any]]: + components = path.split(".") + + if len(components) > 2: + raise Exception('Expected a path in the form of "moviename" or "moviename.exportedtag"!') + + for name, swf in self.swfs.items(): + if swf.exported_name == components[0]: + # This is the SWF we care about. + with self.debugging(verbose): + return self.__render(swf, components[1] if len(components) > 1 else None) + + raise Exception(f'{path} not found in registered SWFs!') + + def __place(self, tag: Tag, parent_sprite: Optional[int], prefix: str = "") -> List[Clip]: + if isinstance(tag, AP2ShapeTag): + self.vprint(f"{prefix} Loading {tag.reference} into shape slot {tag.id}") + + if tag.reference not in self.shapes: + raise Exception(f"Cannot find shape reference {tag.reference}!") + + self.__registered_shapes[tag.id] = self.shapes[tag.reference] + return [] + elif isinstance(tag, AP2DefineSpriteTag): + self.vprint(f"{prefix} Registering Sprite Tag {tag.id}") + + # Register a new clip that we have to execute. + clip = Clip(tag.id, tag.frames, tag.tags) + clips: List[Clip] = [clip] + + # Now, we need to run the first frame of this clip, since that's this frame. + if clip.running(): + frame = clip.frame() + if frame.num_tags > 0: + self.vprint(f"{prefix} First Frame Initialization, Start Frame: {frame.start_tag_offset}, Num Frames: {frame.num_tags}") + for child in clip.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)]: + clips.extend(self.__place(child, parent_sprite=tag.id, prefix=" ")) + + # Finally, return the new clips we registered. + return clips + elif isinstance(tag, AP2PlaceObjectTag): + if tag.update: + raise Exception("Don't support update tags yet!") + else: + self.vprint(f"{prefix} Placing Object ID {tag.object_id} onto Depth {tag.depth}") + + self.__placed_objects.append(PlacedObject(parent_sprite, tag)) + + # TODO: Handle triggers for this object. + return [] + elif isinstance(tag, AP2RemoveObjectTag): + self.vprint(f"{prefix} Removing Object ID {tag.object_id} from Depth {tag.depth}") + + if tag.object_id != 0: + # Remove the identified object by object ID and depth. + self.__placed_objects = [ + o for o in self.__placed_objects + if o.tag.object_id == tag.object_id and o.tag.depth == tag.depth + ] + else: + # Remove the last placed object at this depth. + for i in range(len(self.__placed_objects)): + real_index = len(self.__placed_objects) - (i + 1) + + if self.__placed_objects[real_index].tag.depth == tag.depth: + self.__placed_objects = self.__placed_objects[:real_index] + self.__placed_objects[(real_index + 1):] + break + + return [] + else: + raise Exception(f"Failed to process tag: {tag}") + + def __render_object(self, img: Any, tag: AP2PlaceObjectTag) -> Any: + if tag.source_tag_id is None: + self.vprint(" Nothing to render!") + return img + + # Double check supported options. + if tag.mult_color or tag.add_color: + raise Exception("Don't support color blending yet!") + + # Look up the affine transformation matrix and rotation/origin. + transform = tag.transform or Matrix.identity() + origin = tag.rotation_offset or Point.identity() + + # Look up source shape. + if tag.source_tag_id not in self.__registered_shapes: + # TODO: Lots of animations are referencing other sprite tags with transform + # offsets and such. We need to support this. However, I'm not sure how the + # original gets hidden... + raise Exception(f"Failed to find shape tag {tag.source_tag_id} for object render!") + shape = self.__registered_shapes[tag.source_tag_id] + + for params in shape.draw_params: + if not (params.flags & 0x1): + # Not instantiable, don't render. + return img + + if params.flags & 0x4 or params.flags & 0x8: + raise Exception("Don't support shape blend or uv coordinate color yet!") + + texture = None + if params.flags & 0x2: + # We need to look up the texture for this. + if params.region not in self.textures: + raise Exception(f"Cannot find texture reference {params.region}!") + texture = self.textures[params.region] + + # TODO: Need to do actual affine transformations here. + offset = transform.multiply_point(Point.identity().subtract(origin)) + + # Now, render out the texture. + cutoff = Point.identity() + if offset.x < 0: + cutoff.x = -offset.x + offset.x = 0 + if offset.y < 0: + cutoff.y = -offset.y + offset.y = 0 + + img.alpha_composite(texture, offset.as_tuple(), cutoff.as_tuple()) + return img + + def __render(self, swf: SWF, export_tag: Optional[str]) -> Tuple[int, List[Any]]: + # If we are rendering only an exported tag, we want to perform the actions of the + # rest of the SWF but not update any layers as a result. + self.__visible_tag = None + if export_tag is not None: + # Make sure this tag is actually present in the SWF. + if export_tag not in swf.exported_tags: + raise Exception(f'{export_tag} is not exported by {swf.exported_name}!') + self.__visible_tag = swf.exported_tags[export_tag] + + # Now, we need to make an index of each ID'd tag. + self.__ided_tags = {} + + def get_children(tag: Tag) -> List[Tag]: + children: List[Tag] = [] + + for child in tag.children(): + children.extend(get_children(child)) + children.append(tag) + return children + + all_children: List[Tag] = [] + for tag in swf.tags: + all_children.extend(get_children(tag)) + + for child in all_children: + if child.id is not None: + if child.id in self.__ided_tags: + raise Exception(f"Already have a Tag ID {child.id}!") + self.__ided_tags[child.id] = child + + # TODO: Now, we have to resolve imports. + pass + + # Now, let's go through each frame, performing actions as necessary. + spf = 1.0 / swf.fps + frames: List[Any] = [] + frameno: int = 0 + clips: List[Clip] = [Clip(None, swf.frames, swf.tags)] + + # Reset any registered shapes. + self.__registered_shapes = {} + + while any(c.running() for c in clips): + # Create a new image to render into. + time = spf * float(frameno) + color = swf.color or Color(0.0, 0.0, 0.0, 0.0) + curimage = Image.new("RGBA", (swf.location.width, swf.location.height), color=color.as_tuple()) + self.vprint(f"Rendering Frame {frameno} ({time}s)") + + # Go through all registered clips, place all needed tags. + newclips: List[Clip] = [] + for clip in clips: + if clip.finished(): + continue + + frame = clip.frame() + if frame.num_tags > 0: + self.vprint(f" Sprite Tag ID: {clip.tag_id}, Start Frame: {frame.start_tag_offset}, Num Frames: {frame.num_tags}") + for tag in clip.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)]: + newclips.extend(self.__place(tag, parent_sprite=clip.tag_id)) + + # Add any new clips that we should process next frame. + clips.extend(newclips) + + # Now, render out the placed objects. + for obj in sorted(self.__placed_objects, key=lambda o: o.tag.depth): + if self.__visible_tag is not None and self.__visible_tag != obj.parent_sprite: + continue + + self.vprint(f" Rendering placed object ID {obj.tag.object_id} from sprite {obj.parent_sprite} onto Depth {obj.tag.depth}") + curimage = self.__render_object(curimage, obj.tag) + + # Advance all the clips and frame now that we processed and rendered them. + for clip in clips: + clip.advance() + frames.append(curimage) + frameno += 1 + + return int(spf * 1000.0), frames diff --git a/bemani/format/afp/swf.py b/bemani/format/afp/swf.py index 1997915..71586bf 100644 --- a/bemani/format/afp/swf.py +++ b/bemani/format/afp/swf.py @@ -57,6 +57,9 @@ class Tag: def __init__(self, id: Optional[int]) -> None: self.id = id + def children(self) -> List["Tag"]: + return [] + class AP2ShapeTag(Tag): def __init__(self, id: int, reference: str) -> None: @@ -168,6 +171,9 @@ class AP2DefineSpriteTag(Tag): # The list of frames this SWF occupies. self.frames = frames + def children(self) -> List["Tag"]: + return self.tags + class AP2DefineEditTextTag(Tag): def __init__(self, id: int, font_tag_id: int, font_height: int, rect: Rectangle, color: Color, default_text: Optional[str] = None) -> None: @@ -242,6 +248,9 @@ class SWF(TrackedCoverage, VerboseOutput): # tracking which strings in the table have been parsed correctly. self.__strings: Dict[int, Tuple[str, bool]] = {} + # Whether this is parsed or not. + self.parsed = False + def print_coverage(self) -> None: # First print uncovered bytes super().print_coverage() @@ -1149,7 +1158,7 @@ class SWF(TrackedCoverage, VerboseOutput): raise Exception(f"Invalid tag size {size} ({hex(size)})") self.vprint(f"{prefix} Tag: {hex(tagid)} ({AP2Tag.tag_to_name(tagid)}), Size: {hex(size)}, Offset: {hex(tags_offset + 4)}") - self.tags.append(self.__parse_tag(ap2_version, afp_version, ap2data, tagid, size, tags_offset + 4, prefix=prefix)) + tags.append(self.__parse_tag(ap2_version, afp_version, ap2data, tagid, size, tags_offset + 4, prefix=prefix)) tags_offset += ((size + 3) & 0xFFFFFFFC) + 4 # Skip past tag header and data, rounding to the nearest 4 bytes. # Now, parse frames. @@ -1431,3 +1440,5 @@ class SWF(TrackedCoverage, VerboseOutput): if verbose: self.print_coverage() + + self.parsed = True diff --git a/bemani/format/afp/types/generic.py b/bemani/format/afp/types/generic.py index 1748eeb..c56a6bb 100644 --- a/bemani/format/afp/types/generic.py +++ b/bemani/format/afp/types/generic.py @@ -1,21 +1,4 @@ -from typing import Any, Dict - - -class Matrix: - def __init__(self, a: float, b: float, c: float, d: float, tx: float, ty: float) -> None: - self.a = a - self.b = b - self.c = c - self.d = d - self.tx = tx - self.ty = ty - - @staticmethod - def identity() -> "Matrix": - return Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0) - - def __repr__(self) -> str: - return f"a: {round(self.a, 5)}, b: {round(self.b, 5)}, c: {round(self.c, 5)}, d: {round(self.d, 5)}, tx: {round(self.tx, 5)}, ty: {round(self.ty, 5)}" +from typing import Any, Dict, Tuple class Color: @@ -33,6 +16,14 @@ class Color: 'a': self.a, } + def as_tuple(self) -> Tuple[int, int, int, int]: + return ( + int(self.r * 255), + int(self.g * 255), + int(self.b * 255), + int(self.a * 255), + ) + def __repr__(self) -> str: return f"r: {round(self.r, 5)}, g: {round(self.g, 5)}, b: {round(self.b, 5)}, a: {round(self.a, 5)}" @@ -42,12 +33,29 @@ class Point: self.x = x self.y = y + @staticmethod + def identity() -> "Point": + return Point(0.0, 0.0) + def as_dict(self) -> Dict[str, Any]: return { 'x': self.x, 'y': self.y, } + def as_tuple(self) -> Tuple[int, int]: + return (int(self.x), int(self.y)) + + def add(self, other: "Point") -> "Point": + self.x += other.x + self.y += other.y + return self + + def subtract(self, other: "Point") -> "Point": + self.x -= other.x + self.y -= other.y + return self + def __repr__(self) -> str: return f"x: {round(self.x, 5)}, y: {round(self.y, 5)}" @@ -81,3 +89,26 @@ class Rectangle: @staticmethod def Empty() -> "Rectangle": return Rectangle(left=0.0, right=0.0, top=0.0, bottom=0.0) + + +class Matrix: + def __init__(self, a: float, b: float, c: float, d: float, tx: float, ty: float) -> None: + self.a = a + self.b = b + self.c = c + self.d = d + self.tx = tx + self.ty = ty + + @staticmethod + def identity() -> "Matrix": + return Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + + def multiply_point(self, point: Point) -> Point: + return Point( + x=(self.a * point.x) + (self.c * point.y) + self.tx, + y=(self.b * point.x) + (self.d * point.y) + self.ty, + ) + + def __repr__(self) -> str: + return f"a: {round(self.a, 5)}, b: {round(self.b, 5)}, c: {round(self.c, 5)}, d: {round(self.d, 5)}, tx: {round(self.tx, 5)}, ty: {round(self.ty, 5)}" diff --git a/bemani/utils/afputils.py b/bemani/utils/afputils.py index eb40fb8..4f56501 100644 --- a/bemani/utils/afputils.py +++ b/bemani/utils/afputils.py @@ -1,5 +1,6 @@ #! /usr/bin/env python3 import argparse +import io import json import os import os.path @@ -8,14 +9,15 @@ import textwrap from PIL import Image, ImageDraw # type: ignore from typing import Any, Dict -from bemani.format.afp import TXP2File, Shape, SWF +from bemani.format.afp import TXP2File, Shape, SWF, AFPRenderer +from bemani.format import IFS def main() -> int: parser = argparse.ArgumentParser(description="Konami AFP graphic file unpacker/repacker") subparsers = parser.add_subparsers(help='Action to take', dest='action') - extract_parser = subparsers.add_parser('extract', help='Extract relevant textures from file') + extract_parser = subparsers.add_parser('extract', help='Extract relevant textures from TXP2 container') extract_parser.add_argument( "file", metavar="FILE", @@ -69,7 +71,7 @@ def main() -> int: help="Write binary SWF files to disk", ) - update_parser = subparsers.add_parser('update', help='Update relevant textures in a file from a directory') + update_parser = subparsers.add_parser('update', help='Update relevant textures in a TXP2 container from a directory') update_parser.add_argument( "file", metavar="FILE", @@ -93,7 +95,7 @@ def main() -> int: help="Display verbuse debugging output", ) - print_parser = subparsers.add_parser('print', help='Print the file contents as a JSON dictionary') + print_parser = subparsers.add_parser('print', help='Print the TXP2 container contents as a JSON dictionary') print_parser.add_argument( "file", metavar="FILE", @@ -106,7 +108,7 @@ def main() -> int: help="Display verbuse debugging output", ) - parseafp_parser = subparsers.add_parser('parseafp', help='Parse a raw AFP/BSI file pair extracted from an IFS container') + parseafp_parser = subparsers.add_parser('parseafp', help='Parse a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container') parseafp_parser.add_argument( "afp", metavar="AFPFILE", @@ -124,7 +126,7 @@ def main() -> int: help="Display verbuse debugging output", ) - parsegeo_parser = subparsers.add_parser('parsegeo', help='Parse a raw GEO file extracted from an IFS container') + parsegeo_parser = subparsers.add_parser('parsegeo', help='Parse a raw GEO file previously extracted from an IFS or TXP2 container') parsegeo_parser.add_argument( "geo", metavar="GEOFILE", @@ -137,6 +139,35 @@ def main() -> int: help="Display verbuse debugging output", ) + render_parser = subparsers.add_parser('render', help='Render a particular animation out of a series of SWFs') + render_parser.add_argument( + "container", + metavar="CONTAINER", + type=str, + nargs='+', + help="A container file to use for loading SWF data. Can be either a TXP2 or IFS container.", + ) + render_parser.add_argument( + "--path", + metavar="PATH", + type=str, + required=True, + help='A path to render, specified either as "moviename" or "moviename.exportedtag".', + ) + render_parser.add_argument( + "--output", + metavar="IMAGE", + type=str, + default="out.gif", + help='The output file (ending either in .gif or .webp) where the render should be saved.', + ) + render_parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Display verbuse debugging output", + ) + args = parser.parse_args() if args.action == "extract": @@ -398,6 +429,76 @@ def main() -> int: print(geo, file=sys.stderr) print(json.dumps(geo.as_dict(), sort_keys=True, indent=4)) + if args.action == "render": + # This is a complicated one, as we need to be able to specify multiple + # directories of files as well as support IFS files and TXP2 files. + renderer = AFPRenderer() + + # TODO: Allow specifying individual folders and such. + for container in args.container: + with open(container, "rb") as bfp: + data = bfp.read() + + afpfile = None + try: + afpfile = TXP2File(data, verbose=args.verbose) + except Exception: + pass + + if afpfile is not None: + # TODO: Load from afp container + pass + + ifsfile = None + try: + ifsfile = IFS(data, decode_textures=True) + except Exception: + pass + + if ifsfile is not None: + for fname in ifsfile.filenames: + if fname.startswith("geo/"): + # Trim off directory. + shapename = fname[4:] + + # Load file, register it. + fdata = ifsfile.read_file(fname) + shape = Shape(shapename, fdata) + renderer.add_shape(shapename, shape) + + if args.verbose: + print(f"Added {shapename} to SWF shape library.", file=sys.stderr) + elif fname.startswith("tex/") and fname.endswith(".png"): + # Trim off directory, png extension. + texname = fname[4:][:-4] + + # Load file, register it. + fdata = ifsfile.read_file(fname) + tex = Image.open(io.BytesIO(fdata)) + renderer.add_texture(texname, tex) + + if args.verbose: + print(f"Added {texname} to SWF texture library.", file=sys.stderr) + elif fname.startswith("afp/"): + # Trim off directory, see if it has a corresponding bsi. + afpname = fname[4:] + bsipath = f"afp/bsi/{afpname}" + + if bsipath in ifsfile.filenames: + afpdata = ifsfile.read_file(fname) + bsidata = ifsfile.read_file(bsipath) + flash = SWF(afpname, afpdata, bsidata) + renderer.add_swf(afpname, flash) + + if args.verbose: + print(f"Added {afpname} to SWF library.", file=sys.stderr) + + duration, images = renderer.render_path(args.path, verbose=args.verbose) + if len(images) == 0: + raise Exception("Did not render any frames!") + images[0].save(args.output, save_all=True, append_images=images[1:], loop=0, duration=duration) + print(f"Wrote animation to {args.output}") + return 0