Coverage for  / opt / hostedtoolcache / Python / 3.12.13 / x64 / lib / python3.12 / site-packages / fcp7xml_to_unreal / models / Episode.py: 95%

227 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-17 22:47 +0000

1import re 

2import xml.etree.ElementTree as ET 

3 

4from fcp7xml_to_unreal import config, logger 

5from fcp7xml_to_unreal.models.Audio import AudioFile 

6from fcp7xml_to_unreal.models.Shot import ConformScene, ConformShot, UnrealShot 

7 

8 

9class Episode: 

10 def __init__(self, xml_file): 

11 self.file = xml_file 

12 self.tree = ET.parse(xml_file) 

13 self.root = self.tree.getroot() 

14 self.track_names = [] 

15 self.audio_files = [] 

16 self.shot_count = 0 

17 self.removed = 0 

18 

19 self.burnin_count = 0 

20 

21 self.start_frame = 100000 

22 self.end_frame = -1 

23 

24 self.cshots = [] 

25 self.sshots = [] 

26 self.fx_shots = [] 

27 

28 self.scenes = [] 

29 self.notes = [] 

30 

31 self.ingest_log = "" 

32 

33 video_tracks = self.root.findall("./sequence/media/video") 

34 for video in video_tracks: 

35 tracks_removed = 0 

36 for track in video.findall("track"): 

37 # remove disabled tracks entirely 

38 if track.find("enabled").text == "FALSE": 

39 tracks_removed += 1 

40 video.remove(track) 

41 continue 

42 

43 self.process_audio() 

44 self.process_video() 

45 

46 def write_filtered(self): 

47 outfile = self.file[:-4] + "_filtered" + ".xml" 

48 try: 

49 self.tree.write(outfile) 

50 except Exception as e: 

51 logger.error(f"Error writing filtered XML: {e}") 

52 logger.info(f"Wrote filtered xml to file: {outfile}") 

53 return 

54 

55 def process_audio(self): 

56 # 

57 # AUDIO 

58 # Clipitems are audio files, and they may have transitionitems connecting them. 

59 # For some reason, FCP XML doesn't write accurate start/end frame counts (it uses -1) 

60 # if there's a transition. That necessitates a 2-pass process over each track. 

61 # 

62 

63 important_tags = ["clipitem", "transitionitem"] 

64 

65 # Currently, remove all audio from the exported XML, but build a report of what was there. 

66 # More logic can be built in here to preserve audio if needed 

67 for audio in self.root.findall("./sequence/media/audio"): 

68 for track in audio.findall("track"): 

69 track_name = "undefined" 

70 if track.attrib is not None: 

71 if "MZ.TrackName" in track.attrib: 

72 track_name = track.attrib["MZ.TrackName"] 

73 self.track_names.append(track_name) 

74 

75 # first pass: store everything in the track in a list 

76 # so that we can find transitions in a second pass. 

77 track_contents = [] 

78 for clipitem in track.findall("./*"): 

79 if clipitem.tag not in important_tags: 

80 continue 

81 track_contents.append(clipitem) 

82 

83 # second pass: go through the clipitems and patch up start/end frames 

84 for index, thing in enumerate(track_contents): 

85 if thing.tag == "transitionitem": 

86 continue 

87 

88 # here we have clipitems only. 

89 name = thing.find("name").text 

90 mcid = thing.find("masterclipid") 

91 masterclipid = mcid.text if mcid is not None else "undefined" 

92 

93 pathurl = thing.find("./file/pathurl") 

94 path = pathurl.text if pathurl is not None else "" 

95 

96 start_frame = int(thing.find("start").text) 

97 end_frame = int(thing.find("end").text) 

98 

99 color = "undefined" 

100 for ls in thing.findall("labels"): 

101 color = ls.find("label2").text 

102 

103 if start_frame == -1: 

104 # our start frame MUST come from a prior transitionitem. 

105 # trying without error-checking because I'm careless 

106 # TODO: Add error checking? 

107 start_frame = int(track_contents[index - 1].find("start").text) 

108 if end_frame == -1: 

109 end_frame = int(track_contents[index + 1].find("end").text) 

110 

111 # find all filters 

112 filters = [] 

113 for effect in thing.findall("./filter/effect/name"): 

114 filters.append(effect.text) 

115 

116 af = AudioFile( 

117 name, 

118 path, 

119 masterclipid, 

120 start_frame, 

121 end_frame, 

122 track_name, 

123 color, 

124 ) 

125 af.effects = filters 

126 

127 found = False 

128 for a in self.audio_files: 

129 if a == af: 

130 found = True 

131 continue 

132 if not found: 

133 self.audio_files.append(af) 

134 

