from typing import Any, Dict, Generator, List, Set, Tuple, Optional, Union from PIL import Image # type: ignore from .blend import affine_composite, perspective_composite from .swf import ( SWF, Frame, Tag, AP2ShapeTag, AP2DefineSpriteTag, AP2PlaceObjectTag, AP2RemoveObjectTag, AP2DoActionTag, AP2DefineFontTag, AP2DefineEditTextTag, AP2PlaceCameraTag, AP2ImageTag, ) from .decompile import ByteCode from .types import ( Color, Matrix, Point, Rectangle, AAMode, AP2Trigger, AP2Action, PushAction, StoreRegisterAction, StringConstant, Register, NULL, UNDEFINED, GLOBAL, ROOT, PARENT, THIS, CLIP, ) from .geo import Shape, DrawParams from .util import VerboseOutput class RegisteredClip: # A movie clip that we are rendering, frame by frame. These are manifest by the root # SWF as well as AP2DefineSpriteTags which are essentially embedded movie clips. The # tag_id is the AP2DefineSpriteTag that created us, or None if this is the clip for # the root of the movie. def __init__(self, tag_id: Optional[int], frames: List[Frame], tags: List[Tag], labels: Dict[str, int]) -> None: self.tag_id = tag_id self.frames = frames self.tags = tags self.labels = labels def __repr__(self) -> str: return f"RegisteredClip(tag_id={self.tag_id})" class RegisteredShape: # A shape that we are rendering, as placed by some placed clip somewhere. def __init__(self, tag_id: int, vertex_points: List[Point], tex_points: List[Point], tex_colors: List[Color], draw_params: List[DrawParams]) -> None: self.tag_id = tag_id self.vertex_points: List[Point] = vertex_points self.tex_points: List[Point] = tex_points self.tex_colors: List[Color] = tex_colors self.draw_params: List[DrawParams] = draw_params self.rectangle: Optional[Image.image] = None def __repr__(self) -> str: return f"RegisteredShape(tag_id={self.tag_id}, vertex_points={self.vertex_points}, tex_points={self.tex_points}, tex_colors={self.tex_colors}, draw_params={self.draw_params})" class RegisteredImage: # An image that we should draw directly. def __init__(self, tag_id: int, reference: str) -> None: self.tag_id = tag_id self.reference = reference def __repr__(self) -> str: return f"RegisteredImage(tag_id={self.tag_id}, reference={self.reference})" class RegisteredDummy: # An imported tag that we could not find. def __init__(self, tag_id: int) -> None: self.tag_id = tag_id def __repr__(self) -> str: return f"RegisteredDummy(tag_id={self.tag_id})" class Mask: def __init__(self, bounds: Rectangle) -> None: self.bounds = bounds self.rectangle: Optional[Image.Image] = None class PlacedObject: # An object that occupies the screen at some depth. def __init__( self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, projection: int, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask], ) -> None: self.__object_id = object_id self.__depth = depth self.rotation_origin = rotation_origin self.transform = transform self.projection = projection self.mult_color = mult_color self.add_color = add_color self.blend = blend self.mask = mask self.visible: bool = True @property def source(self) -> Union[RegisteredClip, RegisteredShape, RegisteredImage, RegisteredDummy]: raise NotImplementedError("Only implemented in subclass!") @property def depth(self) -> int: return self.__depth @property def object_id(self) -> int: return self.__object_id def __repr__(self) -> str: return f"PlacedObject(object_id={self.object_id}, depth={self.depth})" class PlacedShape(PlacedObject): # A shape that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag # referencing an AP2ShapeTag. def __init__( self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, projection: int, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask], source: RegisteredShape, ) -> None: super().__init__(object_id, depth, rotation_origin, transform, projection, mult_color, add_color, blend, mask) self.__source = source @property def source(self) -> RegisteredShape: return self.__source def __repr__(self) -> str: return f"PlacedShape(object_id={self.object_id}, depth={self.depth}, source={self.source})" class PlacedClip(PlacedObject): # A movieclip that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag # referencing an AP2DefineSpriteTag. Essentially an embedded movie clip. def __init__( self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, projection: int, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask], source: RegisteredClip, ) -> None: super().__init__(object_id, depth, rotation_origin, transform, projection, mult_color, add_color, blend, mask) self.placed_objects: List[PlacedObject] = [] self.frame: int = 0 self.unplayed_tags: List[int] = [i for i in range(len(source.tags))] self.__source = source # Dynamic properties that are adjustable by SWF bytecode. self.playing: bool = True self.requested_frame: Optional[int] = None self.visible_frame: int = -1 @property def source(self) -> RegisteredClip: return self.__source def __check_visible(self) -> None: if self.visible_frame >= 0 and self.frame >= self.visible_frame: self.visible = True self.visible_frame = -1 def advance(self) -> None: if self.frame < len(self.source.frames): self.frame += 1 self.__check_visible() def rewind(self) -> None: self.frame = 0 self.unplayed_tags = [i for i in range(len(self.__source.tags))] self.placed_objects = [] self.__check_visible() @property def finished(self) -> bool: return self.frame == len(self.source.frames) def __repr__(self) -> str: return ( f"PlacedClip(object_id={self.object_id}, depth={self.depth}, source={self.source}, frame={self.frame}, " + f"requested_frame={self.requested_frame}, total_frames={len(self.source.frames)}, playing={self.playing}, " + f"finished={self.finished})" ) def __resolve_frame(self, frame: Any) -> Optional[int]: if isinstance(frame, int): return frame if isinstance(frame, str): if frame in self.__source.labels: return self.__source.labels[frame] return None # The following are attributes and functions necessary to support some simple bytecode. def gotoAndStop(self, frame: Any) -> None: actual_frame = self.__resolve_frame(frame) if actual_frame is None: print(f"WARNING: Unrecognized frame {frame} to gotoAndStop function!") return if actual_frame <= 0: actual_frame = 1 if actual_frame > len(self.source.frames): actual_frame = len(self.source.frames) self.requested_frame = actual_frame self.playing = False def gotoAndPlay(self, frame: Any) -> None: actual_frame = self.__resolve_frame(frame) if actual_frame is None: print(f"WARNING: Non-integer frame {frame} to gotoAndPlay function!") return if actual_frame <= 0: actual_frame = 1 if actual_frame > len(self.source.frames): actual_frame = len(self.source.frames) self.requested_frame = actual_frame self.playing = True def stop(self) -> None: self.playing = False def play(self) -> None: self.playing = True def setInvisibleUntil(self, frame: Any) -> None: actual_frame = self.__resolve_frame(frame) if actual_frame is None: print(f"WARNING: Non-integer frame {frame} to setInvisibleUntil function!") return self.visible = False if actual_frame <= 0: actual_frame = 1 if actual_frame > len(self.source.frames): actual_frame = len(self.source.frames) self.visible_frame = actual_frame @property def frameOffset(self) -> int: return self.requested_frame or self.frame @frameOffset.setter def frameOffset(self, val: Any) -> None: actual_frame = self.__resolve_frame(val) if actual_frame is None: print(f"WARNING: Non-integer frameOffset {val} to frameOffset attribute!") return if actual_frame < 0: actual_frame = 0 if actual_frame >= len(self.source.frames): actual_frame = len(self.source.frames) - 1 self.requested_frame = actual_frame + 1 class PlacedImage(PlacedObject): # An image that occupies its parent clip at some depth. Placed by an AP2PlaceObjectTag # referencing an AP2ImageTag. def __init__( self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, projection: int, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask], source: RegisteredImage, ) -> None: super().__init__(object_id, depth, rotation_origin, transform, projection, mult_color, add_color, blend, mask) self.__source = source @property def source(self) -> RegisteredImage: return self.__source def __repr__(self) -> str: return f"PlacedImage(object_id={self.object_id}, depth={self.depth}, source={self.source})" class PlacedDummy(PlacedObject): # A reference to an object we can't find because we're missing the import. def __init__( self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, projection: int, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask], source: RegisteredDummy, ) -> None: super().__init__(object_id, depth, rotation_origin, transform, projection, mult_color, add_color, blend, mask) self.__source = source @property def source(self) -> RegisteredDummy: return self.__source class PlacedCamera: def __init__(self, center: Point, focal_length: float) -> None: self.center = center self.focal_length = focal_length self.adjusted = False class Global: def __init__(self, root: PlacedClip, clip: PlacedClip) -> None: self.root = root self.clip = clip def getInstanceAtDepth(self, depth: Any) -> Any: if not isinstance(depth, int): return UNDEFINED # For some reason, it looks like internally the depth of all objects is # stored added to -0x4000, so let's reverse that. depth = depth + 0x4000 for obj in self.clip.placed_objects: if obj.depth == depth: return obj print(f"WARNING: Could not find object at depth {depth}!") return UNDEFINED def __find_parent(self, parent: PlacedClip, child: PlacedClip) -> Optional[PlacedClip]: for obj in parent.placed_objects: if obj is child: # This is us, so the parent is our parent. return parent if isinstance(obj, PlacedClip): maybe_parent = self.__find_parent(obj, child) if maybe_parent is not None: return maybe_parent return None def find_parent(self, child: PlacedClip) -> Optional[PlacedClip]: return self.__find_parent(self.root, child) class AEPLib: def aep_set_rect_mask(self, thisptr: Any, left: Any, right: Any, top: Any, bottom: Any) -> None: if not isinstance(left, (int, float)) or not isinstance(right, (int, float)) or not isinstance(top, (int, float)) or not isinstance(bottom, (int, float)): print(f"WARNING: Ignoring aeplib.aep_set_rect_mask call with invalid parameters {left}, {right}, {top}, {bottom}!") return if isinstance(thisptr, PlacedObject): thisptr.mask = Mask( Rectangle( left=float(left), right=float(right), top=float(top), bottom=float(bottom), ), ) else: print(f"WARNING: Ignoring aeplib.aep_set_rect_mask call with unrecognized target {thisptr}!") def aep_set_set_frame(self, thisptr: Any, frame: Any) -> None: # This appears to be some sort of callback that the game or other animations can use to figure out # what frame of animation is currently happening. Whenever I've seen it, it is with the 'frame' set # to an integer value that matches the currently rendering frame in the render loop. I think its # safe to ignore this, but if we ever create animations it might be necessary to add calls to this. pass def aep_set_frame_control(self, thisptr: Any, depth: Any, frame: Any) -> None: if not isinstance(thisptr, PlacedClip): print(f"WARNING: Ignoring aeplib.aep_set_frame_control with unrecognized current object {thisptr}!") return for obj in thisptr.placed_objects: if obj.depth == depth: if not isinstance(obj, PlacedClip): print(f"WARNING: Ignoring aeplib.aep_set_frame_control called on object {obj} at depth {depth}!") return obj.setInvisibleUntil(frame) return print(f"WARNING: Ignoring aeplib.aep_set_frame_control called on nonexistent object at depth {depth}!") def gotoAndPlay(self, thisptr: Any, frame: Any) -> Any: # This appears to be a wrapper to allow calling gotoAndPlay on clips. try: meth = getattr(thisptr, 'gotoAndPlay') # Call it, set the return on the stack. return meth(frame) except AttributeError: # Function does not exist! print(f"WARNING: Tried to call gotoAndPlay({frame}) on {thisptr} but that method doesn't exist!") return UNDEFINED def deepGotoAndPlay(self, thisptr: Any, frame: Any) -> Any: # I don't know how this differs from regular gotoAndPlay. try: meth = getattr(thisptr, 'gotoAndPlay') # Call it, set the return on the stack. return meth(frame) except AttributeError: # Function does not exist! print(f"WARNING: Tried to call gotoAndPlay({frame}) on {thisptr} but that method doesn't exist!") return UNDEFINED def stop(self, thisptr: Any) -> Any: # This appears to be a wrapper to allow calling stop on clips. try: meth = getattr(thisptr, 'stop') # Call it, set the return on the stack. return meth() except AttributeError: # Function does not exist! print(f"WARNING: Tried to call stop() on {thisptr} but that method doesn't exist!") return UNDEFINED class ASDLib: def sound_play(self, sound: Any) -> None: if not isinstance(sound, str): print(f"WARNING: Ignoring asdlib.sound_play call with invalid parameters {sound}!") print(f"WARNING: Requested sound {sound} be played but we don't support sound yet!") MissingThis = object() class AFPRenderer(VerboseOutput): def __init__(self, shapes: Dict[str, Shape] = {}, textures: Dict[str, Image.Image] = {}, swfs: Dict[str, SWF] = {}, single_threaded: bool = False, enable_aa: bool = False) -> None: super().__init__() # Options for rendering self.__single_threaded = single_threaded self.__enable_aa = enable_aa # Library of shapes (draw instructions), textures (actual images) and swfs (us and other files for imports). self.shapes: Dict[str, Shape] = shapes self.textures: Dict[str, Image.Image] = textures self.swfs: Dict[str, SWF] = swfs # Internal render parameters. self.__registered_objects: Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]] = {} self.__root: Optional[PlacedClip] = None self.__camera: Optional[PlacedCamera] = None # List of imports that we provide stub implementations for. self.__stubbed_swfs: Set[str] = { 'aeplib.aeplib', 'aeplib.__Packages.aeplib', } def add_shape(self, name: str, data: Shape) -> None: # Register a named shape with the renderer. if not data.parsed: data.parse() self.shapes[name] = data def add_texture(self, name: str, data: Image.Image) -> None: # Register a named texture (already loaded PIL image) with the renderer. self.textures[name] = data.convert("RGBA") def add_swf(self, name: str, data: SWF) -> None: # Register a named SWF with the renderer. if not data.parsed: data.parse() self.swfs[name] = data def render_path( self, path: str, background_color: Optional[Color] = None, background_image: Optional[Image.Image] = None, only_depths: Optional[List[int]] = None, only_frames: Optional[List[int]] = None, movie_transform: Matrix = Matrix.identity(), verbose: bool = False, ) -> Generator[Image.Image, None, None]: # Given a path to a SWF root animation, attempt to render it to a list of frames. for _name, swf in self.swfs.items(): if swf.exported_name == path: # This is the SWF we care about. with self.debugging(verbose): swf.color = background_color or swf.color yield from self.__render(swf, only_depths, only_frames, movie_transform, background_image) return raise Exception(f'{path} not found in registered SWFs!') def compute_path_location( self, path: str, ) -> Rectangle: # Given a path to a SWF root animation, find its bounding rectangle. for _name, swf in self.swfs.items(): if swf.exported_name == path: # This is the SWF we care about. return swf.location raise Exception(f'{path} not found in registered SWFs!') def compute_path_frames( self, path: str, ) -> int: # Given a path to a SWF root animation, figure out how many frames are # in that root path with no regard to bytecode 'stop()' commands. for _name, swf in self.swfs.items(): if swf.exported_name == path: # This is the SWF we care about. return len(swf.frames) raise Exception(f'{path} not found in registered SWFs!') def compute_path_frame_duration( self, path: str, ) -> int: # Given a path to a SWF root animation, figure out how many milliseconds are # occupied by each frame. for _name, swf in self.swfs.items(): if swf.exported_name == path: # This is the SWF we care about. spf = 1.0 / swf.fps return int(spf * 1000.0) raise Exception(f'{path} not found in registered SWFs!') def compute_path_size( self, path: str, ) -> Rectangle: # Given a path to a SWF root animation, figure out what the dimensions # of the SWF are. for _name, swf in self.swfs.items(): if swf.exported_name == path: return swf.location raise Exception(f'{path} not found in registered SWFs!') def list_paths(self, verbose: bool = False) -> Generator[str, None, None]: # Given the loaded animations, return a list of possible paths to render. for _name, swf in self.swfs.items(): yield swf.exported_name def __execute_bytecode(self, bytecode: ByteCode, clip: PlacedClip, thisptr: Optional[Any] = MissingThis, prefix: str="") -> None: if self.__root is None: raise Exception("Logic error, executing bytecode outside of a rendering movie clip!") thisobj = clip if (thisptr is MissingThis) else thisptr globalobj = Global(self.__root, clip) location: int = 0 stack: List[Any] = [] variables: Dict[str, Any] = { 'aeplib': AEPLib(), 'asdlib': ASDLib(), } registers: List[Any] = [UNDEFINED] * 256 self.vprint(f"{prefix}Bytecode engine starting.") while location < len(bytecode.actions): action = bytecode.actions[location] if action.opcode == AP2Action.END: # End the execution. self.vprint(f"{prefix} Ending bytecode execution.") break elif action.opcode == AP2Action.GET_VARIABLE: varname = stack.pop() # Look up the variable, put it on the stack. if varname in variables: stack.append(variables[varname]) else: stack.append(UNDEFINED) elif action.opcode == AP2Action.SET_MEMBER: # Grab what we're about to do. set_value = stack.pop() attribute = stack.pop() obj = stack.pop() if not hasattr(obj, attribute): print(f"WARNING: Tried to set attribute {attribute} on {obj} but that attribute doesn't exist!") else: self.vprint(f"{prefix} Setting attribute {attribute} on {obj} to {set_value}") setattr(obj, attribute, set_value) elif action.opcode == AP2Action.CALL_METHOD: # Grab the method name. methname = stack.pop() # Grab the object to perform the call on. obj = stack.pop() # Grab the parameters to pass to the function. num_params = stack.pop() if not isinstance(num_params, int): raise Exception("Logic error, cannot get number of parameters to method call!") params = [] for _ in range(num_params): params.append(stack.pop()) # Look up the python function we're calling. try: self.vprint(f"{prefix} Calling method {methname}({', '.join(repr(s) for s in params)}) on {obj}") meth = getattr(obj, methname) # Call it, set the return on the stack. stack.append(meth(*params)) except AttributeError: # Function does not exist! print(f"WARNING: Tried to call {methname}({', '.join(repr(s) for s in params)}) on {obj} but that method doesn't exist!") stack.append(UNDEFINED) elif action.opcode == AP2Action.CALL_FUNCTION: # Grab the method name. funcname = stack.pop() # Grab the parameters to pass to the function. num_params = stack.pop() if not isinstance(num_params, int): raise Exception("Logic error, cannot get number of parameters to function call!") params = [] for _ in range(num_params): params.append(stack.pop()) # Look up the python function we're calling. try: self.vprint(f"{prefix} Calling global function {funcname}({', '.join(repr(s) for s in params)})") func = getattr(globalobj, funcname) # Call it, set the return on the stack. stack.append(func(*params)) except AttributeError: # Function does not exist! print(f"WARNING: Tried to call {funcname}({', '.join(repr(s) for s in params)}) on {globalobj} but that function doesn't exist!") stack.append(UNDEFINED) elif isinstance(action, PushAction): for obj in action.objects: if isinstance(obj, Register): stack.append(registers[obj.no]) elif isinstance(obj, StringConstant): if obj.alias: stack.append(obj.alias) else: stack.append(StringConstant.property_to_name(obj.const)) elif obj is NULL: stack.append(None) elif obj is THIS: stack.append(thisobj) elif obj is GLOBAL: stack.append(globalobj) elif obj is ROOT: stack.append(self.__root) elif obj is CLIP: # I am not sure this is correct? Maybe it works out # in circumstances where "THIS" is pointed at something # else, such as defined function calls maybe? stack.append(clip) elif obj is PARENT: # Find the parent of this clip. stack.append(globalobj.find_parent(clip) or UNDEFINED) else: stack.append(obj) elif isinstance(action, StoreRegisterAction): set_value = stack.pop() if action.preserve_stack: stack.append(set_value) for reg in action.registers: registers[reg.no] = set_value elif action.opcode == AP2Action.POP: stack.pop() else: print(f"WARNING: Unhandled opcode {action} with stack {stack}") # Next opcode! location += 1 self.vprint(f"{prefix}Bytecode engine finished.") def __place(self, tag: Tag, operating_clip: PlacedClip, prefix: str = "") -> Tuple[Optional[PlacedClip], bool]: # "Place" a tag on the screen. Most of the time, this means performing the action of the tag, # such as defining a shape (registering it with our shape list) or adding/removing an object. if isinstance(tag, AP2ShapeTag): self.vprint(f"{prefix} Loading {tag.reference} into object slot {tag.id}") if tag.reference not in self.shapes: raise Exception(f"Cannot find shape reference {tag.reference}!") self.__registered_objects[tag.id] = RegisteredShape( tag.id, self.shapes[tag.reference].vertex_points, self.shapes[tag.reference].tex_points, self.shapes[tag.reference].tex_colors, self.shapes[tag.reference].draw_params, ) # Didn't place a new clip, didn't change anything. return None, False elif isinstance(tag, AP2ImageTag): self.vprint(f"{prefix} Loading {tag.reference} into object slot {tag.id}") if tag.reference not in self.textures: raise Exception(f"Cannot find texture reference {tag.reference}!") self.__registered_objects[tag.id] = RegisteredImage( tag.id, tag.reference, ) # Didn't place a new clip, didn't change anything. return None, False elif isinstance(tag, AP2DefineSpriteTag): self.vprint(f"{prefix} Loading Sprite into object slot {tag.id}") # Register a new clip that we might reference to execute. self.__registered_objects[tag.id] = RegisteredClip(tag.id, tag.frames, tag.tags, tag.labels) # Didn't place a new clip, didn't change anything. return None, False elif isinstance(tag, AP2PlaceObjectTag): if tag.unrecognized_options: if tag.source_tag_id is not None: print(f"WARNING: Place object tag referencing {tag.source_tag_id} includes unparsed options and might not display properly!") else: print(f"WARNING: Place object tag on depth {tag.depth} includes unparsed options and might not display properly!") if tag.update: for i in range(len(operating_clip.placed_objects) - 1, -1, -1): obj = operating_clip.placed_objects[i] if obj.object_id == tag.object_id and obj.depth == tag.depth: new_mult_color = tag.mult_color or obj.mult_color new_add_color = tag.add_color or obj.add_color new_transform = obj.transform.update(tag.transform) if tag.transform is not None else obj.transform new_rotation_origin = tag.rotation_origin or obj.rotation_origin new_blend = tag.blend or obj.blend new_projection = tag.projection if tag.projection != AP2PlaceObjectTag.PROJECTION_NONE else obj.projection if tag.source_tag_id is not None and tag.source_tag_id != obj.source.tag_id: # This completely updates the pointed-at object. self.vprint(f"{prefix} Replacing Object source {obj.source.tag_id} with {tag.source_tag_id} on object with Object ID {tag.object_id} onto Depth {tag.depth}") newobj = self.__registered_objects[tag.source_tag_id] if isinstance(newobj, RegisteredShape): operating_clip.placed_objects[i] = PlacedShape( obj.object_id, obj.depth, new_rotation_origin, new_transform, new_projection, new_mult_color, new_add_color, new_blend, obj.mask, newobj, ) # Didn't place a new clip, changed the parent clip. return None, True elif isinstance(newobj, RegisteredImage): operating_clip.placed_objects[i] = PlacedImage( obj.object_id, obj.depth, new_rotation_origin, new_transform, new_projection, new_mult_color, new_add_color, new_blend, obj.mask, newobj, ) # Didn't place a new clip, changed the parent clip. return None, True elif isinstance(newobj, RegisteredClip): new_clip = PlacedClip( tag.object_id, tag.depth, new_rotation_origin, new_transform, new_projection, new_mult_color, new_add_color, new_blend, obj.mask, newobj, ) operating_clip.placed_objects[i] = new_clip # Placed a new clip, changed the parent. return new_clip, True elif isinstance(newobj, RegisteredDummy): operating_clip.placed_objects[i] = PlacedDummy( obj.object_id, obj.depth, new_rotation_origin, new_transform, new_projection, new_mult_color, new_add_color, new_blend, obj.mask, newobj, ) # Didn't place a new clip, changed the parent clip. return None, True else: raise Exception(f"Unrecognized object with Tag ID {tag.source_tag_id}!") else: # As far as I can tell, pretty much only color and matrix stuff can be updated. self.vprint(f"{prefix} Updating Object ID {tag.object_id} on Depth {tag.depth}") obj.mult_color = new_mult_color obj.add_color = new_add_color obj.transform = new_transform obj.rotation_origin = new_rotation_origin obj.projection = new_projection obj.blend = new_blend return None, True # Didn't place a new clip, did change something. print(f"WARNING: Couldn't find tag {tag.object_id} on depth {tag.depth} to update!") return None, False else: if tag.source_tag_id is None: raise Exception("Cannot place a tag with no source ID and no update flags!") if tag.source_tag_id in self.__registered_objects: self.vprint(f"{prefix} Placing Object {tag.source_tag_id} with Object ID {tag.object_id} onto Depth {tag.depth}") newobj = self.__registered_objects[tag.source_tag_id] if isinstance(newobj, RegisteredShape): operating_clip.placed_objects.append( PlacedShape( tag.object_id, tag.depth, tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.projection, tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, None, newobj, ) ) # Didn't place a new clip, changed the parent clip. return None, True elif isinstance(newobj, RegisteredImage): operating_clip.placed_objects.append( PlacedImage( tag.object_id, tag.depth, tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.projection, tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, None, newobj, ) ) # Didn't place a new clip, changed the parent clip. return None, True elif isinstance(newobj, RegisteredClip): placed_clip = PlacedClip( tag.object_id, tag.depth, tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.projection, tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, None, newobj, ) operating_clip.placed_objects.append(placed_clip) for flags, code in tag.triggers.items(): if flags & AP2Trigger.ON_LOAD: for bytecode in code: self.__execute_bytecode(bytecode, placed_clip, prefix=prefix + " ") else: print("WARNING: Unhandled PLACE_OBJECT trigger with flags {flags}!") # Placed a new clip, changed the parent. return placed_clip, True elif isinstance(newobj, RegisteredDummy): operating_clip.placed_objects.append( PlacedDummy( tag.object_id, tag.depth, tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.projection, tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), tag.blend or 0, None, newobj, ) ) # Didn't place a new clip, changed the parent clip. return None, True else: raise Exception(f"Unrecognized object with Tag ID {tag.source_tag_id}!") raise Exception(f"Cannot find a shape or sprite with Tag ID {tag.source_tag_id}!") 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. # Remember removed objects so we can stop any clips. removed_objects = [ obj for obj in operating_clip.placed_objects if obj.object_id == tag.object_id and obj.depth == tag.depth ] # Get rid of the objects that we're removing from the master list. operating_clip.placed_objects = [ obj for obj in operating_clip.placed_objects if not(obj.object_id == tag.object_id and obj.depth == tag.depth) ] else: # Remove the last placed object at this depth. The placed objects list isn't # ordered so much as apppending to the list means the last placed object at a # depth comes last. removed_objects = [] for i in range(len(operating_clip.placed_objects)): real_index = len(operating_clip.placed_objects) - (i + 1) if operating_clip.placed_objects[real_index].depth == tag.depth: removed_objects = operating_clip.placed_objects[real_index:(real_index + 1)] operating_clip.placed_objects = operating_clip.placed_objects[:real_index] + operating_clip.placed_objects[(real_index + 1):] break if not removed_objects: print(f"WARNING: Couldn't find object to remove by ID {tag.object_id} and depth {tag.depth}!") # TODO: Handle ON_UNLOAD triggers for this object. I don't think I've ever seen one # on any object so this might be a pedantic request. # Didn't place a new clip, changed parent clip. return None, True elif isinstance(tag, AP2DoActionTag): self.vprint(f"{prefix} Execution action tag.") self.__execute_bytecode(tag.bytecode, operating_clip, prefix=prefix + " ") # Didn't place a new clip. return None, False elif isinstance(tag, AP2DefineFontTag): print("WARNING: Unhandled DEFINE_FONT tag!") # Didn't place a new clip. return None, False elif isinstance(tag, AP2DefineEditTextTag): print("WARNING: Unhandled DEFINE_EDIT_TEXT tag!") # Didn't place a new clip. return None, False elif isinstance(tag, AP2PlaceCameraTag): self.vprint(f"{prefix} Place camera tag.") self.__camera = PlacedCamera( tag.center, tag.focal_length, ) # Didn't place a new clip. return None, False else: raise Exception(f"Failed to process tag: {tag}") def __apply_mask( self, parent_mask: Image.Image, transform: Matrix, projection: int, mask: Mask, ) -> Image.Image: if mask.rectangle is None: # Calculate the new mask rectangle. mask.rectangle = affine_composite( Image.new('RGBA', (int(mask.bounds.right), int(mask.bounds.bottom)), (0, 0, 0, 0)), Color(0.0, 0.0, 0.0, 0.0), Color(1.0, 1.0, 1.0, 1.0), Matrix.identity().translate(Point(mask.bounds.left, mask.bounds.top)), None, 0, Image.new('RGBA', (int(mask.bounds.width), int(mask.bounds.height)), (255, 0, 0, 255)), single_threaded=self.__single_threaded, aa_mode=AAMode.NONE, ) # Draw the mask onto a new image. if projection == AP2PlaceObjectTag.PROJECTION_AFFINE: calculated_mask = affine_composite( Image.new('RGBA', (parent_mask.width, parent_mask.height), (0, 0, 0, 0)), Color(0.0, 0.0, 0.0, 0.0), Color(1.0, 1.0, 1.0, 1.0), transform, None, 257, mask.rectangle, single_threaded=self.__single_threaded, aa_mode=AAMode.NONE, ) elif projection == AP2PlaceObjectTag.PROJECTION_PERSPECTIVE: if self.__camera is None: print("WARNING: Element requests perspective projection but no camera exists!") calculated_mask = affine_composite( Image.new('RGBA', (parent_mask.width, parent_mask.height), (0, 0, 0, 0)), Color(0.0, 0.0, 0.0, 0.0), Color(1.0, 1.0, 1.0, 1.0), transform, None, 257, mask.rectangle, single_threaded=self.__single_threaded, aa_mode=AAMode.NONE, ) else: calculated_mask = perspective_composite( Image.new('RGBA', (parent_mask.width, parent_mask.height), (0, 0, 0, 0)), Color(0.0, 0.0, 0.0, 0.0), Color(1.0, 1.0, 1.0, 1.0), transform, self.__camera.center, self.__camera.focal_length, None, 257, mask.rectangle, single_threaded=self.__single_threaded, aa_mode=AAMode.NONE, ) # Composite it onto the current mask. return affine_composite( parent_mask.copy(), Color(0.0, 0.0, 0.0, 0.0), Color(1.0, 1.0, 1.0, 1.0), Matrix.identity(), None, 256, calculated_mask, single_threaded=self.__single_threaded, aa_mode=AAMode.NONE, ) def __render_object( self, img: Image.Image, renderable: PlacedObject, parent_transform: Matrix, parent_projection: int, parent_mask: Image.Image, parent_mult_color: Color, parent_add_color: Color, parent_blend: int, only_depths: Optional[List[int]] = None, prefix: str="", ) -> Image.Image: if not renderable.visible: self.vprint(f"{prefix} Ignoring invisible placed object ID {renderable.object_id} from sprite {renderable.source.tag_id} on Depth {renderable.depth}") return img self.vprint(f"{prefix} Rendering placed object ID {renderable.object_id} from sprite {renderable.source.tag_id} onto Depth {renderable.depth}") # Compute the affine transformation matrix for this object. transform = renderable.transform.multiply(parent_transform).translate(Point.identity().subtract(renderable.rotation_origin)) projection = AP2PlaceObjectTag.PROJECTION_PERSPECTIVE if parent_projection == AP2PlaceObjectTag.PROJECTION_PERSPECTIVE else renderable.projection # Calculate blending and blend color if it is present. mult_color = (renderable.mult_color or Color(1.0, 1.0, 1.0, 1.0)).multiply(parent_mult_color) add_color = (renderable.add_color or Color(0.0, 0.0, 0.0, 0.0)).multiply(parent_mult_color).add(parent_add_color) blend = renderable.blend or 0 if parent_blend not in {0, 1, 2} and blend in {0, 1, 2}: blend = parent_blend if renderable.mask: mask = self.__apply_mask(parent_mask, transform, projection, renderable.mask) else: mask = parent_mask # Render individual shapes if this is a sprite. if isinstance(renderable, PlacedClip): new_only_depths: Optional[List[int]] = None if only_depths is not None: if renderable.depth not in only_depths: if renderable.depth != -1: # Not on the correct depth plane. return img new_only_depths = only_depths # This is a sprite placement reference. Make sure that we render lower depths # first, but preserved placed order as well. depths = set(obj.depth for obj in renderable.placed_objects) for depth in sorted(depths): for obj in renderable.placed_objects: if obj.depth != depth: continue img = self.__render_object(img, obj, transform, projection, mask, mult_color, add_color, blend, only_depths=new_only_depths, prefix=prefix + " ") elif isinstance(renderable, PlacedShape): if only_depths is not None and renderable.depth not in only_depths: # Not on the correct depth plane. return img # This is a shape draw reference. shape = renderable.source # Now, render out shapes. for params in shape.draw_params: if not (params.flags & 0x1): # Not instantiable, don't render. return img if params.flags & 0x4: # TODO: Need to support blending and UV coordinate colors here. print("WARNING: Unhandled UV coordinate color!") texture = None rectangle = False 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] if params.flags & 0x8: # TODO: This texture gets further blended somehow? Not sure this is ever used. print(f"WARNING: Unhandled texture blend color {params.blend}!") elif params.flags & 0x8: if shape.rectangle is None: # This is a raw rectangle. Its possible that the number of vertex points is # not 4, or that the four points in the vertex_points aren't the four corners # of a rectangle, but let's assume that doesn't happen for now. if len(shape.vertex_points) != 4: print("WARNING: Unsupported non-rectangle shape!") if params.blend is None: raise Exception("Logic error, rectangle without a blend color!") x_points = set(p.x for p in shape.vertex_points) y_points = set(p.y for p in shape.vertex_points) left = min(x_points) right = max(x_points) top = min(y_points) bottom = max(y_points) # Make sure that the four corners are aligned. bad = False for point in x_points: if point not in {left, right}: bad = True break for point in y_points: if point not in {top, bottom}: bad = True break if bad: print("WARNING: Unsupported non-rectangle shape!") shape.rectangle = Image.new('RGBA', (int(right - left), int(bottom - top)), (params.blend.as_tuple())) texture = shape.rectangle rectangle = True if texture is not None: if projection == AP2PlaceObjectTag.PROJECTION_AFFINE: if self.__enable_aa: aamode = AAMode.UNSCALED_SSAA_ONLY if rectangle else AAMode.SSAA_OR_BILINEAR else: aamode = AAMode.NONE img = affine_composite( img, add_color, mult_color, transform, mask, blend, texture, single_threaded=self.__single_threaded, aa_mode=aamode, ) elif projection == AP2PlaceObjectTag.PROJECTION_PERSPECTIVE: if self.__camera is None: if self.__enable_aa: aamode = AAMode.UNSCALED_SSAA_ONLY if rectangle else AAMode.SSAA_OR_BILINEAR else: aamode = AAMode.NONE print("WARNING: Element requests perspective projection but no camera exists!") img = affine_composite( img, add_color, mult_color, transform, mask, blend, texture, single_threaded=self.__single_threaded, aa_mode=aamode, ) else: if self.__enable_aa: aamode = AAMode.UNSCALED_SSAA_ONLY if rectangle else AAMode.SSAA_ONLY else: aamode = AAMode.NONE img = perspective_composite( img, add_color, mult_color, transform, self.__camera.center, self.__camera.focal_length, mask, blend, texture, single_threaded=self.__single_threaded, aa_mode=aamode, ) elif isinstance(renderable, PlacedImage): if only_depths is not None and renderable.depth not in only_depths: # Not on the correct depth plane. return img # This is a shape draw reference. texture = self.textures[renderable.source.reference] if projection == AP2PlaceObjectTag.PROJECTION_AFFINE: img = affine_composite( img, add_color, mult_color, transform, mask, blend, texture, single_threaded=self.__single_threaded, aa_mode=AAMode.SSAA_OR_BILINEAR if self.__enable_aa else AAMode.NONE, ) elif projection == AP2PlaceObjectTag.PROJECTION_PERSPECTIVE: if self.__camera is None: print("WARNING: Element requests perspective projection but no camera exists!") img = affine_composite( img, add_color, mult_color, transform, mask, blend, texture, single_threaded=self.__single_threaded, aa_mode=AAMode.SSAA_OR_BILINEAR if self.__enable_aa else AAMode.NONE, ) else: img = perspective_composite( img, add_color, mult_color, transform, self.__camera.center, self.__camera.focal_length, mask, blend, texture, single_threaded=self.__single_threaded, aa_mode=AAMode.SSAA_ONLY if self.__enable_aa else AAMode.NONE, ) elif isinstance(renderable, PlacedDummy): # Nothing to do! pass else: raise Exception(f"Unknown placed object type to render {renderable}!") return img def __is_dirty(self, clip: PlacedClip) -> bool: # If we are dirty ourselves, then the clip is definitely dirty. if clip.requested_frame is not None: return True # If one of our children is dirty, then we are dirty. for child in clip.placed_objects: if isinstance(child, PlacedClip): if self.__is_dirty(child): return True # None of our children (or their children, etc...) or ourselves is dirty. return False def __process_tags(self, clip: PlacedClip, only_dirty: bool, prefix: str = " ") -> bool: self.vprint(f"{prefix}Handling {'dirty updates on ' if only_dirty else ''}placed clip {clip.object_id} at depth {clip.depth}") # Track whether anything in ourselves or our children changes during this processing. changed = False # Make sure to set the requested frame if it isn't set by an external force. if clip.requested_frame is None: if not clip.playing or clip.finished or only_dirty: # We aren't playing this clip because its either paused or finished, # or it isn't dirty and we're doing dirty updates only. So, we don't # need to advance to any frame. clip.requested_frame = clip.frame else: # We need to do as many things as we need to get to the next frame. clip.requested_frame = clip.frame + 1 while True: # First, see if we need to rewind the clip if we were requested to go backwards # during some bytecode update in this loop. if clip.frame > clip.requested_frame: # Rewind this clip to the beginning so we can replay until the requested frame. if clip is self.__root: print("WARNING: Root clip was rewound, its possible this animation plays forever!") clip.rewind() self.vprint(f"{prefix} Processing frame {clip.frame} on our way to frame {clip.requested_frame}") # Clips that are part of our own placed objects which we should handle. child_clips = [c for c in clip.placed_objects if isinstance(c, PlacedClip)] # Execute each tag in the frame if we need to move forward to a new frame. if clip.frame != clip.requested_frame: frame = clip.source.frames[clip.frame] orphans: List[Tag] = [] played_tags: Set[int] = set() # See if we have any orphans that need to be placed before this frame will work. for unplayed_tag in clip.unplayed_tags: if unplayed_tag < frame.start_tag_offset: self.vprint(f"{prefix} Including orphaned tag {unplayed_tag} in frame evaluation") played_tags.add(unplayed_tag) orphans.append(clip.source.tags[unplayed_tag]) for tagno in range(frame.start_tag_offset, frame.start_tag_offset + frame.num_tags): played_tags.add(tagno) # Check these off our future todo list. clip.unplayed_tags = [t for t in clip.unplayed_tags if t not in played_tags] # Grab the normal list of tags, add to the orphans. tags = orphans + clip.source.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)] for tagno, tag in enumerate(tags): # Perform the action of this tag. self.vprint(f"{prefix} Sprite Tag ID: {clip.source.tag_id}, Current Tag: {frame.start_tag_offset + tagno}, Num Tags: {frame.num_tags}") new_clip, clip_changed = self.__place(tag, clip, prefix=prefix) changed = changed or clip_changed # If we create a new movie clip, process it as well for this frame. if new_clip: # These are never dirty-only updates as they're fresh-placed. changed = self.__process_tags(new_clip, False, prefix=prefix + " ") or changed # Now, advance the frame for this clip since we processed the frame. clip.advance() # Now, handle each of the existing clips. for child in child_clips: changed = self.__process_tags(child, only_dirty, prefix=prefix + " ") or changed # See if we're done with this clip. if clip.frame == clip.requested_frame: clip.requested_frame = None break self.vprint(f"{prefix}Finished handling {'dirty updates on ' if only_dirty else ''}placed clip {clip.object_id} at depth {clip.depth}") # Return if anything was modified. return changed def __handle_imports(self, swf: SWF) -> Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]]: external_objects: Dict[int, Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]] = {} # Go through, recursively resolve imports for all SWF files. for tag_id, imp in swf.imported_tags.items(): for _name, other in self.swfs.items(): if other.exported_name == imp.swf: # This SWF should have the tag reference. if imp.tag not in other.exported_tags: print(f"WARNING: {swf.exported_name} imports {imp} but that import is not in {other.exported_name}!") external_objects[tag_id] = RegisteredDummy(tag_id) break else: external_objects[tag_id] = self.__find_import(other, other.exported_tags[imp.tag]) break else: # Only display a warning if we don't have our own stub implementation of this SWF. if repr(imp) not in self.__stubbed_swfs: print(f"WARNING: {swf.exported_name} imports {imp} but that SWF is not in our library!") external_objects[tag_id] = RegisteredDummy(tag_id) # Fix up tag IDs to point at our local definition of them. for tid in external_objects: external_objects[tid].tag_id = tid # Return our newly populated registered object table containing all imports! return external_objects def __find_import(self, swf: SWF, tag_id: int) -> Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]: if tag_id in swf.imported_tags: external_objects = self.__handle_imports(swf) if tag_id not in external_objects: raise Exception(f"Logic error, tag ID {tag_id} is an export for {swf.exported_name} but we didn't populate it!") return external_objects[tag_id] # We need to do a basic placement to find the registered object so we can return it. root_clip = RegisteredClip( None, swf.frames, swf.tags, swf.labels, ) tag = self.__find_tag(root_clip, tag_id) if tag is None: print(f"WARNING: {swf.exported_name} exports {swf.imported_tags[tag_id]} but does not manifest an object!") return RegisteredDummy(tag_id) return tag def __find_tag(self, clip: RegisteredClip, tag_id: int) -> Optional[Union[RegisteredShape, RegisteredClip, RegisteredImage, RegisteredDummy]]: # Fake-execute this clip to find the tag we need to manifest. for frame in clip.frames: tags = clip.tags[frame.start_tag_offset:(frame.start_tag_offset + frame.num_tags)] for tag in tags: # Attempt to place any tags. if isinstance(tag, AP2ShapeTag): if tag.id == tag_id: # We need to be able to see this shape to place it. if tag.reference not in self.shapes: raise Exception(f"Cannot find shape reference {tag.reference}!") # This matched, so this is the import. return RegisteredShape( tag.id, self.shapes[tag.reference].vertex_points, self.shapes[tag.reference].tex_points, self.shapes[tag.reference].tex_colors, self.shapes[tag.reference].draw_params, ) elif isinstance(tag, AP2ImageTag): if tag.id == tag_id: # We need to be able to see this shape to place it. if tag.reference not in self.textures: raise Exception(f"Cannot find texture reference {tag.reference}!") # This matched, so this is the import. return RegisteredImage( tag.id, tag.reference, ) elif isinstance(tag, AP2DefineSpriteTag): new_clip = RegisteredClip(tag.id, tag.frames, tag.tags, tag.labels) if tag.id == tag_id: # This matched, so it is the clip that we want to export. return new_clip # Recursively look in this as well. maybe_tag = self.__find_tag(new_clip, tag_id) if maybe_tag is not None: return maybe_tag # We didn't find the tag we were after. return None def __render( self, swf: SWF, only_depths: Optional[List[int]], only_frames: Optional[List[int]], movie_transform: Matrix, background_image: Optional[Image.Image], ) -> Generator[Image.Image, None, None]: # First, let's attempt to resolve imports. self.__registered_objects = self.__handle_imports(swf) # Initialize overall frame advancement stuff. last_rendered_frame: Optional[Image.Image] = None frameno: int = 0 # Calculate actual size based on given movie transform. resized_width, resized_height, _ = movie_transform.multiply_point(Point(swf.location.width, swf.location.height)).as_tuple() # TODO: If the location top/left is nonzero, we need move the root transform # so that the correct viewport is rendered. # Create a root clip for the movie to play. root_clip = PlacedClip( -1, -1, Point.identity(), Matrix.identity(), AP2PlaceObjectTag.PROJECTION_AFFINE, Color(1.0, 1.0, 1.0, 1.0), Color(0.0, 0.0, 0.0, 0.0), 0, None, RegisteredClip( None, swf.frames, swf.tags, swf.labels, ), ) self.__root = root_clip # If we have a background image, add it to the root clip. if background_image: # Stretch the image to make sure it fits the entire frame. imgwidth = float(background_image.width) imgheight = float(background_image.height) background_matrix = Matrix.affine( a=swf.location.width / imgwidth, b=0, c=0, d=swf.location.height / imgheight, tx=0, ty=0, ) # Register the background image with the texture library. name = f"{swf.exported_name}_inserted_background" self.textures[name] = background_image.convert("RGBA") # Place an instance of this background on the root clip. root_clip.placed_objects.append( PlacedShape( -1, -1, Point.identity(), background_matrix, AP2PlaceObjectTag.PROJECTION_AFFINE, Color(1.0, 1.0, 1.0, 1.0), Color(0.0, 0.0, 0.0, 0.0), 0, None, RegisteredShape( -1, # The coordinates of the rectangle of the shape in screen space. [ Point(0, 0), Point(imgwidth, 0), Point(imgwidth, imgheight), Point(0, imgheight), ], # The coordinates of the original texture in UV space (we don't use this). [ Point(0.0, 0.0), Point(1.0, 0.0), Point(1.0, 1.0), Point(0.0, 1.0), ], # No texture colors. [], [ DrawParams( # Instantiable, includes texture. 0x3, # The texture this should use for drawing. name, # The coordinates of the triangles that get drawn (we don't use this). [0, 1, 2, 2, 1, 3], # The blend color. None, ), ], ), ), ) # Create the root mask for where to draw the root clip. movie_mask = Image.new("RGBA", (resized_width, resized_height), color=(255, 0, 0, 255)) # These could possibly be overwritten from an external source of we wanted. actual_mult_color = Color(1.0, 1.0, 1.0, 1.0) actual_add_color = Color(0.0, 0.0, 0.0, 0.0) actual_blend = 0 max_frame: Optional[int] = None if only_frames: max_frame = max(only_frames) # Now play the frames of the root clip. try: while root_clip.playing and not root_clip.finished: # Create a new image to render into. self.vprint(f"Rendering frame {frameno + 1}/{len(root_clip.source.frames)}") # Go through all registered clips, place all needed tags. changed = self.__process_tags(root_clip, False) while self.__is_dirty(root_clip): changed = self.__process_tags(root_clip, True) or changed # Adjust camera based on the movie's scaling. if self.__camera is not None and not self.__camera.adjusted: self.__camera.center = movie_transform.multiply_point(self.__camera.center) self.__camera.adjusted = True # If we're only rendering some frames, don't bother to do the draw operations # if we aren't going to return the frame. if only_frames and (frameno + 1) not in only_frames: self.vprint(f"Skipped rendering frame {frameno + 1}/{len(root_clip.source.frames)}") last_rendered_frame = None frameno += 1 continue if changed or last_rendered_frame is None: # Now, render out the placed objects. color = swf.color or Color(0.0, 0.0, 0.0, 0.0) curimage = Image.new("RGBA", (resized_width, resized_height), color=color.as_tuple()) curimage = self.__render_object( curimage, root_clip, movie_transform, AP2PlaceObjectTag.PROJECTION_AFFINE, movie_mask, actual_mult_color, actual_add_color, actual_blend, only_depths=only_depths, ) else: # Nothing changed, make a copy of the previous render. self.vprint(" Using previous frame render") curimage = last_rendered_frame.copy() # Return that frame, advance our bookkeeping. self.vprint(f"Finished rendering frame {frameno + 1}/{len(root_clip.source.frames)}") last_rendered_frame = curimage frameno += 1 yield curimage # See if we should bail because we passed the last requested frame. if max_frame is not None and frameno == max_frame: break except KeyboardInterrupt: # Allow ctrl-c to end early and render a partial animation. print(f"WARNING: Interrupted early, will render only {frameno}/{len(root_clip.source.frames)} frames of animation!") # Clean up self.__root = None