1
0
mirror of synced 2025-01-31 04:03:45 +01:00

Initial stab at an AFP animation renderer. It can render some basic animations from Pop'n Music!

This commit is contained in:
Jennifer Taylor 2021-04-15 23:18:33 +00:00
parent 3941b7e602
commit 1683c8ecdd
6 changed files with 440 additions and 25 deletions

View File

@ -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',

View File

@ -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:

266
bemani/format/afp/render.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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)}"

View File

@ -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