135 audio.remove(track) 

136 

137 def is_movie_file(self, name): 

138 for suffix in config["MOVIE_FILE_SUFFIXES"]: 

139 if name.endswith(suffix): 

140 return True 

141 return False 

142 

143 def is_image_file(self, name): 

144 for suffix in config["IMAGE_FILE_SUFFIXES"]: 

145 if name.endswith(suffix): 

146 return True 

147 return False 

148 

149 def is_conformshot_burnin(self, basename): 

150 if basename.startswith(config["CONFORMSHOT_BURNIN_PREFIX"]): 

151 return True 

152 return False 

153 

154 def is_conformscene_burnin(self, basename): 

155 if basename.startswith(config["CONFORMSCENE_BURNIN_PREFIX"]): 

156 return True 

157 return False 

158 

159 def process_video(self): 

160 for track in self.root.findall("./sequence/media/video/track"): 

161 for clipitem in track.findall("clipitem"): 

162 # remove disabled clips entirely 

163 if clipitem.find("enabled").text == "FALSE": 

164 track.remove(clipitem) 

165 continue 

166 

167 name = clipitem.find("name").text 

168 

169 start = clipitem.find("start").text 

170 end = clipitem.find("end").text 

171 

172 inp = clipitem.find("in").text 

173 outp = clipitem.find("out").text 

174 

175 # Is this clip a movie file? 

176 # If so, check if it's a valid story shot by matching its name against the regex from config 

177 # Uncomment the printout "NOTE" below if you fear something appropriate is being filtered out! 

178 if self.is_movie_file(name): 

179 # TODO: use 'unreal shot' instead of 'story shot' 

180 # TODO: should this regex live with UnrealShot? I think so. 

181 story_shot_pattern = config["shot_name_regex"] 

182 logger.debug( 

183 f"Checking shot {name} against regex {story_shot_pattern}" 

184 ) 

185 valid_story_shot = re.match(story_shot_pattern, name) 

186 if valid_story_shot is None: 

187 # print(f"NOTE: ignoring input clip {name} (it does not match story shot naming conventions)") 

188 track.remove(clipitem) 

189 continue 

190 

191 # Now let's filter the name to make it match UE level sequence naming 

192 # Productions can have any number of filters, just run the list in order. 

193 

194 # TODO: eval() to be removed once yaml config is fixed to not double quote strings 

195 filteredname = name 

196 for resub in config["shot_name_resubs"]: 

197 filteredname = re.sub( 

198 eval(resub["pattern"]), 

199 eval(resub["replacement"]), 

200 filteredname, 

201 ) 

202 

203 # if we changed anything let's rename this 

204 if filteredname != name: 

205 clipitem.find("name").text = filteredname 

206 

207 # the start and end values will be bad if there's a transition. 

208 # It's not clear how to fix this automatically! TODO: would be cool to figure out some logic 

209 # Trying to be smart. If there are transitions on both sides, however, I'm not sure we can be. 

210 if start == "-1": 

211 if end == "-1": 

212 self.ingest_log += f"**** Removing shot {name}. Check for transitions in the cut and remove them, then export XML and run again!\n" 

213 track.remove(clipitem) 

214 continue 

215 

216 start = int(end) - (int(outp) - int(inp)) 

217 self.ingest_log += f"Auto-fixed shot {name} which still has a transition on its start. Check for accuracy!\n" 

218 

219 if end == "-1": 

220 end = int(start) + (int(outp) - int(inp)) 

221 self.ingest_log += f"Auto-fixed shot {name} which still has a transition on its end. Check for accuracy!\n" 

222 

223 # Confirmed this is a story shot, and by here its name has been filtered/cleaned. 

224 # It has valid start/end frames. 

225 # let's create it as a formal shot and add to our list of shots 

226 this_shot = UnrealShot(filteredname, start, end, inp, outp) 

227 self.sshots.append(this_shot) 

228 

229 # check for editing filters that necessitate fixes in 3D 

230 for fx in clipitem.findall("filter/effect"): 

231 fx_type = fx.find("effectid").text 

232 

233 if fx_type == "timeremap": 

234 params = {} 

235 # this is also time reversing? 

236 for param in fx.findall("parameter"): 

237 for param_option in [ 

238 "speed", 

239 "reverse", 

240 "variablespeed", 

241 "graphdict", 

242 ]: 

243 if param.find("parameterid").text == param_option: 

244 if param_option == "graphdict": 

245 keys = "" 

246 for key in param.findall("keyframe"): 

247 when = key.find("when").text 

248 val = key.find("value").text 

249 keys += f"(f{val} @ f{when}) " 

250 params[param_option] = keys 

251 else: 

