| 1 | """Narration audio discovery and PPTX XML helpers.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import base64 |
| 6 | import json |
| 7 | import re |
| 8 | import subprocess |
| 9 | from collections.abc import Iterable |
| 10 | from pathlib import Path |
| 11 | from xml.etree import ElementTree as ET |
| 12 | |
| 13 | from pptx_transitions import ( |
| 14 | AdvanceUpdate, |
| 15 | EnterUpdate, |
| 16 | MAX_OOXML_UNSIGNED_INT, |
| 17 | P14_NS, |
| 18 | PML_NS, |
| 19 | apply_slide_motion_xml, |
| 20 | parse_source_xml, |
| 21 | read_slide_transition_xml, |
| 22 | serialize_source_xml, |
| 23 | ) |
| 24 | |
| 25 | |
| 26 | DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 27 | RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" |
| 28 | MARKUP_COMPATIBILITY_NS = ( |
| 29 | "http://schemas.openxmlformats.org/markup-compatibility/2006" |
| 30 | ) |
| 31 | |
| 32 | MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media" |
| 33 | AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio" |
| 34 | IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" |
| 35 | |
| 36 | AUDIO_CONTENT_TYPES = { |
| 37 | ".m4a": "audio/mp4", |
| 38 | ".mp3": "audio/mpeg", |
| 39 | ".wav": "audio/wav", |
| 40 | } |
| 41 | |
| 42 | NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys()) |
| 43 | |
| 44 | AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px |
| 45 | AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU |
| 46 | AUDIO_MARKER_PNG_BYTES = base64.b64decode( |
| 47 | "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG" |
| 48 | "cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu" |
| 49 | "2ZRCLKzIQBdqnXDPsL+dA+8Qx88UJpAHPHTfJsRY4Jmo1yBUoCZ8Sj2SgNHxK3KiBWv" |
| 50 | "DU+EEDteGtQhxeA63AW/l4WMuolNa5zx4DNaCd/XXuWfAY15sBTXguOJTL9501GlG50" |
| 51 | "Ovc/xpjzDjtP+5nqj0gkXEL7763OhmQmj4S4FZY1iGnaAUkwQMxTlWouoz65CYBq4LV" |
| 52 | "0cTU6VMgw8frOO3e6273x3Nbhq7JCmCCVYDTqDkDMOuUajdnIFWXgdq6DEgLNm5oGbV" |
| 53 | "qzoBPcOmES+qxkW3L0FE2s1BWJDa5cdqjm5gxf7ddmqyAC4+dQq1EDYzTTpq3mTFO56" |
| 54 | "KTAam7xpJGsOxDTtUpJGEEZhw7lRb5SWlNcJs8dJx+q4DkwwcEJLsiLh86hTRNGMM3g" |
| 55 | "nFVXUYlGt1rQLsKqfLxBCvQiokQR3QK1TYRi0/qgVomKHHJTVzaBDUeC0rzBnBqoljL" |
| 56 | "qFY1OOP+3yf1PpX+r8TH6wW14c3/7xdFRAAAAABJRU5ErkJggg==" |
| 57 | ) |
| 58 | |
| 59 | |
| 60 | for _prefix, _uri in ( |
| 61 | ("p", PML_NS), |
| 62 | ("a", DRAWINGML_NS), |
| 63 | ("r", RELATIONSHIPS_NS), |
| 64 | ("p14", P14_NS), |
| 65 | ): |
| 66 | try: |
| 67 | ET.register_namespace(_prefix, _uri) |
| 68 | except (AttributeError, ValueError): |
| 69 | pass |
| 70 | |
| 71 | |
| 72 | def _qn(namespace: str, tag: str) -> str: |
| 73 | return f"{{{namespace}}}{tag}" |
| 74 | |
| 75 | |
| 76 | def _normalize_title(title: str) -> str: |
| 77 | text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip()) |
| 78 | return re.sub(r"_+", "_", text).strip("_").lower() |
| 79 | |
| 80 | |
| 81 | def _leading_number(text: str) -> int | None: |
| 82 | match = re.match(r"^(\d{1,3})", text.strip()) |
| 83 | return int(match.group(1)) if match else None |
| 84 | |
| 85 | |
| 86 | def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Path]: |
| 87 | """Return `{svg_stem: audio_path}` matched by exact stem, normalized stem, or index.""" |
| 88 | if not audio_dir.exists() or not audio_dir.is_dir(): |
| 89 | return {} |
| 90 | |
| 91 | audio_files = [ |
| 92 | path for path in sorted(audio_dir.iterdir()) |
| 93 | if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS |
| 94 | ] |
| 95 | exact: dict[str, list[Path]] = {} |
| 96 | normalized: dict[str, list[Path]] = {} |
| 97 | numbered: dict[int, list[Path]] = {} |
| 98 | for path in audio_files: |
| 99 | exact.setdefault(path.stem, []).append(path) |
| 100 | normalized.setdefault(_normalize_title(path.stem), []).append(path) |
| 101 | number = _leading_number(path.stem) |
| 102 | if number is not None: |
| 103 | numbered.setdefault(number, []).append(path) |
| 104 | |
| 105 | matched: dict[str, Path] = {} |
| 106 | claimed_by: dict[Path, str] = {} |
| 107 | for index, svg in enumerate(svg_files, 1): |
| 108 | stem = svg.stem |
| 109 | candidates = exact.get(stem) |
| 110 | if not candidates: |
| 111 | candidates = normalized.get(_normalize_title(stem)) |
| 112 | if not candidates: |
| 113 | candidates = numbered.get(index) |
| 114 | if not candidates: |
| 115 | continue |
| 116 | if len(candidates) > 1: |
| 117 | names = ", ".join(path.name for path in candidates) |
| 118 | raise ValueError( |
| 119 | f"multiple narration audio files match slide {stem!r}: " |
| 120 | f"{names}; keep exactly one supported file for this slide" |
| 121 | ) |
| 122 | candidate = candidates[0] |
| 123 | previous_stem = claimed_by.get(candidate) |
| 124 | if previous_stem is not None: |
| 125 | raise ValueError( |
| 126 | f"narration audio file {candidate.name!r} matches multiple slides: " |
| 127 | f"{previous_stem!r}, {stem!r}; provide one distinct audio file " |
| 128 | "per slide" |
| 129 | ) |
| 130 | matched[stem] = candidate |
| 131 | claimed_by[candidate] = stem |
| 132 | return matched |
| 133 | |
| 134 | |
| 135 | def probe_audio_duration(audio_path: Path) -> float | None: |
| 136 | """Return duration in seconds using ffprobe when available.""" |
| 137 | try: |
| 138 | result = subprocess.run( |
| 139 | [ |
| 140 | "ffprobe", "-v", "error", |
| 141 | "-show_entries", "format=duration", |
| 142 | "-of", "json", |
| 143 | str(audio_path), |
| 144 | ], |
| 145 | check=True, |
| 146 | capture_output=True, |
| 147 | text=True, |
| 148 | encoding="utf-8", |
| 149 | ) |
| 150 | data = json.loads(result.stdout or "{}") |
| 151 | duration = float(data.get("format", {}).get("duration", 0)) |
| 152 | return duration if duration > 0 else None |
| 153 | except Exception: |
| 154 | return None |
| 155 | |
| 156 | |
| 157 | def next_shape_id(slide_xml: str) -> int: |
| 158 | """Return the next slide-local non-visual shape id.""" |
| 159 | root = parse_source_xml(slide_xml) |
| 160 | if root.tag != _qn(PML_NS, "sld"): |
| 161 | raise ValueError("narration source XML root must be p:sld") |
| 162 | ids = _numeric_ids( |
| 163 | root.iter(_qn(PML_NS, "cNvPr")), |
| 164 | "shape", |
| 165 | minimum=1, |
| 166 | ) |
| 167 | next_id = max(ids, default=1) + 1 |
| 168 | if next_id > MAX_OOXML_UNSIGNED_INT: |
| 169 | raise ValueError("narration source has no available shape identifiers") |
| 170 | return next_id |
| 171 | |
| 172 | |
| 173 | def _create_audio_pic_element( |
| 174 | shape_id: int, |
| 175 | shape_name: str, |
| 176 | audio_rid: str, |
| 177 | media_rid: str, |
| 178 | poster_rid: str, |
| 179 | ) -> ET.Element: |
| 180 | pic = ET.Element(_qn(PML_NS, "pic")) |
| 181 | nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr")) |
| 182 | c_nv_pr = ET.SubElement( |
| 183 | nv_pic_pr, |
| 184 | _qn(PML_NS, "cNvPr"), |
| 185 | {"id": str(shape_id), "name": shape_name}, |
| 186 | ) |
| 187 | ET.SubElement( |
| 188 | c_nv_pr, |
| 189 | _qn(DRAWINGML_NS, "hlinkClick"), |
| 190 | { |
| 191 | _qn(RELATIONSHIPS_NS, "id"): "", |
| 192 | "action": "ppaction://media", |
| 193 | }, |
| 194 | ) |
| 195 | c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr")) |
| 196 | ET.SubElement( |
| 197 | c_nv_pic_pr, |
| 198 | _qn(DRAWINGML_NS, "picLocks"), |
| 199 | {"noChangeAspect": "1"}, |
| 200 | ) |
| 201 | nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr")) |
| 202 | ET.SubElement( |
| 203 | nv_pr, |
| 204 | _qn(DRAWINGML_NS, "audioFile"), |
| 205 | {_qn(RELATIONSHIPS_NS, "link"): audio_rid}, |
| 206 | ) |
| 207 | ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst")) |
| 208 | extension = ET.SubElement( |
| 209 | ext_list, |
| 210 | _qn(PML_NS, "ext"), |
| 211 | {"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"}, |
| 212 | ) |
| 213 | ET.SubElement( |
| 214 | extension, |
| 215 | _qn(P14_NS, "media"), |
| 216 | {_qn(RELATIONSHIPS_NS, "embed"): media_rid}, |
| 217 | ) |
| 218 | |
| 219 | blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill")) |
| 220 | ET.SubElement( |
| 221 | blip_fill, |
| 222 | _qn(DRAWINGML_NS, "blip"), |
| 223 | {_qn(RELATIONSHIPS_NS, "embed"): poster_rid}, |
| 224 | ) |
| 225 | stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch")) |
| 226 | ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect")) |
| 227 | |
| 228 | shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr")) |
| 229 | transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm")) |
| 230 | ET.SubElement( |
| 231 | transform, |
| 232 | _qn(DRAWINGML_NS, "off"), |
| 233 | { |
| 234 | "x": str(AUDIO_MARKER_OFF_CANVAS_EMU), |
| 235 | "y": str(AUDIO_MARKER_OFF_CANVAS_EMU), |
| 236 | }, |
| 237 | ) |
| 238 | ET.SubElement( |
| 239 | transform, |
| 240 | _qn(DRAWINGML_NS, "ext"), |
| 241 | { |
| 242 | "cx": str(AUDIO_MARKER_SIZE_EMU), |
| 243 | "cy": str(AUDIO_MARKER_SIZE_EMU), |
| 244 | }, |
| 245 | ) |
| 246 | geometry = ET.SubElement( |
| 247 | shape_properties, |
| 248 | _qn(DRAWINGML_NS, "prstGeom"), |
| 249 | {"prst": "rect"}, |
| 250 | ) |
| 251 | ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst")) |
| 252 | return pic |
| 253 | |
| 254 | |
| 255 | def create_audio_pic_xml( |
| 256 | shape_id: int, |
| 257 | shape_name: str, |
| 258 | audio_rid: str, |
| 259 | media_rid: str, |
| 260 | poster_rid: str, |
| 261 | ) -> str: |
| 262 | """Create an off-canvas audio picture shape carrying narration media.""" |
| 263 | element = _create_audio_pic_element( |
| 264 | shape_id, |
| 265 | shape_name, |
| 266 | audio_rid, |
| 267 | media_rid, |
| 268 | poster_rid, |
| 269 | ) |
| 270 | return ET.tostring(element, encoding="unicode") |
| 271 | |
| 272 | |
| 273 | def _numeric_ids( |
| 274 | elements: Iterable[ET.Element], |
| 275 | label: str, |
| 276 | *, |
| 277 | minimum: int = 0, |
| 278 | maximum: int = MAX_OOXML_UNSIGNED_INT, |
| 279 | ) -> list[int]: |
| 280 | ids: list[int] = [] |
| 281 | seen: set[int] = set() |
| 282 | for element in elements: |
| 283 | raw_id = element.get("id") |
| 284 | try: |
| 285 | numeric_id = int(raw_id) |
| 286 | except (TypeError, ValueError) as exc: |
| 287 | raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc |
| 288 | if numeric_id < minimum: |
| 289 | raise ValueError( |
| 290 | f"narration source has {label} id below {minimum}: {numeric_id}" |
| 291 | ) |
| 292 | if numeric_id > maximum: |
| 293 | raise ValueError( |
| 294 | f"narration source has {label} id above {maximum}: {numeric_id}" |
| 295 | ) |
| 296 | if numeric_id in seen: |
| 297 | raise ValueError(f"narration source has duplicate {label} id: {numeric_id}") |
| 298 | ids.append(numeric_id) |
| 299 | seen.add(numeric_id) |
| 300 | return ids |
| 301 | |
| 302 | |
| 303 | def _create_audio_timing_element(shape_id: int, ctn_id: int) -> ET.Element: |
| 304 | audio = ET.Element(_qn(PML_NS, "audio")) |
| 305 | media_node = ET.SubElement( |
| 306 | audio, |
| 307 | _qn(PML_NS, "cMediaNode"), |
| 308 | {"vol": "80000"}, |
| 309 | ) |
| 310 | time_node = ET.SubElement( |
| 311 | media_node, |
| 312 | _qn(PML_NS, "cTn"), |
| 313 | {"id": str(ctn_id), "fill": "hold", "display": "0"}, |
| 314 | ) |
| 315 | start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst")) |
| 316 | ET.SubElement(start_conditions, _qn(PML_NS, "cond"), {"delay": "0"}) |
| 317 | target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl")) |
| 318 | ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)}) |
| 319 | return audio |
| 320 | |
| 321 | |
| 322 | def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element: |
| 323 | children = [child for child in parent if child.tag == tag] |
| 324 | if len(children) != 1: |
| 325 | raise ValueError( |
| 326 | f"narration source must contain exactly one direct {label}; found {len(children)}" |
| 327 | ) |
| 328 | return children[0] |
| 329 | |
| 330 | |
| 331 | def _existing_timing_root(timing: ET.Element) -> ET.Element: |
| 332 | children = list(timing) |
| 333 | for tag, label in ( |
| 334 | (_qn(PML_NS, "tnLst"), "p:tnLst"), |
| 335 | (_qn(PML_NS, "bldLst"), "p:bldLst"), |
| 336 | (_qn(PML_NS, "extLst"), "p:extLst"), |
| 337 | ): |
| 338 | if sum(child.tag == tag for child in children) > 1: |
| 339 | raise ValueError(f"narration source timing has multiple {label} elements") |
| 340 | node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst") |
| 341 | node_index = children.index(node_list) |
| 342 | for tag, label in ( |
| 343 | (_qn(PML_NS, "bldLst"), "p:bldLst"), |
| 344 | (_qn(PML_NS, "extLst"), "p:extLst"), |
| 345 | ): |
| 346 | sibling = next((child for child in children if child.tag == tag), None) |
| 347 | if sibling is not None and node_index > children.index(sibling): |
| 348 | raise ValueError(f"narration source p:tnLst must precede {label}") |
| 349 | timing_roots = [ |
| 350 | element |
| 351 | for element in node_list.iter(_qn(PML_NS, "cTn")) |
| 352 | if element.get("nodeType") == "tmRoot" |
| 353 | ] |
| 354 | if len(timing_roots) != 1: |
| 355 | raise ValueError( |
| 356 | "narration source timing must contain exactly one tmRoot; " |
| 357 | f"found {len(timing_roots)}" |
| 358 | ) |
| 359 | return timing_roots[0] |
| 360 | |
| 361 | |
| 362 | def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element: |
| 363 | timing = ET.Element(_qn(PML_NS, "timing")) |
| 364 | node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst")) |
| 365 | parallel = ET.SubElement(node_list, _qn(PML_NS, "par")) |
| 366 | timing_root = ET.SubElement( |
| 367 | parallel, |
| 368 | _qn(PML_NS, "cTn"), |
| 369 | { |
| 370 | "id": str(root_id), |
| 371 | "dur": "indefinite", |
| 372 | "restart": "never", |
| 373 | "nodeType": "tmRoot", |
| 374 | }, |
| 375 | ) |
| 376 | child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst")) |
| 377 | child_nodes.append(audio_timing) |
| 378 | return timing |
| 379 | |
| 380 | |
| 381 | def _root_extension_index(slide: ET.Element) -> int | None: |
| 382 | extension_lists = [ |
| 383 | index |
| 384 | for index, child in enumerate(slide) |
| 385 | if child.tag == _qn(PML_NS, "extLst") |
| 386 | ] |
| 387 | if len(extension_lists) > 1: |
| 388 | raise ValueError("narration source has multiple root p:extLst elements") |
| 389 | if extension_lists and extension_lists[0] != len(slide) - 1: |
| 390 | raise ValueError("narration source root p:extLst is not the last slide child") |
| 391 | return extension_lists[0] if extension_lists else None |
| 392 | |
| 393 | |
| 394 | def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None: |
| 395 | children = list(slide) |
| 396 | timing_index = children.index(timing) |
| 397 | for tag, label in ( |
| 398 | (_qn(PML_NS, "cSld"), "p:cSld"), |
| 399 | (_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"), |
| 400 | ): |
| 401 | siblings = [index for index, child in enumerate(children) if child.tag == tag] |
| 402 | if len(siblings) > 1: |
| 403 | raise ValueError(f"narration source has multiple root {label} elements") |
| 404 | if siblings and siblings[0] > timing_index: |
| 405 | raise ValueError(f"narration source root p:timing must follow {label}") |
| 406 | extension_index = _root_extension_index(slide) |
| 407 | if extension_index is not None and timing_index > extension_index: |
| 408 | raise ValueError("narration source root p:timing must precede p:extLst") |
| 409 | |
| 410 | |
| 411 | def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None: |
| 412 | extension_index = _root_extension_index(slide) |
| 413 | insert_at = extension_index if extension_index is not None else len(slide) |
| 414 | slide.insert(insert_at, timing) |
| 415 | |
| 416 | |
| 417 | def _animation_timing_branches( |
| 418 | slide: ET.Element, |
| 419 | ) -> tuple[ET.Element | None, list[ET.Element]]: |
| 420 | """Return the root timing anchor and every active/fallback timing branch.""" |
| 421 | direct = [ |
| 422 | child for child in slide |
| 423 | if child.tag == _qn(PML_NS, "timing") |
| 424 | ] |
| 425 | alternates: list[tuple[ET.Element, list[ET.Element]]] = [] |
| 426 | for child in slide: |
| 427 | if child.tag != _qn(MARKUP_COMPATIBILITY_NS, "AlternateContent"): |
| 428 | continue |
| 429 | timings = [ |
| 430 | timing |
| 431 | for branch in list(child) |
| 432 | for timing in list(branch) |
| 433 | if timing.tag == _qn(PML_NS, "timing") |
| 434 | ] |
| 435 | if timings: |
| 436 | alternates.append((child, timings)) |
| 437 | if direct and alternates: |
| 438 | raise ValueError( |
| 439 | "narration source contains both direct and AlternateContent timing" |
| 440 | ) |
| 441 | if len(direct) > 1 or len(alternates) > 1: |
| 442 | raise ValueError("narration source has multiple root animation timings") |
| 443 | if direct: |
| 444 | return direct[0], direct |
| 445 | if alternates: |
| 446 | anchor, timings = alternates[0] |
| 447 | if len(timings) != 2: |
| 448 | raise ValueError( |
| 449 | "narration source animation AlternateContent must contain " |
| 450 | "one Choice and one Fallback timing" |
| 451 | ) |
| 452 | return anchor, timings |
| 453 | nested = list(slide.iter(_qn(PML_NS, "timing"))) |
| 454 | if nested: |
| 455 | raise ValueError( |
| 456 | "narration source contains unsupported non-root p:timing" |
| 457 | ) |
| 458 | return None, [] |
| 459 | |
| 460 | |
| 461 | def inject_narration( |
| 462 | slide_xml: str, |
| 463 | *, |
| 464 | shape_id: int, |
| 465 | shape_name: str, |
| 466 | audio_rid: str, |
| 467 | media_rid: str, |
| 468 | poster_rid: str, |
| 469 | ) -> str: |
| 470 | """Inject a hidden narration media shape and slide-entry autoplay timing.""" |
| 471 | if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0: |
| 472 | raise ValueError("narration shape_id must be a positive integer") |
| 473 | if shape_id > MAX_OOXML_UNSIGNED_INT: |
| 474 | raise ValueError( |
| 475 | "narration shape_id exceeds the OOXML unsigned-integer limit: " |
| 476 | f"{shape_id}" |
| 477 | ) |
| 478 | |
| 479 | root = parse_source_xml(slide_xml) |
| 480 | if root.tag != _qn(PML_NS, "sld"): |
| 481 | raise ValueError("narration source XML root must be p:sld") |
| 482 | common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld") |
| 483 | shape_tree = _direct_child( |
| 484 | common_slide_data, |
| 485 | _qn(PML_NS, "spTree"), |
| 486 | "p:cSld/p:spTree", |
| 487 | ) |
| 488 | |
| 489 | shape_ids = _numeric_ids( |
| 490 | root.iter(_qn(PML_NS, "cNvPr")), |
| 491 | "shape", |
| 492 | minimum=1, |
| 493 | ) |
| 494 | if shape_id in shape_ids: |
| 495 | raise ValueError(f"narration shape id already exists on slide: {shape_id}") |
| 496 | timing_anchor, timing_branches = _animation_timing_branches(root) |
| 497 | timing_id_sets = [ |
| 498 | _numeric_ids(timing.iter(_qn(PML_NS, "cTn")), "timing node") |
| 499 | for timing in timing_branches |
| 500 | ] |
| 501 | timing_ids = [ |
| 502 | timing_id |
| 503 | for timing_set in timing_id_sets |
| 504 | for timing_id in timing_set |
| 505 | ] |
| 506 | next_timing_id = max(timing_ids, default=0) + 1 |
| 507 | if next_timing_id > MAX_OOXML_UNSIGNED_INT: |
| 508 | raise ValueError("narration source has no available timing node identifiers") |
| 509 | |
| 510 | if not timing_branches and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT: |
| 511 | raise ValueError( |
| 512 | "narration source has no identifiers available for a new timing root" |
| 513 | ) |
| 514 | |
| 515 | audio_picture = _create_audio_pic_element( |
| 516 | shape_id, |
| 517 | shape_name, |
| 518 | audio_rid, |
| 519 | media_rid, |
| 520 | poster_rid, |
| 521 | ) |
| 522 | shape_tree.append(audio_picture) |
| 523 | |
| 524 | if timing_branches: |
| 525 | if timing_anchor is None: |
| 526 | raise AssertionError("timing branches lost their root anchor") |
| 527 | _validate_root_timing_position(root, timing_anchor) |
| 528 | for timing in timing_branches: |
| 529 | timing_root = _existing_timing_root(timing) |
| 530 | child_nodes = _direct_child( |
| 531 | timing_root, |
| 532 | _qn(PML_NS, "childTnLst"), |
| 533 | "tmRoot/p:childTnLst", |
| 534 | ) |
| 535 | child_nodes.append( |
| 536 | _create_audio_timing_element(shape_id, next_timing_id) |
| 537 | ) |
| 538 | else: |
| 539 | audio_timing = _create_audio_timing_element(shape_id, next_timing_id + 1) |
| 540 | _insert_root_timing(root, _new_timing(audio_timing, next_timing_id)) |
| 541 | |
| 542 | return serialize_source_xml(root, slide_xml).decode("utf-8") |
| 543 | |
| 544 | |
| 545 | def apply_recorded_timing( |
| 546 | slide_xml: str, |
| 547 | *, |
| 548 | advance_after: float, |
| 549 | transition_duration: float, |
| 550 | transition_effect: str | None = "fade", |
| 551 | ) -> str: |
| 552 | """Set slide auto-advance timing so exported video follows narration length.""" |
| 553 | summary = read_slide_transition_xml(slide_xml) |
| 554 | if summary.logical_count: |
| 555 | enter = EnterUpdate(policy="preserve") |
| 556 | elif transition_effect is None or transition_effect == "none": |
| 557 | enter = EnterUpdate(policy="none") |
| 558 | else: |
| 559 | enter = EnterUpdate( |
| 560 | policy="replace", |
| 561 | effect=transition_effect, |
| 562 | duration=transition_duration, |
| 563 | ) |
| 564 | updated, _uses_timings = apply_slide_motion_xml( |
| 565 | slide_xml, |
| 566 | enter=enter, |
| 567 | advance=AdvanceUpdate(mode="narration", after=advance_after), |
| 568 | ) |
| 569 | return updated |
| 570 |