Initial stab at an AFP animation renderer. It can render some basic animations from Pop'n Music!
This commit is contained in:
parent
3941b7e602
commit
1683c8ecdd
@ -1,6 +1,7 @@
|
|||||||
from .geo import Shape, DrawParams
|
from .geo import Shape, DrawParams
|
||||||
from .swf import SWF, NamedTagReference
|
from .swf import SWF, NamedTagReference
|
||||||
from .container import TXP2File, PMAN, Texture, TextureRegion, Unknown1, Unknown2
|
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
|
from .types import Matrix, Color, Point, Rectangle, AP2Tag, AP2Action, AP2Object, AP2Pointer, AP2Property
|
||||||
|
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ __all__ = [
|
|||||||
'TextureRegion',
|
'TextureRegion',
|
||||||
'Unknown1',
|
'Unknown1',
|
||||||
'Unknown2',
|
'Unknown2',
|
||||||
|
'AFPRenderer',
|
||||||
'Matrix',
|
'Matrix',
|
||||||
'Color',
|
'Color',
|
||||||
'Point',
|
'Point',
|
||||||
|
@ -31,6 +31,9 @@ class Shape:
|
|||||||
# Actual shape drawing parameters.
|
# Actual shape drawing parameters.
|
||||||
self.draw_params: List[DrawParams] = []
|
self.draw_params: List[DrawParams] = []
|
||||||
|
|
||||||
|
# Whether this is parsed.
|
||||||
|
self.parsed = False
|
||||||
|
|
||||||
def as_dict(self) -> Dict[str, Any]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
@ -186,6 +189,7 @@ class Shape:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.draw_params = draw_params
|
self.draw_params = draw_params
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
|
||||||
class DrawParams:
|
class DrawParams:
|
||||||
|
266
bemani/format/afp/render.py
Normal file
266
bemani/format/afp/render.py
Normal 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
|
@ -57,6 +57,9 @@ class Tag:
|
|||||||
def __init__(self, id: Optional[int]) -> None:
|
def __init__(self, id: Optional[int]) -> None:
|
||||||
self.id = id
|
self.id = id
|
||||||
|
|
||||||
|
def children(self) -> List["Tag"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class AP2ShapeTag(Tag):
|
class AP2ShapeTag(Tag):
|
||||||
def __init__(self, id: int, reference: str) -> None:
|
def __init__(self, id: int, reference: str) -> None:
|
||||||
@ -168,6 +171,9 @@ class AP2DefineSpriteTag(Tag):
|
|||||||
# The list of frames this SWF occupies.
|
# The list of frames this SWF occupies.
|
||||||
self.frames = frames
|
self.frames = frames
|
||||||
|
|
||||||
|
def children(self) -> List["Tag"]:
|
||||||
|
return self.tags
|
||||||
|
|
||||||
|
|
||||||
class AP2DefineEditTextTag(Tag):
|
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:
|
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.
|
# tracking which strings in the table have been parsed correctly.
|
||||||
self.__strings: Dict[int, Tuple[str, bool]] = {}
|
self.__strings: Dict[int, Tuple[str, bool]] = {}
|
||||||
|
|
||||||
|
# Whether this is parsed or not.
|
||||||
|
self.parsed = False
|
||||||
|
|
||||||
def print_coverage(self) -> None:
|
def print_coverage(self) -> None:
|
||||||
# First print uncovered bytes
|
# First print uncovered bytes
|
||||||
super().print_coverage()
|
super().print_coverage()
|
||||||
@ -1149,7 +1158,7 @@ class SWF(TrackedCoverage, VerboseOutput):
|
|||||||
raise Exception(f"Invalid tag size {size} ({hex(size)})")
|
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.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.
|
tags_offset += ((size + 3) & 0xFFFFFFFC) + 4 # Skip past tag header and data, rounding to the nearest 4 bytes.
|
||||||
|
|
||||||
# Now, parse frames.
|
# Now, parse frames.
|
||||||
@ -1431,3 +1440,5 @@ class SWF(TrackedCoverage, VerboseOutput):
|
|||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
self.print_coverage()
|
self.print_coverage()
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
@ -1,21 +1,4 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
|
||||||
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)}"
|
|
||||||
|
|
||||||
|
|
||||||
class Color:
|
class Color:
|
||||||
@ -33,6 +16,14 @@ class Color:
|
|||||||
'a': self.a,
|
'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:
|
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)}"
|
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.x = x
|
||||||
self.y = y
|
self.y = y
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def identity() -> "Point":
|
||||||
|
return Point(0.0, 0.0)
|
||||||
|
|
||||||
def as_dict(self) -> Dict[str, Any]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
'x': self.x,
|
'x': self.x,
|
||||||
'y': self.y,
|
'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:
|
def __repr__(self) -> str:
|
||||||
return f"x: {round(self.x, 5)}, y: {round(self.y, 5)}"
|
return f"x: {round(self.x, 5)}, y: {round(self.y, 5)}"
|
||||||
|
|
||||||
@ -81,3 +89,26 @@ class Rectangle:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def Empty() -> "Rectangle":
|
def Empty() -> "Rectangle":
|
||||||
return Rectangle(left=0.0, right=0.0, top=0.0, bottom=0.0)
|
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)}"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
#! /usr/bin/env python3
|
#! /usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
@ -8,14 +9,15 @@ import textwrap
|
|||||||
from PIL import Image, ImageDraw # type: ignore
|
from PIL import Image, ImageDraw # type: ignore
|
||||||
from typing import Any, Dict
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Konami AFP graphic file unpacker/repacker")
|
parser = argparse.ArgumentParser(description="Konami AFP graphic file unpacker/repacker")
|
||||||
subparsers = parser.add_subparsers(help='Action to take', dest='action')
|
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(
|
extract_parser.add_argument(
|
||||||
"file",
|
"file",
|
||||||
metavar="FILE",
|
metavar="FILE",
|
||||||
@ -69,7 +71,7 @@ def main() -> int:
|
|||||||
help="Write binary SWF files to disk",
|
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(
|
update_parser.add_argument(
|
||||||
"file",
|
"file",
|
||||||
metavar="FILE",
|
metavar="FILE",
|
||||||
@ -93,7 +95,7 @@ def main() -> int:
|
|||||||
help="Display verbuse debugging output",
|
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(
|
print_parser.add_argument(
|
||||||
"file",
|
"file",
|
||||||
metavar="FILE",
|
metavar="FILE",
|
||||||
@ -106,7 +108,7 @@ def main() -> int:
|
|||||||
help="Display verbuse debugging output",
|
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(
|
parseafp_parser.add_argument(
|
||||||
"afp",
|
"afp",
|
||||||
metavar="AFPFILE",
|
metavar="AFPFILE",
|
||||||
@ -124,7 +126,7 @@ def main() -> int:
|
|||||||
help="Display verbuse debugging output",
|
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(
|
parsegeo_parser.add_argument(
|
||||||
"geo",
|
"geo",
|
||||||
metavar="GEOFILE",
|
metavar="GEOFILE",
|
||||||
@ -137,6 +139,35 @@ def main() -> int:
|
|||||||
help="Display verbuse debugging output",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.action == "extract":
|
if args.action == "extract":
|
||||||
@ -398,6 +429,76 @@ def main() -> int:
|
|||||||
print(geo, file=sys.stderr)
|
print(geo, file=sys.stderr)
|
||||||
print(json.dumps(geo.as_dict(), sort_keys=True, indent=4))
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user