252 params[param_option] = param.find( 

253 "value" 

254 ).text 

255 if params["reverse"] == "FALSE": 

256 params.pop("reverse") 

257 if params["speed"] == "100": 

258 params.pop("speed") 

259 this_shot.add_fx(fx_type, params) 

260 

261 if fx_type == "basic": 

262 params = {} 

263 # this is scaling/panning 

264 for param in fx.findall("parameter"): 

265 # the most basic xforms an editor might use that need to be matched in UE 

266 for param_option in ["scale", "rotation"]: 

267 if param.find("parameterid").text == param_option: 

268 # check for animation 

269 keys = "" 

270 for key in param.findall("keyframe"): 

271 when = key.find("when").text 

272 val = key.find("value").text 

273 keys += f"(f{val} @ f{when}) " 

274 if keys != "": 

275 params[param_option] = keys 

276 else: 

277 params[param_option] = param.find( 

278 "value" 

279 ).text 

280 

281 # some cleaning; remove no-ops 

282 if "scale" in params and params["scale"] == "100": 

283 params.pop("scale") 

284 if "rotation" in params and params["rotation"] == "0": 

285 params.pop("rotation") 

286 

287 if len(params) > 0: 

288 this_shot.add_fx(fx_type, params) 

289 

290 if len(this_shot.fx.keys()) > 0: 

291 self.fx_shots.append(this_shot) 

292 

293 elif self.is_image_file(name): 

294 from pathlib import Path 

295 

296 ue_asset_name = Path(name).stem 

297 

298 # force timebase for images to match config 

299 for tb in clipitem.findall("rate"): 

300 tb.find("timebase").text = config["frame_rate"] 

301 

302 # here we're looking for burnin files specific to this pipeline (set details in config.yaml) 

303 if self.is_conformshot_burnin(ue_asset_name): 

304 self.cshots.append( 

305 ConformShot(ue_asset_name, start, end, inp, outp) 

306 ) 

307 clipitem.find("name").text = ue_asset_name 

308 

309 elif self.is_conformscene_burnin(ue_asset_name): 

310 self.scenes.append( 

311 ConformScene(ue_asset_name, start, end, inp, outp) 

312 ) 

313 clipitem.find("name").text = ue_asset_name 

314 

315 else: 

316 track.remove(clipitem) 

317 

318 else: 

319 track.remove(clipitem) 

320 

321 self.ingest_log += "\nEpisode XML contains:\n" 

322 self.ingest_log += f"\t{len(self.sshots)} unreal shots,\n" 

323 self.ingest_log += f"\t{len(self.cshots)} conform shots,\n" 

324 self.ingest_log += f"\t{len(self.scenes)} conform scenes,\n" 

325 self.ingest_log += f"\t{len(self.audio_files)} unique audio files.\n\n" 

326 

327 minF = 1000000 

328 maxF = -1 

329 for cshot in self.cshots: 

330 minF = min(minF, cshot.ef) 

331 maxF = max(maxF, cshot.ef) 

332 

333 # map cshots to their scenes 

334 for cshot in self.cshots: 

335 # look for all possible scene matches for this shot 

336 possible_scenes = [] 

337 for scene in self.scenes: 

338 if scene.contains(cshot): 

339 possible_scenes.append(scene) 

340 

341 # Now choose one, if there is one 

342 if len(possible_scenes) == 0: 

343 self.ingest_log += ( 

344 f"Burnin {cshot.rawname} is not contained in any scene!\n" 

345 ) 

346 continue 

347 

348 scene_to_assign = None 

349 

350 if len(possible_scenes) == 1: 

351 # this is the easy case 

352 scene_to_assign = possible_scenes[0] 

353 

354 elif len(possible_scenes) > 1: 

355 # This shot fits into multiple scenes. But we need to pick one. 

356 possible_scenes.sort() 

357 

358 if cshot.is_first_shot(): 

359 # print("shot numbered 1, so probably use the last scene") 

360 scene_to_assign = possible_scenes[-1] 

361 else: 

362 # print("based on shot number {shotnum:d}, using the first scene") 

363 scene_to_assign = possible_scenes[0] 

364 # logger.warning( 

365 # f"Placed shot {cshot.name} in {scene_to_assign.name} but it matched {len(possible_scenes):d} scenes." 

366 # ) 

367 

368 cshot.container = scene_to_assign 

369 

370 # Warn about unmapped story shots 

371 for sshot in self.sshots: 

372 in_scene = False 

373 for scene in self.scenes: 

374 if scene.contains(sshot): 

375 in_scene = True 

376 continue 

377 

378 if not in_scene: 

379 self.ingest_log += f"Shot {sshot.name()} is not in any scene\n"