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