|
18 | 18 | import logging |
19 | 19 | import typing as ty |
20 | 20 | import webbrowser |
| 21 | +from datetime import datetime |
| 22 | +from pathlib import Path |
21 | 23 | from string import Template |
| 24 | +from xml.dom import minidom |
| 25 | +from xml.etree import ElementTree |
22 | 26 |
|
| 27 | +from scenedetect._cli.config import XmlFormat |
23 | 28 | from scenedetect._cli.context import CliContext |
24 | 29 | from scenedetect.platform import get_and_create_path |
25 | 30 | from scenedetect.scene_manager import ( |
@@ -248,3 +253,175 @@ def split_video( |
248 | 253 | ) |
249 | 254 | if scenes: |
250 | 255 | logger.info("Video splitting completed, scenes written to disk.") |
| 256 | + |
| 257 | + |
| 258 | +def _save_xml_fcpx( |
| 259 | + context: CliContext, |
| 260 | + scenes: SceneList, |
| 261 | + filename: str, |
| 262 | + output: str, |
| 263 | +): |
| 264 | + """Saves scenes in Final Cut Pro X XML format.""" |
| 265 | + ASSET_ID = "asset1" |
| 266 | + FORMAT_ID = "format1" |
| 267 | + # TODO: Need to handle other video formats! |
| 268 | + VIDEO_FORMAT_TODO_HANDLE_OTHERS = "FFVideoFormat1080p24" |
| 269 | + |
| 270 | + root = ElementTree.Element("fcpxml", version="1.9") |
| 271 | + resources = ElementTree.SubElement(root, "resources") |
| 272 | + ElementTree.SubElement(resources, "format", id="format1", name=VIDEO_FORMAT_TODO_HANDLE_OTHERS) |
| 273 | + |
| 274 | + video_name = context.video_stream.name |
| 275 | + |
| 276 | + # TODO: We should calculate duration from the scene list. |
| 277 | + duration = context.video_stream.duration |
| 278 | + duration = str(duration.get_seconds()) + "s" # TODO: Is float okay here? |
| 279 | + # TODO: This should be an absolute path, but the path types between VideoStream impls aren't |
| 280 | + # consistent. Need to make a breaking change to the API so that they return pathlib.Path types. |
| 281 | + path = context.video_stream.path |
| 282 | + ElementTree.SubElement( |
| 283 | + resources, |
| 284 | + "asset", |
| 285 | + id=ASSET_ID, |
| 286 | + name=video_name, |
| 287 | + src=path, |
| 288 | + duration=duration, |
| 289 | + hasVideo="1", |
| 290 | + hasAudio="1", # TODO: Handle case of no audio. |
| 291 | + format=FORMAT_ID, |
| 292 | + ) |
| 293 | + |
| 294 | + library = ElementTree.SubElement(root, "library") |
| 295 | + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| 296 | + event = ElementTree.SubElement(library, "event", name=f"Shot Detection {now}") |
| 297 | + project = ElementTree.SubElement( |
| 298 | + event, "project", name=video_name |
| 299 | + ) # TODO: Allow customizing project name. |
| 300 | + sequence = ElementTree.SubElement(project, "sequence", format=FORMAT_ID, duration=duration) |
| 301 | + spine = ElementTree.SubElement(sequence, "spine") |
| 302 | + |
| 303 | + for i, (start, end) in enumerate(scenes): |
| 304 | + start_seconds = start.get_seconds() |
| 305 | + duration_seconds = (end - start).get_seconds() |
| 306 | + clip = ElementTree.SubElement( |
| 307 | + spine, |
| 308 | + "clip", |
| 309 | + name=f"Shot {i + 1}", |
| 310 | + duration=f"{duration_seconds:.3f}s", |
| 311 | + start=f"{start_seconds:.3f}s", |
| 312 | + offset=f"{start_seconds:.3f}s", |
| 313 | + ) |
| 314 | + ElementTree.SubElement( |
| 315 | + clip, |
| 316 | + "asset-clip", |
| 317 | + ref=ASSET_ID, |
| 318 | + duration=f"{duration_seconds:.3f}s", |
| 319 | + start=f"{start_seconds:.3f}s", |
| 320 | + offset="0s", |
| 321 | + name=f"Shot {i + 1}", |
| 322 | + ) |
| 323 | + |
| 324 | + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( |
| 325 | + indent=" " |
| 326 | + ) |
| 327 | + xml_path = get_and_create_path( |
| 328 | + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), |
| 329 | + output, |
| 330 | + ) |
| 331 | + logger.info(f"Writing scenes in FCPX format to {xml_path}") |
| 332 | + with open(xml_path, "w") as f: |
| 333 | + f.write(pretty_xml) |
| 334 | + |
| 335 | + |
| 336 | +def _save_xml_fcp( |
| 337 | + context: CliContext, |
| 338 | + scenes: SceneList, |
| 339 | + filename: str, |
| 340 | + output: str, |
| 341 | +): |
| 342 | + """Saves scenes in Final Cut Pro 7 XML format.""" |
| 343 | + root = ElementTree.Element("xmeml", version="5") |
| 344 | + project = ElementTree.SubElement(root, "project") |
| 345 | + ElementTree.SubElement(project, "name").text = context.video_stream.name |
| 346 | + sequence = ElementTree.SubElement(project, "sequence") |
| 347 | + ElementTree.SubElement(sequence, "name").text = context.video_stream.name |
| 348 | + |
| 349 | + # TODO: We should calculate duration from the scene list. |
| 350 | + duration = context.video_stream.duration |
| 351 | + duration = str(duration.get_seconds()) # TODO: Is float okay here? |
| 352 | + ElementTree.SubElement(sequence, "duration").text = duration |
| 353 | + |
| 354 | + rate = ElementTree.SubElement(sequence, "rate") |
| 355 | + ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate) |
| 356 | + ElementTree.SubElement(rate, "ntsc").text = "FALSE" |
| 357 | + |
| 358 | + timecode = ElementTree.SubElement(sequence, "timecode") |
| 359 | + tc_rate = ElementTree.SubElement(timecode, "rate") |
| 360 | + ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate) |
| 361 | + ElementTree.SubElement(tc_rate, "ntsc").text = "FALSE" |
| 362 | + ElementTree.SubElement(timecode, "frame").text = "0" |
| 363 | + ElementTree.SubElement(timecode, "displayformat").text = "NDF" |
| 364 | + |
| 365 | + media = ElementTree.SubElement(sequence, "media") |
| 366 | + video = ElementTree.SubElement(media, "video") |
| 367 | + format = ElementTree.SubElement(video, "format") |
| 368 | + ElementTree.SubElement(format, "samplecharacteristics") |
| 369 | + track = ElementTree.SubElement(video, "track") |
| 370 | + |
| 371 | + # Add clips for each shot boundary |
| 372 | + for i, (start, end) in enumerate(scenes): |
| 373 | + clip = ElementTree.SubElement(track, "clipitem") |
| 374 | + ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}" |
| 375 | + ElementTree.SubElement(clip, "enabled").text = "TRUE" |
| 376 | + ElementTree.SubElement(clip, "rate").append( |
| 377 | + ElementTree.fromstring(f"<timebase>{context.video_stream.frame_rate}</timebase>") |
| 378 | + ) |
| 379 | + # TODO: Are these supposed to be frame numbers or another format? |
| 380 | + ElementTree.SubElement(clip, "start").text = str(start.get_frames()) |
| 381 | + ElementTree.SubElement(clip, "end").text = str(end.get_frames()) |
| 382 | + ElementTree.SubElement(clip, "in").text = str(start.get_frames()) |
| 383 | + ElementTree.SubElement(clip, "out").text = str(end.get_frames()) |
| 384 | + |
| 385 | + file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}") |
| 386 | + ElementTree.SubElement(file_ref, "name").text = context.video_stream.name |
| 387 | + # TODO: Turn this into absolute path, see TODO in the FCPX function for details. |
| 388 | + path = context.video_stream.path |
| 389 | + ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}" |
| 390 | + |
| 391 | + media_ref = ElementTree.SubElement(file_ref, "media") |
| 392 | + video_ref = ElementTree.SubElement(media_ref, "video") |
| 393 | + ElementTree.SubElement(video_ref, "samplecharacteristics") |
| 394 | + link = ElementTree.SubElement(clip, "link") |
| 395 | + ElementTree.SubElement(link, "linkclipref").text = f"file{i + 1}" |
| 396 | + ElementTree.SubElement(link, "mediatype").text = "video" |
| 397 | + |
| 398 | + pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml( |
| 399 | + indent=" " |
| 400 | + ) |
| 401 | + xml_path = get_and_create_path( |
| 402 | + Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name), |
| 403 | + output, |
| 404 | + ) |
| 405 | + logger.info(f"Writing scenes in FCP format to {xml_path}") |
| 406 | + with open(xml_path, "w") as f: |
| 407 | + f.write(pretty_xml) |
| 408 | + |
| 409 | + |
| 410 | +def save_xml( |
| 411 | + context: CliContext, |
| 412 | + scenes: SceneList, |
| 413 | + cuts: CutList, |
| 414 | + filename: str, |
| 415 | + format: XmlFormat, |
| 416 | + output: str, |
| 417 | +): |
| 418 | + """Handles the `save-xml` command.""" |
| 419 | + # We only use scene information. |
| 420 | + del cuts |
| 421 | + |
| 422 | + if format == XmlFormat.FCPX: |
| 423 | + _save_xml_fcpx(context, scenes, filename, output) |
| 424 | + elif format == XmlFormat.FCP: |
| 425 | + _save_xml_fcp(context, scenes, filename, output) |
| 426 | + else: |
| 427 | + logger.error(f"Unknown format: {format}") |
0 commit comments