697 lines
23 KiB
Python
697 lines
23 KiB
Python
"""Narration audio discovery and PPTX XML helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from pptx_transitions import (
|
|
AdvanceUpdate,
|
|
EnterUpdate,
|
|
MAX_OOXML_UNSIGNED_INT,
|
|
P14_NS,
|
|
PML_NS,
|
|
apply_slide_motion_xml,
|
|
parse_source_xml,
|
|
read_slide_transition_xml,
|
|
serialize_source_xml,
|
|
validate_seconds,
|
|
)
|
|
|
|
|
|
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
MARKUP_COMPATIBILITY_NS = (
|
|
"http://schemas.openxmlformats.org/markup-compatibility/2006"
|
|
)
|
|
|
|
MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media"
|
|
AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
|
|
IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
|
|
|
|
AUDIO_CONTENT_TYPES = {
|
|
".m4a": "audio/mp4",
|
|
".mp3": "audio/mpeg",
|
|
".wav": "audio/wav",
|
|
}
|
|
|
|
NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys())
|
|
DEFAULT_NARRATION_START_FLOOR = 0.8
|
|
|
|
AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px
|
|
AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU
|
|
AUDIO_MARKER_PNG_BYTES = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG"
|
|
"cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu"
|
|
"2ZRCLKzIQBdqnXDPsL+dA+8Qx88UJpAHPHTfJsRY4Jmo1yBUoCZ8Sj2SgNHxK3KiBWv"
|
|
"DU+EEDteGtQhxeA63AW/l4WMuolNa5zx4DNaCd/XXuWfAY15sBTXguOJTL9501GlG50"
|
|
"Ovc/xpjzDjtP+5nqj0gkXEL7763OhmQmj4S4FZY1iGnaAUkwQMxTlWouoz65CYBq4LV"
|
|
"0cTU6VMgw8frOO3e6273x3Nbhq7JCmCCVYDTqDkDMOuUajdnIFWXgdq6DEgLNm5oGbV"
|
|
"qzoBPcOmES+qxkW3L0FE2s1BWJDa5cdqjm5gxf7ddmqyAC4+dQq1EDYzTTpq3mTFO56"
|
|
"KTAam7xpJGsOxDTtUpJGEEZhw7lRb5SWlNcJs8dJx+q4DkwwcEJLsiLh86hTRNGMM3g"
|
|
"nFVXUYlGt1rQLsKqfLxBCvQiokQR3QK1TYRi0/qgVomKHHJTVzaBDUeC0rzBnBqoljL"
|
|
"qFY1OOP+3yf1PpX+r8TH6wW14c3/7xdFRAAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
for _prefix, _uri in (
|
|
("p", PML_NS),
|
|
("a", DRAWINGML_NS),
|
|
("r", RELATIONSHIPS_NS),
|
|
("p14", P14_NS),
|
|
):
|
|
try:
|
|
ET.register_namespace(_prefix, _uri)
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
|
|
def _qn(namespace: str, tag: str) -> str:
|
|
return f"{{{namespace}}}{tag}"
|
|
|
|
|
|
def _normalize_title(title: str) -> str:
|
|
text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip())
|
|
return re.sub(r"_+", "_", text).strip("_").lower()
|
|
|
|
|
|
def _leading_number(text: str) -> int | None:
|
|
match = re.match(r"^(\d{1,3})", text.strip())
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Path]:
|
|
"""Return `{svg_stem: audio_path}` matched by exact stem, normalized stem, or index."""
|
|
if not audio_dir.exists() or not audio_dir.is_dir():
|
|
return {}
|
|
|
|
audio_files = [
|
|
path for path in sorted(audio_dir.iterdir())
|
|
if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS
|
|
]
|
|
exact: dict[str, list[Path]] = {}
|
|
normalized: dict[str, list[Path]] = {}
|
|
numbered: dict[int, list[Path]] = {}
|
|
for path in audio_files:
|
|
exact.setdefault(path.stem, []).append(path)
|
|
normalized.setdefault(_normalize_title(path.stem), []).append(path)
|
|
number = _leading_number(path.stem)
|
|
if number is not None:
|
|
numbered.setdefault(number, []).append(path)
|
|
|
|
matched: dict[str, Path] = {}
|
|
claimed_by: dict[Path, str] = {}
|
|
for index, svg in enumerate(svg_files, 1):
|
|
stem = svg.stem
|
|
candidates = exact.get(stem)
|
|
if not candidates:
|
|
candidates = normalized.get(_normalize_title(stem))
|
|
if not candidates:
|
|
candidates = numbered.get(index)
|
|
if not candidates:
|
|
continue
|
|
if len(candidates) > 1:
|
|
names = ", ".join(path.name for path in candidates)
|
|
raise ValueError(
|
|
f"multiple narration audio files match slide {stem!r}: "
|
|
f"{names}; keep exactly one supported file for this slide"
|
|
)
|
|
candidate = candidates[0]
|
|
previous_stem = claimed_by.get(candidate)
|
|
if previous_stem is not None:
|
|
raise ValueError(
|
|
f"narration audio file {candidate.name!r} matches multiple slides: "
|
|
f"{previous_stem!r}, {stem!r}; provide one distinct audio file "
|
|
"per slide"
|
|
)
|
|
matched[stem] = candidate
|
|
claimed_by[candidate] = stem
|
|
return matched
|
|
|
|
|
|
def probe_audio_duration(audio_path: Path) -> float | None:
|
|
"""Return duration in seconds using ffprobe when available."""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"ffprobe", "-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "json",
|
|
str(audio_path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
data = json.loads(result.stdout or "{}")
|
|
duration = float(data.get("format", {}).get("duration", 0))
|
|
return duration if duration > 0 else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def next_shape_id(slide_xml: str) -> int:
|
|
"""Return the next slide-local non-visual shape id."""
|
|
root = parse_source_xml(slide_xml)
|
|
if root.tag != _qn(PML_NS, "sld"):
|
|
raise ValueError("narration source XML root must be p:sld")
|
|
ids = _numeric_ids(
|
|
root.iter(_qn(PML_NS, "cNvPr")),
|
|
"shape",
|
|
minimum=1,
|
|
)
|
|
next_id = max(ids, default=1) + 1
|
|
if next_id > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError("narration source has no available shape identifiers")
|
|
return next_id
|
|
|
|
|
|
def _create_audio_pic_element(
|
|
shape_id: int,
|
|
shape_name: str,
|
|
audio_rid: str,
|
|
media_rid: str,
|
|
poster_rid: str,
|
|
) -> ET.Element:
|
|
pic = ET.Element(_qn(PML_NS, "pic"))
|
|
nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr"))
|
|
c_nv_pr = ET.SubElement(
|
|
nv_pic_pr,
|
|
_qn(PML_NS, "cNvPr"),
|
|
{"id": str(shape_id), "name": shape_name},
|
|
)
|
|
ET.SubElement(
|
|
c_nv_pr,
|
|
_qn(DRAWINGML_NS, "hlinkClick"),
|
|
{
|
|
_qn(RELATIONSHIPS_NS, "id"): "",
|
|
"action": "ppaction://media",
|
|
},
|
|
)
|
|
c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr"))
|
|
ET.SubElement(
|
|
c_nv_pic_pr,
|
|
_qn(DRAWINGML_NS, "picLocks"),
|
|
{"noChangeAspect": "1"},
|
|
)
|
|
nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr"))
|
|
ET.SubElement(
|
|
nv_pr,
|
|
_qn(DRAWINGML_NS, "audioFile"),
|
|
{_qn(RELATIONSHIPS_NS, "link"): audio_rid},
|
|
)
|
|
ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst"))
|
|
extension = ET.SubElement(
|
|
ext_list,
|
|
_qn(PML_NS, "ext"),
|
|
{"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"},
|
|
)
|
|
ET.SubElement(
|
|
extension,
|
|
_qn(P14_NS, "media"),
|
|
{_qn(RELATIONSHIPS_NS, "embed"): media_rid},
|
|
)
|
|
|
|
blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill"))
|
|
ET.SubElement(
|
|
blip_fill,
|
|
_qn(DRAWINGML_NS, "blip"),
|
|
{_qn(RELATIONSHIPS_NS, "embed"): poster_rid},
|
|
)
|
|
stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch"))
|
|
ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect"))
|
|
|
|
shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr"))
|
|
transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm"))
|
|
ET.SubElement(
|
|
transform,
|
|
_qn(DRAWINGML_NS, "off"),
|
|
{
|
|
"x": str(AUDIO_MARKER_OFF_CANVAS_EMU),
|
|
"y": str(AUDIO_MARKER_OFF_CANVAS_EMU),
|
|
},
|
|
)
|
|
ET.SubElement(
|
|
transform,
|
|
_qn(DRAWINGML_NS, "ext"),
|
|
{
|
|
"cx": str(AUDIO_MARKER_SIZE_EMU),
|
|
"cy": str(AUDIO_MARKER_SIZE_EMU),
|
|
},
|
|
)
|
|
geometry = ET.SubElement(
|
|
shape_properties,
|
|
_qn(DRAWINGML_NS, "prstGeom"),
|
|
{"prst": "rect"},
|
|
)
|
|
ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst"))
|
|
return pic
|
|
|
|
|
|
def create_audio_pic_xml(
|
|
shape_id: int,
|
|
shape_name: str,
|
|
audio_rid: str,
|
|
media_rid: str,
|
|
poster_rid: str,
|
|
) -> str:
|
|
"""Create an off-canvas audio picture shape carrying narration media."""
|
|
element = _create_audio_pic_element(
|
|
shape_id,
|
|
shape_name,
|
|
audio_rid,
|
|
media_rid,
|
|
poster_rid,
|
|
)
|
|
return ET.tostring(element, encoding="unicode")
|
|
|
|
|
|
def _numeric_ids(
|
|
elements: Iterable[ET.Element],
|
|
label: str,
|
|
*,
|
|
minimum: int = 0,
|
|
maximum: int = MAX_OOXML_UNSIGNED_INT,
|
|
) -> list[int]:
|
|
ids: list[int] = []
|
|
seen: set[int] = set()
|
|
for element in elements:
|
|
raw_id = element.get("id")
|
|
try:
|
|
numeric_id = int(raw_id)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc
|
|
if numeric_id < minimum:
|
|
raise ValueError(
|
|
f"narration source has {label} id below {minimum}: {numeric_id}"
|
|
)
|
|
if numeric_id > maximum:
|
|
raise ValueError(
|
|
f"narration source has {label} id above {maximum}: {numeric_id}"
|
|
)
|
|
if numeric_id in seen:
|
|
raise ValueError(f"narration source has duplicate {label} id: {numeric_id}")
|
|
ids.append(numeric_id)
|
|
seen.add(numeric_id)
|
|
return ids
|
|
|
|
|
|
def narration_lead_in_seconds(
|
|
transition_duration: float,
|
|
*,
|
|
start_floor: float = DEFAULT_NARRATION_START_FLOOR,
|
|
) -> float:
|
|
"""Return silence after a slide transition before narration starts."""
|
|
transition_seconds = validate_seconds(
|
|
transition_duration,
|
|
"narration transition duration",
|
|
allow_zero=True,
|
|
)
|
|
floor_seconds = validate_seconds(
|
|
start_floor,
|
|
"narration start floor",
|
|
allow_zero=True,
|
|
)
|
|
return max(0.0, floor_seconds - transition_seconds)
|
|
|
|
|
|
def _create_audio_timing_element(
|
|
shape_id: int,
|
|
ctn_id: int,
|
|
start_delay_ms: int,
|
|
) -> ET.Element:
|
|
audio = ET.Element(_qn(PML_NS, "audio"))
|
|
media_node = ET.SubElement(
|
|
audio,
|
|
_qn(PML_NS, "cMediaNode"),
|
|
{"vol": "80000"},
|
|
)
|
|
time_node = ET.SubElement(
|
|
media_node,
|
|
_qn(PML_NS, "cTn"),
|
|
{"id": str(ctn_id), "fill": "hold", "display": "0"},
|
|
)
|
|
start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst"))
|
|
ET.SubElement(
|
|
start_conditions,
|
|
_qn(PML_NS, "cond"),
|
|
{"delay": str(start_delay_ms)},
|
|
)
|
|
target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl"))
|
|
ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)})
|
|
return audio
|
|
|
|
|
|
def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element:
|
|
children = [child for child in parent if child.tag == tag]
|
|
if len(children) != 1:
|
|
raise ValueError(
|
|
f"narration source must contain exactly one direct {label}; found {len(children)}"
|
|
)
|
|
return children[0]
|
|
|
|
|
|
def _existing_timing_root(timing: ET.Element) -> ET.Element:
|
|
children = list(timing)
|
|
for tag, label in (
|
|
(_qn(PML_NS, "tnLst"), "p:tnLst"),
|
|
(_qn(PML_NS, "bldLst"), "p:bldLst"),
|
|
(_qn(PML_NS, "extLst"), "p:extLst"),
|
|
):
|
|
if sum(child.tag == tag for child in children) > 1:
|
|
raise ValueError(f"narration source timing has multiple {label} elements")
|
|
node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst")
|
|
node_index = children.index(node_list)
|
|
for tag, label in (
|
|
(_qn(PML_NS, "bldLst"), "p:bldLst"),
|
|
(_qn(PML_NS, "extLst"), "p:extLst"),
|
|
):
|
|
sibling = next((child for child in children if child.tag == tag), None)
|
|
if sibling is not None and node_index > children.index(sibling):
|
|
raise ValueError(f"narration source p:tnLst must precede {label}")
|
|
timing_roots = [
|
|
element
|
|
for element in node_list.iter(_qn(PML_NS, "cTn"))
|
|
if element.get("nodeType") == "tmRoot"
|
|
]
|
|
if len(timing_roots) != 1:
|
|
raise ValueError(
|
|
"narration source timing must contain exactly one tmRoot; "
|
|
f"found {len(timing_roots)}"
|
|
)
|
|
return timing_roots[0]
|
|
|
|
|
|
def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element:
|
|
timing = ET.Element(_qn(PML_NS, "timing"))
|
|
node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst"))
|
|
parallel = ET.SubElement(node_list, _qn(PML_NS, "par"))
|
|
timing_root = ET.SubElement(
|
|
parallel,
|
|
_qn(PML_NS, "cTn"),
|
|
{
|
|
"id": str(root_id),
|
|
"dur": "indefinite",
|
|
"restart": "never",
|
|
"nodeType": "tmRoot",
|
|
},
|
|
)
|
|
child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst"))
|
|
child_nodes.append(audio_timing)
|
|
return timing
|
|
|
|
|
|
def _root_extension_index(slide: ET.Element) -> int | None:
|
|
extension_lists = [
|
|
index
|
|
for index, child in enumerate(slide)
|
|
if child.tag == _qn(PML_NS, "extLst")
|
|
]
|
|
if len(extension_lists) > 1:
|
|
raise ValueError("narration source has multiple root p:extLst elements")
|
|
if extension_lists and extension_lists[0] != len(slide) - 1:
|
|
raise ValueError("narration source root p:extLst is not the last slide child")
|
|
return extension_lists[0] if extension_lists else None
|
|
|
|
|
|
def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None:
|
|
children = list(slide)
|
|
timing_index = children.index(timing)
|
|
for tag, label in (
|
|
(_qn(PML_NS, "cSld"), "p:cSld"),
|
|
(_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"),
|
|
):
|
|
siblings = [index for index, child in enumerate(children) if child.tag == tag]
|
|
if len(siblings) > 1:
|
|
raise ValueError(f"narration source has multiple root {label} elements")
|
|
if siblings and siblings[0] > timing_index:
|
|
raise ValueError(f"narration source root p:timing must follow {label}")
|
|
extension_index = _root_extension_index(slide)
|
|
if extension_index is not None and timing_index > extension_index:
|
|
raise ValueError("narration source root p:timing must precede p:extLst")
|
|
|
|
|
|
def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
|
|
extension_index = _root_extension_index(slide)
|
|
insert_at = extension_index if extension_index is not None else len(slide)
|
|
slide.insert(insert_at, timing)
|
|
|
|
|
|
def _animation_timing_branches(
|
|
slide: ET.Element,
|
|
) -> tuple[ET.Element | None, list[ET.Element]]:
|
|
"""Return the root timing anchor and every active/fallback timing branch."""
|
|
direct = [
|
|
child for child in slide
|
|
if child.tag == _qn(PML_NS, "timing")
|
|
]
|
|
alternates: list[tuple[ET.Element, list[ET.Element]]] = []
|
|
for child in slide:
|
|
if child.tag != _qn(MARKUP_COMPATIBILITY_NS, "AlternateContent"):
|
|
continue
|
|
timings = [
|
|
timing
|
|
for branch in list(child)
|
|
for timing in list(branch)
|
|
if timing.tag == _qn(PML_NS, "timing")
|
|
]
|
|
if timings:
|
|
alternates.append((child, timings))
|
|
if direct and alternates:
|
|
raise ValueError(
|
|
"narration source contains both direct and AlternateContent timing"
|
|
)
|
|
if len(direct) > 1 or len(alternates) > 1:
|
|
raise ValueError("narration source has multiple root animation timings")
|
|
if direct:
|
|
return direct[0], direct
|
|
if alternates:
|
|
anchor, timings = alternates[0]
|
|
if len(timings) != 2:
|
|
raise ValueError(
|
|
"narration source animation AlternateContent must contain "
|
|
"one Choice and one Fallback timing"
|
|
)
|
|
return anchor, timings
|
|
nested = list(slide.iter(_qn(PML_NS, "timing")))
|
|
if nested:
|
|
raise ValueError(
|
|
"narration source contains unsupported non-root p:timing"
|
|
)
|
|
return None, []
|
|
|
|
|
|
def inject_narration(
|
|
slide_xml: str,
|
|
*,
|
|
shape_id: int,
|
|
shape_name: str,
|
|
audio_rid: str,
|
|
media_rid: str,
|
|
poster_rid: str,
|
|
start_delay: float = 0.0,
|
|
) -> str:
|
|
"""Inject a hidden narration shape and delayed slide-entry autoplay timing."""
|
|
if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0:
|
|
raise ValueError("narration shape_id must be a positive integer")
|
|
if shape_id > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError(
|
|
"narration shape_id exceeds the OOXML unsigned-integer limit: "
|
|
f"{shape_id}"
|
|
)
|
|
start_delay_seconds = validate_seconds(
|
|
start_delay,
|
|
"narration start delay",
|
|
allow_zero=True,
|
|
)
|
|
start_delay_ms = round(start_delay_seconds * 1000)
|
|
if start_delay_ms > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError(
|
|
"narration start delay exceeds the OOXML unsigned-integer limit: "
|
|
f"{start_delay_ms} ms"
|
|
)
|
|
|
|
root = parse_source_xml(slide_xml)
|
|
if root.tag != _qn(PML_NS, "sld"):
|
|
raise ValueError("narration source XML root must be p:sld")
|
|
common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld")
|
|
shape_tree = _direct_child(
|
|
common_slide_data,
|
|
_qn(PML_NS, "spTree"),
|
|
"p:cSld/p:spTree",
|
|
)
|
|
|
|
shape_ids = _numeric_ids(
|
|
root.iter(_qn(PML_NS, "cNvPr")),
|
|
"shape",
|
|
minimum=1,
|
|
)
|
|
if shape_id in shape_ids:
|
|
raise ValueError(f"narration shape id already exists on slide: {shape_id}")
|
|
timing_anchor, timing_branches = _animation_timing_branches(root)
|
|
timing_id_sets = [
|
|
_numeric_ids(timing.iter(_qn(PML_NS, "cTn")), "timing node")
|
|
for timing in timing_branches
|
|
]
|
|
timing_ids = [
|
|
timing_id
|
|
for timing_set in timing_id_sets
|
|
for timing_id in timing_set
|
|
]
|
|
next_timing_id = max(timing_ids, default=0) + 1
|
|
if next_timing_id > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError("narration source has no available timing node identifiers")
|
|
|
|
if not timing_branches and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError(
|
|
"narration source has no identifiers available for a new timing root"
|
|
)
|
|
|
|
audio_picture = _create_audio_pic_element(
|
|
shape_id,
|
|
shape_name,
|
|
audio_rid,
|
|
media_rid,
|
|
poster_rid,
|
|
)
|
|
shape_tree.append(audio_picture)
|
|
|
|
if timing_branches:
|
|
if timing_anchor is None:
|
|
raise AssertionError("timing branches lost their root anchor")
|
|
_validate_root_timing_position(root, timing_anchor)
|
|
for timing in timing_branches:
|
|
timing_root = _existing_timing_root(timing)
|
|
child_nodes = _direct_child(
|
|
timing_root,
|
|
_qn(PML_NS, "childTnLst"),
|
|
"tmRoot/p:childTnLst",
|
|
)
|
|
child_nodes.append(
|
|
_create_audio_timing_element(
|
|
shape_id,
|
|
next_timing_id,
|
|
start_delay_ms,
|
|
)
|
|
)
|
|
else:
|
|
audio_timing = _create_audio_timing_element(
|
|
shape_id,
|
|
next_timing_id + 1,
|
|
start_delay_ms,
|
|
)
|
|
_insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
|
|
|
|
return serialize_source_xml(root, slide_xml).decode("utf-8")
|
|
|
|
|
|
def read_narration_start_delay_xml(slide_xml: str) -> int:
|
|
"""Return the latest embedded narration picture's autoplay delay in ms."""
|
|
root = parse_source_xml(slide_xml)
|
|
if root.tag != _qn(PML_NS, "sld"):
|
|
raise ValueError("narration source XML root must be p:sld")
|
|
|
|
audio_shape_properties: list[ET.Element] = []
|
|
for picture in root.iter(_qn(PML_NS, "pic")):
|
|
if not any(
|
|
element.tag == _qn(DRAWINGML_NS, "audioFile")
|
|
for element in picture.iter()
|
|
):
|
|
continue
|
|
properties = list(picture.iter(_qn(PML_NS, "cNvPr")))
|
|
if len(properties) != 1:
|
|
raise ValueError(
|
|
"narration audio picture must contain exactly one p:cNvPr"
|
|
)
|
|
audio_shape_properties.extend(properties)
|
|
if not audio_shape_properties:
|
|
raise ValueError("narration source has no embedded audio picture")
|
|
|
|
narration_shape_id = max(
|
|
_numeric_ids(
|
|
audio_shape_properties,
|
|
"narration audio shape",
|
|
minimum=1,
|
|
)
|
|
)
|
|
delays: list[int] = []
|
|
for audio in root.iter(_qn(PML_NS, "audio")):
|
|
media_node = audio.find(_qn(PML_NS, "cMediaNode"))
|
|
if media_node is None:
|
|
continue
|
|
target = media_node.find(
|
|
f"{_qn(PML_NS, 'tgtEl')}/{_qn(PML_NS, 'spTgt')}"
|
|
)
|
|
if target is None or target.get("spid") != str(narration_shape_id):
|
|
continue
|
|
time_node = _direct_child(
|
|
media_node,
|
|
_qn(PML_NS, "cTn"),
|
|
"p:audio/p:cMediaNode/p:cTn",
|
|
)
|
|
start_conditions = _direct_child(
|
|
time_node,
|
|
_qn(PML_NS, "stCondLst"),
|
|
"p:cTn/p:stCondLst",
|
|
)
|
|
condition = _direct_child(
|
|
start_conditions,
|
|
_qn(PML_NS, "cond"),
|
|
"p:stCondLst/p:cond",
|
|
)
|
|
raw_delay = condition.get("delay")
|
|
try:
|
|
delay = int(raw_delay)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(
|
|
f"narration timing has invalid start delay: {raw_delay!r}"
|
|
) from exc
|
|
if delay < 0 or delay > MAX_OOXML_UNSIGNED_INT:
|
|
raise ValueError(
|
|
"narration timing start delay is outside the OOXML "
|
|
f"unsigned-integer range: {delay}"
|
|
)
|
|
delays.append(delay)
|
|
|
|
if not delays:
|
|
raise ValueError("narration source has no autoplay timing for its audio picture")
|
|
if len(set(delays)) != 1:
|
|
raise ValueError(
|
|
"narration source timing branches disagree on autoplay delay: "
|
|
f"{sorted(set(delays))}"
|
|
)
|
|
return delays[0]
|
|
|
|
|
|
def apply_recorded_timing(
|
|
slide_xml: str,
|
|
*,
|
|
advance_after: float,
|
|
transition_duration: float,
|
|
transition_effect: str | None = "fade",
|
|
) -> str:
|
|
"""Set slide auto-advance timing so exported video follows narration length."""
|
|
summary = read_slide_transition_xml(slide_xml)
|
|
if summary.logical_count:
|
|
enter = EnterUpdate(policy="preserve")
|
|
elif transition_effect is None or transition_effect == "none":
|
|
enter = EnterUpdate(policy="none")
|
|
else:
|
|
enter = EnterUpdate(
|
|
policy="replace",
|
|
effect=transition_effect,
|
|
duration=transition_duration,
|
|
)
|
|
updated, _uses_timings = apply_slide_motion_xml(
|
|
slide_xml,
|
|
enter=enter,
|
|
advance=AdvanceUpdate(mode="narration", after=advance_after),
|
|
)
|
|
return updated
|