├── README.md └── Blender Camera to Replay Camera.txt /README.md: -------------------------------------------------------------------------------- 1 | # Blender-Camera-to-Replay-Camera 2 | The script I use to convert selected blender cameras into replay mod's timeline 3 | 4 | ## BEFORE USING 5 | 6 | **I recommended having Toggle Path Preview off** 7 | It will lag otherwise. 8 | 9 | **Make backups of important replay recordings** 10 | Not responsible for corrupted files. 11 | 12 | ## How to use the script 13 | If you haven't used scripts in Blender, copy and paste the text into the Text Editor and click the Run Script. 14 | 15 | First, have the camera you want to import selected. 16 | 17 | Second, run the script and select the replay recording file you want to change. Files are normally located here: AppData\Roaming.minecraft\replay_recordings 18 | 19 | Third, click Modify .mcpr 20 | 21 | ## What the options do 22 | 23 | Density: Controls the percentage of keyframes transferred. At very low values you might be able to toggle path preview on. 24 | 25 | Scale Time: Stretches the Blender timeline to Replay Mod’s timeline. A value of 1 means 1:1, 2 means 1:2, and so on. Very low values (<0.01) may cause keyframes to overlap, preventing the replay from loading. 26 | 27 | Scene Start is Replay Start: Automatically aligns the start of your scene with the replay's start. Disable this if you want your first keyframe to appear later. 28 | 29 | Enables Catmull Interpolation: By default it uses linear but it might be useful to use smoothing with low density values. 30 | 31 | 32 | ## Quick video showing it https://youtu.be/2QIWRUhhq4I 33 | 34 | All camera constraints should automatically be baked correctly but if you are doing something very weird and it doesnt work lmk. 35 | 36 | ## How do you know what position in Blender corresponds to Minecraft? 37 | The easiest way to do this is by importing part of the map with Mineways. However, **unselect** this option when exporting. 38 | You can also try exporting a test camera path from Replay Mod but it might be more tedious 39 | 40 | ![image](https://github.com/user-attachments/assets/9d5c082b-f4d9-48df-853e-09fd029f2915) 41 | 42 | 43 | -------------------------------------------------------------------------------- /Blender Camera to Replay Camera.txt: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | import tempfile 4 | import zipfile 5 | import bpy 6 | import numpy as np 7 | import json 8 | import shutil 9 | import re 10 | from mathutils import Quaternion, Euler 11 | from bpy.props import FloatProperty, IntProperty, StringProperty, BoolProperty 12 | from bpy_extras.io_utils import ImportHelper 13 | from bpy.types import Operator 14 | 15 | class OT_TestOpenFilebrowser(Operator, ImportHelper): 16 | bl_idname = "test.open_filebrowser" 17 | bl_label = "Modify .mcpr" 18 | filename_ext = ".mcpr" 19 | filter_glob: StringProperty( 20 | default="*.mcpr", 21 | options={'HIDDEN'}, 22 | maxlen=255, 23 | ) 24 | density_slider: IntProperty( 25 | name='Density', 26 | description='Percent of keyframes saved. Rounds to the nearest frame', 27 | default=100, 28 | min=0, 29 | max=100, 30 | subtype='PERCENTAGE' 31 | ) 32 | time_scalar: FloatProperty( 33 | name='Scale Timeline', 34 | description='Scales the distance between keyframes. Very low number may overlap keyframes causing crashes', 35 | default=1, 36 | min=0.01 37 | ) 38 | scene_frame_replay_start: BoolProperty( 39 | name="Scene Start is Replay Start", 40 | description="If enabled, the scene frame start will be set at the start of replay's timeline", 41 | default=True, 42 | ) 43 | interpolation_type: BoolProperty( 44 | name="Enables Catmull Interpolation", 45 | description="Default linear interpolation", 46 | default=False, 47 | ) 48 | 49 | def extract_mcpr(self, filepath): #Open .mcpr 50 | temp_dir = tempfile.mkdtemp() 51 | with zipfile.ZipFile(filepath, 'r') as zip_ref: 52 | zip_ref.extractall(temp_dir) 53 | return temp_dir 54 | 55 | def check_for_timelines(self, directory): #Make timeline.json if it doesnt exist 56 | timelines_json_path = os.path.join(directory, "timelines.json") 57 | if not os.path.exists(timelines_json_path): 58 | # Create timelines.json file with the specified default content 59 | default_content = '{"":[{"keyframes":[],"segments":[],"interpolators":[]},{"keyframes":[],"segments":[],"interpolators":[]}]}' 60 | with open(timelines_json_path, 'w') as f: 61 | f.write(default_content) 62 | return os.path.exists(timelines_json_path) 63 | 64 | def execute(self, context): 65 | # Check if an active object is selected and if it's a camera 66 | selected_camera = bpy.context.active_object 67 | if selected_camera is None or selected_camera.type != 'CAMERA': 68 | self.report({'WARNING'}, "No active camera selected. Please select a camera before running this operator.") 69 | return {'CANCELLED'} 70 | bpy.ops.ed.undo_push() 71 | filepath = bpy.path.abspath(self.filepath) 72 | 73 | if self.interpolation_type: 74 | interpolator = '{"type":"catmull-rom-spline","alpha":0.5}' 75 | else: 76 | interpolator = '"linear"' 77 | 78 | # Check if the selected path is a file 79 | if os.path.isfile(filepath) and filepath.endswith(".mcpr"): 80 | temp_dir = None # Initialize temp_dir to None 81 | 82 | try: 83 | # Extract the contents of the .mcpr file 84 | temp_dir = self.extract_mcpr(filepath) 85 | if temp_dir: 86 | if self.check_for_timelines(temp_dir): 87 | def euler_to_quaternion(euler): 88 | return euler.to_quaternion() 89 | 90 | def quaternion_to_euler(q): 91 | # Convert quaternion to rotation matrix 92 | w, x, y, z = q 93 | rotation_matrix = np.array([ 94 | [1 - 2*y**2 - 2*z**2, 2*x*y - 2*z*w, 2*x*z + 2*y*w], 95 | [2*x*y + 2*z*w, 1 - 2*x**2 - 2*z**2, 2*y*z - 2*x*w], 96 | [2*x*z - 2*y*w, 2*y*z + 2*x*w, 1 - 2*x**2 - 2*y**2] 97 | ]) 98 | 99 | yaw = np.arctan2(rotation_matrix[0, 2], rotation_matrix[2, 2]) 100 | 101 | # Pitch (about local x axis) 102 | pitch = np.arcsin(-rotation_matrix[1, 2]) 103 | 104 | # Roll (about local z axis) 105 | roll = np.arctan2(rotation_matrix[1, 0], rotation_matrix[1, 1]) 106 | 107 | return yaw, pitch, roll 108 | 109 | def bake_camera_animation(density_percentage): 110 | scene = bpy.context.scene 111 | selected_camera = bpy.context.active_object 112 | 113 | # Set the frame range for baking 114 | frame_start = scene.frame_start 115 | frame_end = scene.frame_end 116 | fps = scene.render.fps 117 | 118 | # Select the camera 119 | bpy.context.view_layer.objects.active = selected_camera 120 | 121 | # Calculate the frame range based on the time step 122 | num_keyframes = round((frame_end - frame_start + 1) * density_percentage / 100) 123 | frame_range = np.linspace(frame_start, frame_end, num=num_keyframes, endpoint=True, dtype=int) 124 | 125 | # Add the first frame if it's not already included 126 | if frame_start not in frame_range: 127 | frame_range = np.append(frame_start, frame_range) 128 | 129 | # Add the last frame if it's not already included 130 | if frame_end not in frame_range: 131 | frame_range = np.append(frame_range, frame_end) 132 | 133 | # Bake camera animation 134 | bpy.ops.nla.bake(frame_start=frame_start, 135 | frame_end=frame_end, 136 | visual_keying=True, 137 | clear_constraints=True, 138 | clear_parents=True, 139 | use_current_action=True, 140 | clean_curves=True, 141 | bake_types={'OBJECT'}) 142 | 143 | keyframe_list = [] 144 | 145 | for frame in frame_range: 146 | scene.frame_set(frame) 147 | loc = selected_camera.location 148 | if selected_camera.rotation_mode == 'QUATERNION': 149 | rot_quat = selected_camera.rotation_quaternion 150 | elif selected_camera.rotation_mode == 'XYZ': 151 | rot_euler = selected_camera.rotation_euler 152 | rot_quat = euler_to_quaternion(rot_euler) 153 | else: 154 | print("Unsupported rotation mode:", selected_camera.rotation_mode) 155 | continue 156 | 157 | # Create a quaternion representing a 90-degree rotation about the global x-axis 158 | rotation_correction = Quaternion((np.sqrt(0.5), np.sqrt(0.5), 0, 0)) 159 | rot_quat = rotation_correction @ rot_quat 160 | 161 | yaw, pitch, roll = quaternion_to_euler(rot_quat) 162 | yaw_degrees = np.degrees(yaw) 163 | pitch_degrees = np.degrees(pitch) 164 | roll_degrees = np.degrees(roll) * -1 + 180 165 | 166 | # Adjust location according to specifications 167 | loc_adjusted = loc.copy() 168 | loc_adjusted.y = loc.z - 1.62 169 | loc_adjusted.z = -loc.y 170 | 171 | keyframe_data = { 172 | "time": round((((frame - (context.scene.frame_start if self.scene_frame_replay_start else 1)) * 1000) * self.time_scalar) / context.scene.render.fps), 173 | "properties": { 174 | "camera:rotation": [yaw_degrees, pitch_degrees, roll_degrees], 175 | "camera:position": [round(loc_adjusted.x, 3), round(loc_adjusted.y, 3), round(loc_adjusted.z, 3)] 176 | } 177 | } 178 | keyframe_list.append(keyframe_data) 179 | 180 | # Convert keyframe list to string format 181 | keyframe_string = str(keyframe_list).replace("'", '"').replace(' ', '') 182 | 183 | # Create the segments string 184 | segments_str = ',"segments":' + str([0] * (len(keyframe_list) - 1)).replace(' ', '') 185 | 186 | # Combine both strings 187 | final_string = '{"keyframes":' + keyframe_string + segments_str + ',"interpolators":[{"type":' + interpolator + ',"properties":["camera:position","camera:rotation"]}]}' # or "type":"linear" 188 | 189 | # Read and process the content of the timelines.json file 190 | timelines_json_path = os.path.join(temp_dir, "timelines.json") 191 | with open(timelines_json_path, "r") as f: 192 | content = f.read() 193 | 194 | # Initialize counters and flags 195 | level = 0 196 | first_brace_found = False 197 | second_brace_start = -1 198 | second_brace_end = -1 199 | 200 | # Iterate over the string to find the second instance of {} 201 | for i, char in enumerate(content): 202 | if char == '{': 203 | if level == 1 and first_brace_found: 204 | second_brace_start = i 205 | level += 1 206 | elif char == '}': 207 | level -= 1 208 | if level == 1 and first_brace_found and second_brace_start != -1: 209 | second_brace_end = i + 1 210 | break 211 | elif char == ',' and level == 1 and not first_brace_found: 212 | first_brace_found = True 213 | 214 | # Replace the second instance of {} with final_string 215 | if second_brace_start != -1 and second_brace_end != -1: 216 | result = (content[:second_brace_start] + final_string + content[second_brace_end:]) 217 | 218 | with open(timelines_json_path, "w") as f: 219 | f.write(result) 220 | 221 | # Write the modified timelines.json back to the .mcpr archive 222 | with zipfile.ZipFile(filepath, 'w') as zip_ref: 223 | for root, dirs, files in os.walk(temp_dir): 224 | for file in files: 225 | file_path = os.path.join(root, file) 226 | zip_ref.write(file_path, os.path.relpath(file_path, temp_dir)) 227 | print("changed timeline successfully") 228 | else: 229 | result = content # No replacement if the pattern is not found 230 | print("failed") 231 | 232 | 233 | bpy.ops.ed.undo_push() 234 | bpy.ops.ed.undo() 235 | bpy.ops.ed.undo() 236 | 237 | # Set your desired density percentage (0-100%) 238 | density_percentage = self.density_slider 239 | 240 | # Call the function with the desired time step 241 | bake_camera_animation(density_percentage) 242 | 243 | else: 244 | print("Did not find timeline") 245 | else: 246 | print("Failed to extract .mcpr file") 247 | finally: 248 | if temp_dir: 249 | shutil.rmtree(temp_dir) # Ensure the temp directory is removed 250 | else: 251 | self.report({'WARNING'}, "Invalid.mcpr file selected") 252 | 253 | return {'FINISHED'} 254 | 255 | # Test the operator in the Blender UI 256 | if __name__ == "__main__": 257 | bpy.utils.register_class(OT_TestOpenFilebrowser) 258 | bpy.ops.test.open_filebrowser('INVOKE_DEFAULT') 259 | --------------------------------------------------------------------------------