| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Native Existing PPTX Enhancer |
| 4 | |
| 5 | Implementation core for the public native enhancement CLI and its legacy |
| 6 | narration compatibility entrypoint. It enhances an existing PPTX without |
| 7 | entering the SVG generation pipeline or modifying the original file. |
| 8 | |
| 9 | Enhancement modules: read-only delivery checks, speaker notes, narration audio, |
| 10 | slide auto-advance timings, and optional global or per-slide page transitions. |
| 11 | |
| 12 | Usage: |
| 13 | python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name] |
| 14 | python3 scripts/native_enhance_pptx.py apply <project_path> [--output output.pptx] |
| 15 | python3 scripts/native_enhance_pptx.py validate <project_path> [--materials {all,notes}] |
| 16 | |
| 17 | Examples: |
| 18 | python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station |
| 19 | python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626 |
| 20 | python3 scripts/native_enhance_pptx.py validate projects/fire_station_native_enhance_20260626 |
| 21 | |
| 22 | Dependencies: |
| 23 | ffprobe for narration decodability and audio-duration validation. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import argparse |
| 29 | import hashlib |
| 30 | import json |
| 31 | import posixpath |
| 32 | import re |
| 33 | import shutil |
| 34 | import subprocess |
| 35 | import sys |
| 36 | import tempfile |
| 37 | import zipfile |
| 38 | from collections.abc import Mapping |
| 39 | from dataclasses import dataclass |
| 40 | from datetime import datetime |
| 41 | from pathlib import Path |
| 42 | from xml.etree import ElementTree as ET |
| 43 | |
| 44 | _SCRIPTS_DIR = Path(__file__).resolve().parent |
| 45 | if str(_SCRIPTS_DIR) not in sys.path: |
| 46 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 47 | |
| 48 | from attribution_guard import require_skill_integrity # noqa: E402 |
| 49 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 50 | from pptx_delivery_check import audit_pptx_delivery # noqa: E402 |
| 51 | from pptx_animations import ( # noqa: E402 |
| 52 | object_animation_fingerprint, |
| 53 | validate_pptx_animation_package, |
| 54 | ) |
| 55 | from pptx_transitions import ( # noqa: E402 |
| 56 | AdvanceUpdate, |
| 57 | EnterUpdate, |
| 58 | LEGACY_TRANSITION_KEYS, |
| 59 | NATIVE_TRANSITION_KEYS, |
| 60 | apply_slide_motion_xml, |
| 61 | normalize_transition_effect_request, |
| 62 | read_slide_transition_xml, |
| 63 | set_directory_use_timings, |
| 64 | validate_pptx_transition_package, |
| 65 | validate_seconds, |
| 66 | ) |
| 67 | from svg_to_pptx.pptx_package.builder import ( # noqa: E402 |
| 68 | _add_default_content_type, |
| 69 | _append_relationship, |
| 70 | _ensure_notes_master, |
| 71 | ) |
| 72 | from svg_to_pptx.pptx_package.narration import ( # noqa: E402 |
| 73 | AUDIO_CONTENT_TYPES, |
| 74 | AUDIO_MARKER_PNG_BYTES, |
| 75 | AUDIO_REL_TYPE, |
| 76 | DEFAULT_NARRATION_START_FLOOR, |
| 77 | IMAGE_REL_TYPE, |
| 78 | MEDIA_REL_TYPE, |
| 79 | NARRATION_EXTENSIONS, |
| 80 | inject_narration, |
| 81 | narration_lead_in_seconds, |
| 82 | next_shape_id, |
| 83 | probe_audio_duration, |
| 84 | ) |
| 85 | from svg_to_pptx.pptx_package.notes import ( # noqa: E402 |
| 86 | create_notes_slide_rels_xml, |
| 87 | create_notes_slide_xml, |
| 88 | markdown_to_plain_text, |
| 89 | ) |
| 90 | |
| 91 | configure_utf8_stdio() |
| 92 | |
| 93 | |
| 94 | PROJECT_SCHEMA = "native_pptx_enhancement_project.v1" |
| 95 | PLAN_SCHEMA = "native_pptx_enhancement_plan.v1" |
| 96 | VALIDATION_SCHEMA = "native_pptx_enhancement_validation.v1" |
| 97 | LEGACY_PROJECT_SCHEMAS = {"native_narration_pptx_project.v1"} |
| 98 | _WRITABLE_MODULES = ("notes", "audio", "timings", "transitions") |
| 99 | NOTES_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" |
| 100 | PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" |
| 101 | PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 102 | REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" |
| 103 | CONTENT_TYPE_NOTES_SLIDE = ( |
| 104 | "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" |
| 105 | ) |
| 106 | CONTENT_TYPE_NOTES_MASTER = ( |
| 107 | "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml" |
| 108 | ) |
| 109 | CONTENT_TYPE_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" |
| 110 | _NOTES_SLIDE_PART_RE = re.compile( |
| 111 | r"^ppt/notesSlides/notesSlide([1-9]\d*)\.xml$" |
| 112 | ) |
| 113 | _LEGACY_MISSING_NOTES_MASTER_RE = re.compile( |
| 114 | r"^ppt/notesSlides/_rels/notesSlide[1-9]\d*\.xml\.rels" |
| 115 | r" -> ppt/notesmasters/notesmaster[1-9]\d*\.xml$", |
| 116 | re.IGNORECASE, |
| 117 | ) |
| 118 | _TRANSITION_MODULE_FIELDS = frozenset( |
| 119 | { |
| 120 | "enabled", |
| 121 | "requires_confirmation", |
| 122 | "status", |
| 123 | "effect", |
| 124 | "duration", |
| 125 | "effect_options", |
| 126 | "apply_without_audio", |
| 127 | "slides", |
| 128 | } |
| 129 | ) |
| 130 | _TRANSITION_OVERRIDE_FIELDS = frozenset( |
| 131 | {"effect", "duration", "effect_options"} |
| 132 | ) |
| 133 | @dataclass(frozen=True) |
| 134 | class SlidePart: |
| 135 | index: int |
| 136 | part_name: str |
| 137 | slide_number: int |
| 138 | |
| 139 | |
| 140 | @dataclass |
| 141 | class MaterialReadiness: |
| 142 | note_paths: dict[int, Path] |
| 143 | audio_paths: dict[int, Path] |
| 144 | audio_durations: dict[int, float] |
| 145 | notes_count: int |
| 146 | audio_count: int |
| 147 | missing_notes: list[int] |
| 148 | invalid_notes: dict[int, str] |
| 149 | missing_audio: list[int] |
| 150 | invalid_audio: dict[int, str] |
| 151 | module_errors: list[str] |
| 152 | |
| 153 | @property |
| 154 | def ready(self) -> bool: |
| 155 | return not ( |
| 156 | self.missing_notes |
| 157 | or self.invalid_notes |
| 158 | or self.missing_audio |
| 159 | or self.invalid_audio |
| 160 | or self.module_errors |
| 161 | ) |
| 162 | |
| 163 | |
| 164 | @dataclass(frozen=True) |
| 165 | class ResolvedTransitionPlan: |
| 166 | global_enter: EnterUpdate |
| 167 | slide_enters: Mapping[int, EnterUpdate] |
| 168 | apply_without_audio: bool |
| 169 | |
| 170 | |
| 171 | def _sanitize_slug(value: str) -> str: |
| 172 | slug = re.sub(r"[^0-9A-Za-z_-]+", "_", value).strip("_") |
| 173 | return slug or "native_enhance" |
| 174 | |
| 175 | |
| 176 | def _positive_seconds_arg(value: str) -> float: |
| 177 | try: |
| 178 | return validate_seconds(value, "transition duration", allow_zero=False) |
| 179 | except ValueError as exc: |
| 180 | raise argparse.ArgumentTypeError(str(exc)) from exc |
| 181 | |
| 182 | |
| 183 | def _non_negative_seconds_arg(value: str) -> float: |
| 184 | try: |
| 185 | return validate_seconds(value, "narration padding", allow_zero=True) |
| 186 | except ValueError as exc: |
| 187 | raise argparse.ArgumentTypeError(str(exc)) from exc |
| 188 | |
| 189 | |
| 190 | def _read_json(path: Path) -> dict: |
| 191 | data = json.loads(path.read_text(encoding="utf-8")) |
| 192 | if not isinstance(data, dict): |
| 193 | raise ValueError(f"JSON root must be an object: {path}") |
| 194 | return data |
| 195 | |
| 196 | |
| 197 | def _write_json(path: Path, data: dict) -> None: |
| 198 | path.write_text( |
| 199 | json.dumps(data, ensure_ascii=False, indent=2) + "\n", |
| 200 | encoding="utf-8", |
| 201 | ) |
| 202 | |
| 203 | |
| 204 | def _write_preflight_report( |
| 205 | project_path: Path, |
| 206 | plan: dict, |
| 207 | modules: set[str], |
| 208 | *, |
| 209 | status: str, |
| 210 | **details: object, |
| 211 | ) -> dict: |
| 212 | report = { |
| 213 | "schema": VALIDATION_SCHEMA, |
| 214 | "status": status, |
| 215 | "phase": "preflight", |
| 216 | "plan_status": plan.get("status") or "missing", |
| 217 | "enabled_modules": sorted(modules), |
| 218 | **details, |
| 219 | } |
| 220 | validation_dir = project_path / "validation" |
| 221 | validation_dir.mkdir(exist_ok=True) |
| 222 | _write_json(validation_dir / "report.json", report) |
| 223 | return report |
| 224 | |
| 225 | |
| 226 | def _delivery_issues(report: dict, field: str) -> list[dict]: |
| 227 | issues = report.get(field) |
| 228 | if not isinstance(issues, list): |
| 229 | return [] |
| 230 | return [issue for issue in issues if isinstance(issue, dict)] |
| 231 | |
| 232 | |
| 233 | def _fatal_source_delivery_messages(report: dict) -> list[str]: |
| 234 | fatal = [ |
| 235 | issue |
| 236 | for issue in _delivery_issues(report, "errors") |
| 237 | if not ( |
| 238 | issue.get("code") == "dangling_internal_relationship" |
| 239 | and isinstance(issue.get("message"), str) |
| 240 | and _LEGACY_MISSING_NOTES_MASTER_RE.fullmatch( |
| 241 | issue["message"] |
| 242 | ) |
| 243 | is not None |
| 244 | ) |
| 245 | ] |
| 246 | if fatal: |
| 247 | return [ |
| 248 | str(issue.get("message") or issue) |
| 249 | for issue in fatal |
| 250 | ] |
| 251 | if report.get("status") == "failed" and not _delivery_issues( |
| 252 | report, |
| 253 | "errors", |
| 254 | ): |
| 255 | return ["delivery check failed without structured error details"] |
| 256 | return [] |
| 257 | |
| 258 | |
| 259 | def _new_delivery_errors(source: dict, candidate: dict) -> list[dict]: |
| 260 | source_keys = { |
| 261 | json.dumps(issue, ensure_ascii=False, sort_keys=True) |
| 262 | for issue in _delivery_issues(source, "errors") |
| 263 | } |
| 264 | return [ |
| 265 | issue |
| 266 | for issue in _delivery_issues(candidate, "errors") |
| 267 | if json.dumps(issue, ensure_ascii=False, sort_keys=True) |
| 268 | not in source_keys |
| 269 | ] |
| 270 | |
| 271 | |
| 272 | def _delivery_has_findings(report: dict) -> bool: |
| 273 | return bool( |
| 274 | _delivery_issues(report, "errors") |
| 275 | or _delivery_issues(report, "advisories") |
| 276 | ) |
| 277 | |
| 278 | |
| 279 | def _delivery_hidden_slide_indices( |
| 280 | report: dict, |
| 281 | ) -> tuple[int, ...] | None: |
| 282 | slides = report.get("slides") |
| 283 | hidden = slides.get("hidden") if isinstance(slides, dict) else None |
| 284 | if not isinstance(hidden, list): |
| 285 | return None |
| 286 | indices: list[int] = [] |
| 287 | for item in hidden: |
| 288 | index = item.get("index") if isinstance(item, dict) else None |
| 289 | if isinstance(index, bool) or not isinstance(index, int): |
| 290 | return None |
| 291 | indices.append(index) |
| 292 | return tuple(indices) |
| 293 | |
| 294 | |
| 295 | def _file_sha256(path: Path) -> str: |
| 296 | digest = hashlib.sha256() |
| 297 | with path.open("rb") as handle: |
| 298 | for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| 299 | digest.update(chunk) |
| 300 | return digest.hexdigest() |
| 301 | |
| 302 | |
| 303 | def _is_relative_to(path: Path, parent: Path) -> bool: |
| 304 | try: |
| 305 | path.resolve().relative_to(parent.resolve()) |
| 306 | return True |
| 307 | except ValueError: |
| 308 | return False |
| 309 | |
| 310 | |
| 311 | def _archive_source_pptx(source_pptx: Path, archived_pptx: Path, projects_root: Path) -> str: |
| 312 | """Move project-local sources into the project; copy external sources.""" |
| 313 | archived_pptx.parent.mkdir(parents=True, exist_ok=True) |
| 314 | if source_pptx.resolve() == archived_pptx.resolve(): |
| 315 | return "reuse" |
| 316 | if _is_relative_to(source_pptx, projects_root): |
| 317 | shutil.move(str(source_pptx), str(archived_pptx)) |
| 318 | return "move" |
| 319 | shutil.copy2(source_pptx, archived_pptx) |
| 320 | return "copy" |
| 321 | |
| 322 | |
| 323 | def _relationship_file_for_part(extract_dir: Path, part_name: str) -> Path: |
| 324 | part = Path(part_name) |
| 325 | return extract_dir / part.parent / "_rels" / f"{part.name}.rels" |
| 326 | |
| 327 | |
| 328 | def _ensure_rels_file(path: Path) -> None: |
| 329 | if path.exists(): |
| 330 | return |
| 331 | path.parent.mkdir(parents=True, exist_ok=True) |
| 332 | path.write_text( |
| 333 | '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' |
| 334 | f'<Relationships xmlns="{PACKAGE_REL_NS}">\n</Relationships>', |
| 335 | encoding="utf-8", |
| 336 | ) |
| 337 | |
| 338 | |
| 339 | def _target_to_part(target: str) -> str: |
| 340 | target = target.lstrip("/") |
| 341 | if target.startswith("ppt/"): |
| 342 | return target |
| 343 | return f"ppt/{target}" |
| 344 | |
| 345 | |
| 346 | def _slide_number_from_part(part_name: str) -> int: |
| 347 | match = re.search(r"slide(\d+)\.xml$", part_name) |
| 348 | if not match: |
| 349 | raise ValueError(f"Unsupported slide part name: {part_name}") |
| 350 | return int(match.group(1)) |
| 351 | |
| 352 | |
| 353 | def _resolve_relationship_part(source_part: str, target: str) -> str: |
| 354 | """Resolve an internal relationship target to a package part name.""" |
| 355 | target_path = target.split("#", 1)[0] |
| 356 | if target_path.startswith("/"): |
| 357 | return posixpath.normpath(target_path.lstrip("/")) |
| 358 | return posixpath.normpath( |
| 359 | posixpath.join(posixpath.dirname(source_part), target_path) |
| 360 | ) |
| 361 | |
| 362 | |
| 363 | def _notes_slide_index(part_name: str) -> int | None: |
| 364 | match = _NOTES_SLIDE_PART_RE.fullmatch(part_name) |
| 365 | return int(match.group(1)) if match else None |
| 366 | |
| 367 | |
| 368 | def _is_notes_slide_part(part_name: str) -> bool: |
| 369 | """Return whether a relationship target stays in the notesSlides folder.""" |
| 370 | return ( |
| 371 | posixpath.dirname(part_name) == "ppt/notesSlides" |
| 372 | and posixpath.basename(part_name).endswith(".xml") |
| 373 | and posixpath.basename(part_name) != ".xml" |
| 374 | ) |
| 375 | |
| 376 | |
| 377 | def _notes_slide_part_for_slide( |
| 378 | extract_dir: Path, |
| 379 | slide: SlidePart, |
| 380 | ) -> str | None: |
| 381 | """Return the notes part currently related to a slide, if present.""" |
| 382 | slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) |
| 383 | if not slide_rels.exists(): |
| 384 | return None |
| 385 | |
| 386 | related_parts: list[str] = [] |
| 387 | for rel in ET.parse(slide_rels).getroot(): |
| 388 | if rel.attrib.get("Type") != NOTES_REL_TYPE: |
| 389 | continue |
| 390 | if rel.attrib.get("TargetMode", "").lower() == "external": |
| 391 | raise RuntimeError( |
| 392 | f"Slide {slide.index} has an external notesSlide relationship" |
| 393 | ) |
| 394 | target = rel.attrib.get("Target") |
| 395 | if not target: |
| 396 | raise RuntimeError( |
| 397 | f"Slide {slide.index} notesSlide relationship has no Target" |
| 398 | ) |
| 399 | part_name = _resolve_relationship_part(slide.part_name, target) |
| 400 | if not _is_notes_slide_part(part_name): |
| 401 | raise RuntimeError( |
| 402 | f"Slide {slide.index} has an unsupported notesSlide target: {target}" |
| 403 | ) |
| 404 | related_parts.append(part_name) |
| 405 | |
| 406 | if len(related_parts) > 1: |
| 407 | raise RuntimeError( |
| 408 | f"Slide {slide.index} has multiple notesSlide relationships" |
| 409 | ) |
| 410 | return related_parts[0] if related_parts else None |
| 411 | |
| 412 | |
| 413 | def _used_notes_slide_indices(extract_dir: Path) -> set[int]: |
| 414 | """Collect every notesSlide number already reserved in the package.""" |
| 415 | used: set[int] = set() |
| 416 | notes_dir = extract_dir / "ppt" / "notesSlides" |
| 417 | for path in notes_dir.glob("notesSlide*.xml"): |
| 418 | index = _notes_slide_index(f"ppt/notesSlides/{path.name}") |
| 419 | if index is not None: |
| 420 | used.add(index) |
| 421 | for path in (notes_dir / "_rels").glob("notesSlide*.xml.rels"): |
| 422 | index = _notes_slide_index( |
| 423 | f"ppt/notesSlides/{path.name.removesuffix('.rels')}" |
| 424 | ) |
| 425 | if index is not None: |
| 426 | used.add(index) |
| 427 | |
| 428 | content_types_path = extract_dir / "[Content_Types].xml" |
| 429 | if content_types_path.exists(): |
| 430 | content_types = content_types_path.read_text(encoding="utf-8") |
| 431 | for match in re.finditer( |
| 432 | r'PartName="/(ppt/notesSlides/notesSlide[1-9]\d*\.xml)"', |
| 433 | content_types, |
| 434 | ): |
| 435 | index = _notes_slide_index(match.group(1)) |
| 436 | if index is not None: |
| 437 | used.add(index) |
| 438 | |
| 439 | slides_rels_dir = extract_dir / "ppt" / "slides" / "_rels" |
| 440 | for rels_path in slides_rels_dir.glob("slide*.xml.rels"): |
| 441 | source_part = f"ppt/slides/{rels_path.name.removesuffix('.rels')}" |
| 442 | for rel in ET.parse(rels_path).getroot(): |
| 443 | if ( |
| 444 | rel.attrib.get("Type") != NOTES_REL_TYPE |
| 445 | or rel.attrib.get("TargetMode", "").lower() == "external" |
| 446 | ): |
| 447 | continue |
| 448 | target = rel.attrib.get("Target") |
| 449 | if not target: |
| 450 | continue |
| 451 | index = _notes_slide_index( |
| 452 | _resolve_relationship_part(source_part, target) |
| 453 | ) |
| 454 | if index is not None: |
| 455 | used.add(index) |
| 456 | return used |
| 457 | |
| 458 | |
| 459 | def _allocate_notes_slide_part(extract_dir: Path) -> str: |
| 460 | used = _used_notes_slide_indices(extract_dir) |
| 461 | index = max(used, default=0) + 1 |
| 462 | return f"ppt/notesSlides/notesSlide{index}.xml" |
| 463 | |
| 464 | |
| 465 | def read_slide_parts(extract_dir: Path) -> list[SlidePart]: |
| 466 | presentation_path = extract_dir / "ppt" / "presentation.xml" |
| 467 | rels_path = extract_dir / "ppt" / "_rels" / "presentation.xml.rels" |
| 468 | if not presentation_path.exists() or not rels_path.exists(): |
| 469 | raise RuntimeError("PPTX package is missing presentation.xml or its relationships") |
| 470 | |
| 471 | rels_root = ET.parse(rels_path).getroot() |
| 472 | rels: dict[str, str] = {} |
| 473 | for rel in rels_root.findall(f"{{{PACKAGE_REL_NS}}}Relationship"): |
| 474 | rel_id = rel.attrib.get("Id") |
| 475 | target = rel.attrib.get("Target") |
| 476 | if rel_id and target: |
| 477 | rels[rel_id] = target |
| 478 | |
| 479 | presentation_root = ET.parse(presentation_path).getroot() |
| 480 | slide_parts: list[SlidePart] = [] |
| 481 | for index, slide_id in enumerate( |
| 482 | presentation_root.findall(f".//{{{PRESENTATION_NS}}}sldId"), |
| 483 | 1, |
| 484 | ): |
| 485 | rel_id = slide_id.attrib.get(f"{{{REL_NS}}}id") |
| 486 | if not rel_id or rel_id not in rels: |
| 487 | continue |
| 488 | part_name = _target_to_part(rels[rel_id]) |
| 489 | slide_parts.append( |
| 490 | SlidePart( |
| 491 | index=index, |
| 492 | part_name=part_name, |
| 493 | slide_number=_slide_number_from_part(part_name), |
| 494 | ) |
| 495 | ) |
| 496 | if not slide_parts: |
| 497 | raise RuntimeError("No slides found in presentation.xml") |
| 498 | return slide_parts |
| 499 | |
| 500 | |
| 501 | def _source_state_errors( |
| 502 | project_path: Path, |
| 503 | project: dict, |
| 504 | source_pptx: Path, |
| 505 | slides: list[SlidePart], |
| 506 | ) -> list[str]: |
| 507 | """Return source-drift and slide-index consistency errors.""" |
| 508 | errors: list[str] = [] |
| 509 | actual_count = len(slides) |
| 510 | actual_roster = [slide.part_name for slide in slides] |
| 511 | |
| 512 | expected_sha256 = project.get("source_sha256") |
| 513 | if expected_sha256 is not None: |
| 514 | if ( |
| 515 | not isinstance(expected_sha256, str) |
| 516 | or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None |
| 517 | ): |
| 518 | errors.append("project.json source_sha256 is not a lowercase SHA-256 digest") |
| 519 | elif _file_sha256(source_pptx) != expected_sha256: |
| 520 | errors.append("archived source PPTX SHA-256 no longer matches project.json") |
| 521 | |
| 522 | expected_project_count = project.get("slide_count") |
| 523 | if isinstance(expected_project_count, bool) or not isinstance( |
| 524 | expected_project_count, |
| 525 | int, |
| 526 | ): |
| 527 | errors.append("project.json slide_count is not an integer") |
| 528 | elif expected_project_count != actual_count: |
| 529 | errors.append( |
| 530 | "archived source slide count no longer matches project.json: " |
| 531 | f"{actual_count} != {expected_project_count}" |
| 532 | ) |
| 533 | |
| 534 | expected_project_roster = project.get("slide_part_roster") |
| 535 | if expected_project_roster is not None: |
| 536 | if ( |
| 537 | not isinstance(expected_project_roster, list) |
| 538 | or any( |
| 539 | not isinstance(part_name, str) or not part_name |
| 540 | for part_name in expected_project_roster |
| 541 | ) |
| 542 | ): |
| 543 | errors.append("project.json slide_part_roster is not an array of part names") |
| 544 | elif expected_project_roster != actual_roster: |
| 545 | errors.append( |
| 546 | "archived source ordered slide-part roster no longer matches " |
| 547 | "project.json" |
| 548 | ) |
| 549 | |
| 550 | slide_index_path = project_path / "analysis" / "slide_index.json" |
| 551 | if not slide_index_path.is_file(): |
| 552 | errors.append(f"slide index is missing: {slide_index_path}") |
| 553 | return errors |
| 554 | try: |
| 555 | slide_index = _read_json(slide_index_path) |
| 556 | except (OSError, json.JSONDecodeError) as exc: |
| 557 | errors.append(f"unable to read slide index: {exc}") |
| 558 | return errors |
| 559 | |
| 560 | expected_index_count = slide_index.get("slide_count") |
| 561 | if isinstance(expected_index_count, bool) or not isinstance( |
| 562 | expected_index_count, |
| 563 | int, |
| 564 | ): |
| 565 | errors.append("slide_index.json slide_count is not an integer") |
| 566 | elif expected_index_count != actual_count: |
| 567 | errors.append( |
| 568 | "archived source slide count no longer matches slide_index.json: " |
| 569 | f"{actual_count} != {expected_index_count}" |
| 570 | ) |
| 571 | |
| 572 | indexed_slides = slide_index.get("slides") |
| 573 | if not isinstance(indexed_slides, list): |
| 574 | errors.append("slide_index.json slides is not an array") |
| 575 | return errors |
| 576 | expected_roster: list[str] = [] |
| 577 | for index, item in enumerate(indexed_slides, 1): |
| 578 | part_name = item.get("part_name") if isinstance(item, dict) else None |
| 579 | if not isinstance(part_name, str) or not part_name: |
| 580 | errors.append( |
| 581 | f"slide_index.json slides[{index - 1}].part_name is invalid" |
| 582 | ) |
| 583 | continue |
| 584 | expected_roster.append(part_name) |
| 585 | if len(expected_roster) == len(indexed_slides) and expected_roster != actual_roster: |
| 586 | errors.append( |
| 587 | "archived source ordered slide-part roster no longer matches " |
| 588 | "slide_index.json" |
| 589 | ) |
| 590 | return errors |
| 591 | |
| 592 | |
| 593 | def _zip_dir(source_dir: Path, output_path: Path) -> None: |
| 594 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 595 | with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: |
| 596 | for path in sorted(source_dir.rglob("*")): |
| 597 | if path.is_file(): |
| 598 | zf.write(path, path.relative_to(source_dir).as_posix()) |
| 599 | |
| 600 | |
| 601 | def _extract_pptx(source_pptx: Path, extract_dir: Path) -> None: |
| 602 | with zipfile.ZipFile(source_pptx, "r") as zf: |
| 603 | zf.extractall(extract_dir) |
| 604 | |
| 605 | |
| 606 | def _note_path(notes_dir: Path, index: int) -> Path | None: |
| 607 | candidates = [ |
| 608 | notes_dir / f"{index:03d}.md", |
| 609 | notes_dir / f"{index:02d}.md", |
| 610 | notes_dir / f"{index}.md", |
| 611 | notes_dir / f"slide{index:03d}.md", |
| 612 | notes_dir / f"slide{index:02d}.md", |
| 613 | notes_dir / f"slide{index}.md", |
| 614 | ] |
| 615 | for candidate in candidates: |
| 616 | if candidate.exists(): |
| 617 | return candidate |
| 618 | return None |
| 619 | |
| 620 | |
| 621 | def _audio_path(audio_dir: Path, index: int) -> Path | None: |
| 622 | stems = [ |
| 623 | f"{index:03d}", |
| 624 | f"{index:02d}", |
| 625 | str(index), |
| 626 | f"slide{index:03d}", |
| 627 | f"slide{index:02d}", |
| 628 | f"slide{index}", |
| 629 | ] |
| 630 | for stem in stems: |
| 631 | matches = [ |
| 632 | audio_dir / f"{stem}{ext}" |
| 633 | for ext in NARRATION_EXTENSIONS |
| 634 | if (audio_dir / f"{stem}{ext}").exists() |
| 635 | ] |
| 636 | if len(matches) > 1: |
| 637 | names = ", ".join(path.name for path in matches) |
| 638 | raise ValueError( |
| 639 | f"ambiguous audio stem {stem!r}: {names}; " |
| 640 | "keep exactly one supported extension" |
| 641 | ) |
| 642 | if matches: |
| 643 | return matches[0] |
| 644 | return None |
| 645 | |
| 646 | |
| 647 | def _collect_material_readiness( |
| 648 | slides: list[SlidePart], |
| 649 | notes_dir: Path, |
| 650 | audio_dir: Path, |
| 651 | modules: set[str], |
| 652 | *, |
| 653 | required_modules: set[str] | None = None, |
| 654 | ) -> MaterialReadiness: |
| 655 | """Inspect enabled-module inputs once for both validation and application.""" |
| 656 | material_modules = modules if required_modules is None else required_modules |
| 657 | notes_required = "notes" in material_modules |
| 658 | audio_required = ( |
| 659 | "audio" in material_modules or "timings" in material_modules |
| 660 | ) |
| 661 | timings_enabled = "timings" in material_modules |
| 662 | note_paths: dict[int, Path] = {} |
| 663 | audio_paths: dict[int, Path] = {} |
| 664 | audio_durations: dict[int, float] = {} |
| 665 | missing_notes: list[int] = [] |
| 666 | invalid_notes: dict[int, str] = {} |
| 667 | missing_audio: list[int] = [] |
| 668 | invalid_audio: dict[int, str] = {} |
| 669 | |
| 670 | for slide in slides: |
| 671 | note = _note_path(notes_dir, slide.index) |
| 672 | if note is None: |
| 673 | if notes_required: |
| 674 | missing_notes.append(slide.index) |
| 675 | else: |
| 676 | try: |
| 677 | note_text = markdown_to_plain_text( |
| 678 | note.read_text(encoding="utf-8") |
| 679 | ) |
| 680 | except (OSError, UnicodeError) as exc: |
| 681 | if notes_required: |
| 682 | invalid_notes[slide.index] = f"unable to read {note.name}: {exc}" |
| 683 | else: |
| 684 | if note_text: |
| 685 | note_paths[slide.index] = note |
| 686 | elif notes_required: |
| 687 | invalid_notes[slide.index] = f"{note.name} has no spoken text" |
| 688 | |
| 689 | try: |
| 690 | audio = _audio_path(audio_dir, slide.index) |
| 691 | except ValueError as exc: |
| 692 | if audio_required: |
| 693 | invalid_audio[slide.index] = str(exc) |
| 694 | continue |
| 695 | if audio is None: |
| 696 | if audio_required: |
| 697 | missing_audio.append(slide.index) |
| 698 | continue |
| 699 | try: |
| 700 | if not audio.is_file() or audio.stat().st_size <= 0: |
| 701 | raise ValueError(f"{audio.name} is not a non-empty file") |
| 702 | except (OSError, ValueError) as exc: |
| 703 | if audio_required: |
| 704 | invalid_audio[slide.index] = str(exc) |
| 705 | continue |
| 706 | |
| 707 | if audio_required: |
| 708 | duration = probe_audio_duration(audio) |
| 709 | if duration is None: |
| 710 | invalid_audio[slide.index] = ( |
| 711 | f"unable to decode {audio.name} with ffprobe" |
| 712 | ) |
| 713 | continue |
| 714 | if timings_enabled: |
| 715 | audio_durations[slide.index] = duration |
| 716 | audio_paths[slide.index] = audio |
| 717 | |
| 718 | module_errors: list[str] = [] |
| 719 | if timings_enabled and "audio" not in modules: |
| 720 | module_errors.append("timings requires the audio module") |
| 721 | return MaterialReadiness( |
| 722 | note_paths=note_paths, |
| 723 | audio_paths=audio_paths, |
| 724 | audio_durations=audio_durations, |
| 725 | notes_count=len(note_paths), |
| 726 | audio_count=len(audio_paths), |
| 727 | missing_notes=missing_notes, |
| 728 | invalid_notes=invalid_notes, |
| 729 | missing_audio=missing_audio, |
| 730 | invalid_audio=invalid_audio, |
| 731 | module_errors=module_errors, |
| 732 | ) |
| 733 | |
| 734 | |
| 735 | def _material_readiness_messages(readiness: MaterialReadiness) -> list[str]: |
| 736 | messages = list(readiness.module_errors) |
| 737 | if readiness.missing_notes: |
| 738 | messages.append( |
| 739 | "missing notes for slide(s): " |
| 740 | + ", ".join(str(index) for index in readiness.missing_notes) |
| 741 | ) |
| 742 | if readiness.invalid_notes: |
| 743 | messages.append( |
| 744 | "invalid notes: " |
| 745 | + "; ".join( |
| 746 | f"slide {index}: {reason}" |
| 747 | for index, reason in sorted(readiness.invalid_notes.items()) |
| 748 | ) |
| 749 | ) |
| 750 | if readiness.missing_audio: |
| 751 | messages.append( |
| 752 | "missing audio for slide(s): " |
| 753 | + ", ".join(str(index) for index in readiness.missing_audio) |
| 754 | ) |
| 755 | if readiness.invalid_audio: |
| 756 | messages.append( |
| 757 | "invalid audio: " |
| 758 | + "; ".join( |
| 759 | f"slide {index}: {reason}" |
| 760 | for index, reason in sorted(readiness.invalid_audio.items()) |
| 761 | ) |
| 762 | ) |
| 763 | return messages |
| 764 | |
| 765 | |
| 766 | def _material_readiness_report_fields( |
| 767 | readiness: MaterialReadiness, |
| 768 | ) -> dict[str, object]: |
| 769 | return { |
| 770 | "notes_count": readiness.notes_count, |
| 771 | "audio_count": readiness.audio_count, |
| 772 | "missing_notes": readiness.missing_notes, |
| 773 | "invalid_notes": sorted(readiness.invalid_notes), |
| 774 | "invalid_note_reasons": readiness.invalid_notes, |
| 775 | "missing_audio": readiness.missing_audio, |
| 776 | "invalid_audio": sorted(readiness.invalid_audio), |
| 777 | "invalid_audio_reasons": readiness.invalid_audio, |
| 778 | "module_errors": readiness.module_errors, |
| 779 | } |
| 780 | |
| 781 | |
| 782 | def _add_override(content_types: str, part_name: str, content_type: str) -> str: |
| 783 | if re.search( |
| 784 | rf'<Override\b[^>]*\bPartName="/{re.escape(part_name)}"[^>]*/>', |
| 785 | content_types, |
| 786 | ): |
| 787 | return content_types |
| 788 | override = f' <Override PartName="/{part_name}" ContentType="{content_type}"/>' |
| 789 | return content_types.replace("</Types>", override + "\n</Types>") |
| 790 | |
| 791 | |
| 792 | def _add_notes_content_types(content_types: str, note_parts: set[str]) -> str: |
| 793 | content_types = _add_override(content_types, "ppt/theme/theme2.xml", CONTENT_TYPE_THEME) |
| 794 | content_types = _add_override( |
| 795 | content_types, |
| 796 | "ppt/notesMasters/notesMaster1.xml", |
| 797 | CONTENT_TYPE_NOTES_MASTER, |
| 798 | ) |
| 799 | for part_name in sorted(note_parts): |
| 800 | content_types = _add_override( |
| 801 | content_types, |
| 802 | part_name, |
| 803 | CONTENT_TYPE_NOTES_SLIDE, |
| 804 | ) |
| 805 | return content_types |
| 806 | |
| 807 | |
| 808 | def _apply_notes( |
| 809 | extract_dir: Path, |
| 810 | slide: SlidePart, |
| 811 | note_md: Path, |
| 812 | ) -> str | None: |
| 813 | notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8")) |
| 814 | if not notes_text: |
| 815 | return None |
| 816 | |
| 817 | _ensure_notes_master(extract_dir) |
| 818 | slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) |
| 819 | _ensure_rels_file(slide_rels) |
| 820 | notes_part = _notes_slide_part_for_slide(extract_dir, slide) |
| 821 | if notes_part is None: |
| 822 | notes_part = _allocate_notes_slide_part(extract_dir) |
| 823 | target = posixpath.relpath( |
| 824 | notes_part, |
| 825 | start=posixpath.dirname(slide.part_name), |
| 826 | ) |
| 827 | _append_relationship(slide_rels, NOTES_REL_TYPE, target) |
| 828 | |
| 829 | notes_xml_path = extract_dir / notes_part |
| 830 | notes_xml_path.parent.mkdir(parents=True, exist_ok=True) |
| 831 | notes_xml_path.write_text( |
| 832 | create_notes_slide_xml(slide.slide_number, notes_text), |
| 833 | encoding="utf-8", |
| 834 | ) |
| 835 | |
| 836 | notes_rels_path = _relationship_file_for_part(extract_dir, notes_part) |
| 837 | notes_rels_path.parent.mkdir(parents=True, exist_ok=True) |
| 838 | notes_rels_path.write_text( |
| 839 | create_notes_slide_rels_xml(slide.slide_number), |
| 840 | encoding="utf-8", |
| 841 | ) |
| 842 | return notes_part |
| 843 | |
| 844 | |
| 845 | def _native_audio_carriers( |
| 846 | extract_dir: Path, |
| 847 | slides: list[SlidePart], |
| 848 | ) -> dict[int, list[str]]: |
| 849 | """Return existing tool-owned narration carrier names by public slide.""" |
| 850 | carriers: dict[int, list[str]] = {} |
| 851 | for slide in slides: |
| 852 | slide_root = ET.parse(extract_dir / slide.part_name).getroot() |
| 853 | names = sorted( |
| 854 | { |
| 855 | name |
| 856 | for element in slide_root.iter( |
| 857 | f"{{{PRESENTATION_NS}}}cNvPr" |
| 858 | ) |
| 859 | if (name := element.attrib.get("name", "")).startswith( |
| 860 | "native_enhance_audio_" |
| 861 | ) |
| 862 | } |
| 863 | ) |
| 864 | if names: |
| 865 | carriers[slide.index] = names |
| 866 | return carriers |
| 867 | |
| 868 | |
| 869 | def _allocate_media_name(media_dir: Path, preferred_name: str) -> str: |
| 870 | preferred_path = media_dir / preferred_name |
| 871 | if not preferred_path.exists(): |
| 872 | return preferred_name |
| 873 | stem = preferred_path.stem |
| 874 | suffix = preferred_path.suffix |
| 875 | index = 2 |
| 876 | while True: |
| 877 | candidate_name = f"{stem}_{index}{suffix}" |
| 878 | if not (media_dir / candidate_name).exists(): |
| 879 | return candidate_name |
| 880 | index += 1 |
| 881 | |
| 882 | |
| 883 | def _ensure_audio_poster(media_dir: Path) -> str: |
| 884 | preferred_name = "native_enhance_audio_poster.png" |
| 885 | for candidate in sorted( |
| 886 | media_dir.glob("native_enhance_audio_poster*.png") |
| 887 | ): |
| 888 | if not candidate.is_file(): |
| 889 | continue |
| 890 | try: |
| 891 | if candidate.read_bytes() == AUDIO_MARKER_PNG_BYTES: |
| 892 | return candidate.name |
| 893 | except OSError: |
| 894 | continue |
| 895 | poster_name = _allocate_media_name(media_dir, preferred_name) |
| 896 | (media_dir / poster_name).write_bytes(AUDIO_MARKER_PNG_BYTES) |
| 897 | return poster_name |
| 898 | |
| 899 | |
| 900 | def _apply_audio( |
| 901 | extract_dir: Path, |
| 902 | slide: SlidePart, |
| 903 | audio_path: Path, |
| 904 | *, |
| 905 | enter: EnterUpdate, |
| 906 | timings_enabled: bool, |
| 907 | narration_padding: float, |
| 908 | narration_start_floor: float, |
| 909 | audio_duration: float | None = None, |
| 910 | ) -> bool: |
| 911 | media_dir = extract_dir / "ppt" / "media" |
| 912 | media_dir.mkdir(parents=True, exist_ok=True) |
| 913 | |
| 914 | ext = audio_path.suffix.lower() |
| 915 | media_name = _allocate_media_name( |
| 916 | media_dir, |
| 917 | f"native_enhance_audio_{slide.index:03d}{ext}", |
| 918 | ) |
| 919 | shutil.copy2(audio_path, media_dir / media_name) |
| 920 | |
| 921 | poster_name = _ensure_audio_poster(media_dir) |
| 922 | |
| 923 | slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) |
| 924 | _ensure_rels_file(slide_rels) |
| 925 | media_rid = _append_relationship(slide_rels, MEDIA_REL_TYPE, f"../media/{media_name}") |
| 926 | audio_rid = _append_relationship(slide_rels, AUDIO_REL_TYPE, f"../media/{media_name}") |
| 927 | poster_rid = _append_relationship(slide_rels, IMAGE_REL_TYPE, f"../media/{poster_name}") |
| 928 | |
| 929 | slide_xml_path = extract_dir / slide.part_name |
| 930 | slide_xml = slide_xml_path.read_text(encoding="utf-8") |
| 931 | source_animation_fingerprint = object_animation_fingerprint(slide_xml) |
| 932 | source_transition = read_slide_transition_xml(slide_xml) |
| 933 | if enter.policy == "replace": |
| 934 | transition_duration = enter.duration |
| 935 | elif enter.policy == "none": |
| 936 | transition_duration = 0.0 |
| 937 | else: |
| 938 | # Legacy spd-only transitions expose no exact milliseconds. Treat |
| 939 | # those as unknown and keep the full configured floor after the |
| 940 | # preserved transition rather than guessing an application duration. |
| 941 | transition_duration = ( |
| 942 | source_transition.duration_ms / 1000 |
| 943 | if source_transition.duration_ms is not None |
| 944 | else 0.0 |
| 945 | ) |
| 946 | narration_lead_in = narration_lead_in_seconds( |
| 947 | transition_duration, |
| 948 | start_floor=narration_start_floor, |
| 949 | ) |
| 950 | shape_id = next_shape_id(slide_xml) |
| 951 | slide_xml = inject_narration( |
| 952 | slide_xml, |
| 953 | shape_id=shape_id, |
| 954 | shape_name=media_name, |
| 955 | audio_rid=audio_rid, |
| 956 | media_rid=media_rid, |
| 957 | poster_rid=poster_rid, |
| 958 | start_delay=narration_lead_in, |
| 959 | ) |
| 960 | |
| 961 | advance = AdvanceUpdate(mode="preserve") |
| 962 | duration = audio_duration |
| 963 | if duration is None and ( |
| 964 | timings_enabled or source_transition.advance_after_ms is not None |
| 965 | ): |
| 966 | duration = probe_audio_duration(audio_path) |
| 967 | if ( |
| 968 | not timings_enabled |
| 969 | and source_transition.advance_after_ms is not None |
| 970 | ): |
| 971 | if duration is None: |
| 972 | raise RuntimeError( |
| 973 | f"Unable to validate narration against slide {slide.index} " |
| 974 | f"auto-advance with ffprobe: {audio_path}" |
| 975 | ) |
| 976 | required_playback_ms = round((narration_lead_in + duration) * 1000) |
| 977 | if source_transition.advance_after_ms < required_playback_ms: |
| 978 | raise RuntimeError( |
| 979 | f"Slide {slide.index} advances after " |
| 980 | f"{source_transition.advance_after_ms} ms, before delayed " |
| 981 | f"narration can finish at {required_playback_ms} ms; enable " |
| 982 | "timings or lengthen the source auto-advance" |
| 983 | ) |
| 984 | if timings_enabled: |
| 985 | if duration is None: |
| 986 | raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}") |
| 987 | advance = AdvanceUpdate( |
| 988 | mode="narration", |
| 989 | after=narration_lead_in + duration + narration_padding, |
| 990 | ) |
| 991 | |
| 992 | wrote_advance = False |
| 993 | if enter.policy != "preserve" or timings_enabled: |
| 994 | slide_xml, wrote_advance = apply_slide_motion_xml( |
| 995 | slide_xml, |
| 996 | enter=enter, |
| 997 | advance=advance, |
| 998 | ) |
| 999 | if object_animation_fingerprint(slide_xml) != source_animation_fingerprint: |
| 1000 | raise RuntimeError( |
| 1001 | f"Slide {slide.index} object animations changed while adding narration" |
| 1002 | ) |
| 1003 | slide_xml_path.write_text(slide_xml, encoding="utf-8") |
| 1004 | return timings_enabled and wrote_advance |
| 1005 | |
| 1006 | |
| 1007 | def _update_content_types( |
| 1008 | extract_dir: Path, |
| 1009 | note_parts: set[str], |
| 1010 | audio_exts: set[str], |
| 1011 | ) -> None: |
| 1012 | content_types_path = extract_dir / "[Content_Types].xml" |
| 1013 | content_types = content_types_path.read_text(encoding="utf-8") |
| 1014 | if note_parts: |
| 1015 | content_types = _add_notes_content_types(content_types, note_parts) |
| 1016 | for ext in sorted(audio_exts): |
| 1017 | content_type = AUDIO_CONTENT_TYPES.get(ext) |
| 1018 | if content_type: |
| 1019 | content_types = _add_default_content_type(content_types, ext, content_type) |
| 1020 | if audio_exts: |
| 1021 | content_types = _add_default_content_type(content_types, "png", "image/png") |
| 1022 | content_types_path.write_text(content_types, encoding="utf-8") |
| 1023 | |
| 1024 | |
| 1025 | def _project_paths(project_path: Path) -> tuple[Path, Path, Path, Path]: |
| 1026 | project = _read_json(project_path / "project.json") |
| 1027 | source_pptx = project_path / project["source_pptx"] |
| 1028 | notes_dir = project_path / project["notes_dir"] |
| 1029 | audio_dir = project_path / project["audio_dir"] |
| 1030 | exports_dir = project_path / project["exports_dir"] |
| 1031 | return source_pptx, notes_dir, audio_dir, exports_dir |
| 1032 | |
| 1033 | |
| 1034 | def _output_path_error( |
| 1035 | project_path: Path, |
| 1036 | project: dict, |
| 1037 | source_pptx: Path, |
| 1038 | output_path: Path, |
| 1039 | ) -> str | None: |
| 1040 | if output_path.suffix.lower() != ".pptx": |
| 1041 | return f"output must use a .pptx extension: {output_path}" |
| 1042 | if output_path == source_pptx.resolve(): |
| 1043 | return "output must not overwrite the archived source PPTX" |
| 1044 | |
| 1045 | source_import = project.get("source_import") |
| 1046 | if isinstance(source_import, dict): |
| 1047 | original_path = source_import.get("original_path") |
| 1048 | if isinstance(original_path, str) and original_path: |
| 1049 | try: |
| 1050 | original = Path(original_path).expanduser().resolve() |
| 1051 | except OSError: |
| 1052 | original = None |
| 1053 | if original is not None and output_path == original: |
| 1054 | return "output must not overwrite the original source PPTX" |
| 1055 | |
| 1056 | protected = ( |
| 1057 | project_path / "sources", |
| 1058 | project_path / "analysis", |
| 1059 | project_path / "notes", |
| 1060 | project_path / "audio", |
| 1061 | project_path / "validation", |
| 1062 | ) |
| 1063 | for directory in protected: |
| 1064 | if _is_relative_to(output_path, directory): |
| 1065 | return ( |
| 1066 | "output must not be written inside native-enhance control " |
| 1067 | f"directory: {directory}" |
| 1068 | ) |
| 1069 | return None |
| 1070 | |
| 1071 | |
| 1072 | def _plan_path(project_path: Path) -> Path: |
| 1073 | return project_path / "analysis" / "enhancement_plan.json" |
| 1074 | |
| 1075 | |
| 1076 | def _load_enhancement_plan(project_path: Path) -> dict: |
| 1077 | path = _plan_path(project_path) |
| 1078 | if not path.exists(): |
| 1079 | return {} |
| 1080 | return _read_json(path) |
| 1081 | |
| 1082 | |
| 1083 | def _enabled_modules(plan: dict) -> set[str]: |
| 1084 | modules = plan.get("modules") |
| 1085 | if not isinstance(modules, dict): |
| 1086 | return set(_WRITABLE_MODULES) |
| 1087 | enabled: set[str] = set() |
| 1088 | for name in _WRITABLE_MODULES: |
| 1089 | config = modules.get(name) |
| 1090 | if isinstance(config, dict) and config.get("enabled") is True: |
| 1091 | enabled.add(name) |
| 1092 | return enabled |
| 1093 | |
| 1094 | |
| 1095 | def _resolve_enter_update( |
| 1096 | *, |
| 1097 | cli_effect: str | None, |
| 1098 | configured_effect: object, |
| 1099 | configured_effect_options: object = None, |
| 1100 | transitions_enabled: bool, |
| 1101 | duration: float, |
| 1102 | ) -> EnterUpdate: |
| 1103 | if cli_effect is None and not transitions_enabled: |
| 1104 | if configured_effect == "none": |
| 1105 | normalize_transition_effect_request( |
| 1106 | configured_effect, |
| 1107 | configured_effect_options, |
| 1108 | ) |
| 1109 | return EnterUpdate(policy="none", effect=None, duration=duration) |
| 1110 | return EnterUpdate(policy="preserve", duration=duration) |
| 1111 | |
| 1112 | effect = cli_effect if cli_effect is not None else configured_effect |
| 1113 | if effect == "none": |
| 1114 | normalize_transition_effect_request( |
| 1115 | effect, |
| 1116 | None if cli_effect is not None else configured_effect_options, |
| 1117 | ) |
| 1118 | return EnterUpdate(policy="none", effect=None, duration=duration) |
| 1119 | effect, effect_options = normalize_transition_effect_request( |
| 1120 | effect, |
| 1121 | None if cli_effect is not None else configured_effect_options, |
| 1122 | allow_none=False, |
| 1123 | ) |
| 1124 | |
| 1125 | return EnterUpdate( |
| 1126 | policy="replace", |
| 1127 | effect=effect, |
| 1128 | duration=duration, |
| 1129 | effect_options=effect_options, |
| 1130 | ) |
| 1131 | |
| 1132 | |
| 1133 | def _plan_confirmed(plan: dict) -> bool: |
| 1134 | return plan.get("status") == "confirmed" |
| 1135 | |
| 1136 | |
| 1137 | def _native_transition_config( |
| 1138 | transition: str, |
| 1139 | duration: float, |
| 1140 | effect_options: object = None, |
| 1141 | ) -> dict[str, object]: |
| 1142 | if transition == "none": |
| 1143 | normalize_transition_effect_request(transition, effect_options) |
| 1144 | return {"effect": "none", "duration": duration} |
| 1145 | effect, effect_options = normalize_transition_effect_request( |
| 1146 | transition, |
| 1147 | effect_options, |
| 1148 | allow_none=False, |
| 1149 | ) |
| 1150 | config: dict[str, object] = { |
| 1151 | "effect": effect, |
| 1152 | "duration": duration, |
| 1153 | } |
| 1154 | if effect_options: |
| 1155 | config["effect_options"] = effect_options |
| 1156 | return config |
| 1157 | |
| 1158 | |
| 1159 | def _module_config(plan: dict, name: str) -> dict: |
| 1160 | modules = plan.get("modules") |
| 1161 | if not isinstance(modules, dict): |
| 1162 | return {} |
| 1163 | config = modules.get(name) |
| 1164 | return config if isinstance(config, dict) else {} |
| 1165 | |
| 1166 | |
| 1167 | def _preserved_enabled(plan: dict, name: str, default: bool) -> bool: |
| 1168 | config = _module_config(plan, name) |
| 1169 | if "enabled" not in config: |
| 1170 | return default |
| 1171 | value = config["enabled"] |
| 1172 | if not isinstance(value, bool): |
| 1173 | raise ValueError( |
| 1174 | f"enhancement plan module {name}.enabled must be a boolean" |
| 1175 | ) |
| 1176 | return value |
| 1177 | |
| 1178 | |
| 1179 | def _resolved_draft_transition_config( |
| 1180 | project: dict, |
| 1181 | existing_plan: dict, |
| 1182 | *, |
| 1183 | transition: str | None, |
| 1184 | transition_duration: float | None, |
| 1185 | apply_transition_without_audio: bool | None, |
| 1186 | ) -> tuple[bool, dict[str, object]]: |
| 1187 | existing = _module_config(existing_plan, "transitions") |
| 1188 | project_default = ( |
| 1189 | project.get("transition") |
| 1190 | if isinstance(project.get("transition"), dict) |
| 1191 | else {} |
| 1192 | ) |
| 1193 | |
| 1194 | if transition is not None: |
| 1195 | raw_effect: object = transition |
| 1196 | raw_options: object = None |
| 1197 | elif "effect" in existing: |
| 1198 | raw_effect = existing["effect"] |
| 1199 | raw_options = existing.get("effect_options") |
| 1200 | elif "effect" in project_default: |
| 1201 | raw_effect = project_default["effect"] |
| 1202 | raw_options = project_default.get("effect_options") |
| 1203 | else: |
| 1204 | raw_effect = "fade" |
| 1205 | raw_options = None |
| 1206 | |
| 1207 | if not isinstance(raw_effect, str): |
| 1208 | raise ValueError("transition effect must be a string") |
| 1209 | if transition_duration is not None: |
| 1210 | raw_duration: object = transition_duration |
| 1211 | elif "duration" in existing: |
| 1212 | raw_duration = existing["duration"] |
| 1213 | elif "duration" in project_default: |
| 1214 | raw_duration = project_default["duration"] |
| 1215 | else: |
| 1216 | raw_duration = 0.5 |
| 1217 | duration = validate_seconds( |
| 1218 | raw_duration, |
| 1219 | "transition duration", |
| 1220 | allow_zero=False, |
| 1221 | ) |
| 1222 | config = _native_transition_config( |
| 1223 | raw_effect, |
| 1224 | duration, |
| 1225 | raw_options, |
| 1226 | ) |
| 1227 | |
| 1228 | if apply_transition_without_audio is None: |
| 1229 | raw_apply_without_audio = existing.get( |
| 1230 | "apply_without_audio", |
| 1231 | False, |
| 1232 | ) |
| 1233 | else: |
| 1234 | raw_apply_without_audio = apply_transition_without_audio |
| 1235 | if not isinstance(raw_apply_without_audio, bool): |
| 1236 | raise ValueError("transition apply_without_audio must be a boolean") |
| 1237 | config["apply_without_audio"] = raw_apply_without_audio |
| 1238 | |
| 1239 | if "slides" in existing: |
| 1240 | slides = existing["slides"] |
| 1241 | if not isinstance(slides, dict): |
| 1242 | raise ValueError("transition slides must be an object") |
| 1243 | config["slides"] = { |
| 1244 | str(key): dict(value) if isinstance(value, dict) else value |
| 1245 | for key, value in slides.items() |
| 1246 | } |
| 1247 | |
| 1248 | if transition is not None: |
| 1249 | enabled = transition != "none" |
| 1250 | else: |
| 1251 | enabled = _preserved_enabled( |
| 1252 | existing_plan, |
| 1253 | "transitions", |
| 1254 | raw_effect != "none", |
| 1255 | ) |
| 1256 | return enabled, config |
| 1257 | |
| 1258 | |
| 1259 | def _build_enhancement_plan( |
| 1260 | project: dict, |
| 1261 | *, |
| 1262 | slide_count: int, |
| 1263 | notes_count: int, |
| 1264 | audio_count: int, |
| 1265 | transition: str | None, |
| 1266 | transition_duration: float | None, |
| 1267 | narration_padding: float | None, |
| 1268 | narration_start_floor: float | None, |
| 1269 | apply_transition_without_audio: bool | None, |
| 1270 | existing_plan: dict | None = None, |
| 1271 | ) -> dict: |
| 1272 | previous = existing_plan or {} |
| 1273 | audio_enabled = _preserved_enabled(previous, "audio", True) |
| 1274 | notes_enabled = _preserved_enabled(previous, "notes", True) or audio_enabled |
| 1275 | timings_enabled = _preserved_enabled(previous, "timings", True) |
| 1276 | previous_timings = _module_config(previous, "timings") |
| 1277 | raw_padding: object |
| 1278 | if narration_padding is not None: |
| 1279 | raw_padding = narration_padding |
| 1280 | else: |
| 1281 | raw_padding = previous_timings.get("narration_padding", 0.4) |
| 1282 | resolved_padding = validate_seconds( |
| 1283 | raw_padding, |
| 1284 | "narration padding", |
| 1285 | allow_zero=True, |
| 1286 | ) |
| 1287 | raw_start_floor: object |
| 1288 | if narration_start_floor is not None: |
| 1289 | raw_start_floor = narration_start_floor |
| 1290 | else: |
| 1291 | raw_start_floor = previous_timings.get( |
| 1292 | "narration_start_floor", |
| 1293 | DEFAULT_NARRATION_START_FLOOR, |
| 1294 | ) |
| 1295 | resolved_start_floor = validate_seconds( |
| 1296 | raw_start_floor, |
| 1297 | "narration start floor", |
| 1298 | allow_zero=True, |
| 1299 | ) |
| 1300 | transitions_enabled, transition_config = _resolved_draft_transition_config( |
| 1301 | project, |
| 1302 | previous, |
| 1303 | transition=transition, |
| 1304 | transition_duration=transition_duration, |
| 1305 | apply_transition_without_audio=apply_transition_without_audio, |
| 1306 | ) |
| 1307 | return { |
| 1308 | "schema": PLAN_SCHEMA, |
| 1309 | "status": "draft", |
| 1310 | "source_pptx": project.get("source_pptx"), |
| 1311 | "slide_count": slide_count, |
| 1312 | "modules": { |
| 1313 | "notes": { |
| 1314 | "enabled": notes_enabled, |
| 1315 | "requires_confirmation": True, |
| 1316 | "status": ( |
| 1317 | "disabled" |
| 1318 | if not notes_enabled |
| 1319 | else ( |
| 1320 | "coverage_complete" |
| 1321 | if notes_count == slide_count |
| 1322 | else "needs_notes" |
| 1323 | ) |
| 1324 | ), |
| 1325 | "coverage": {"present": notes_count, "total": slide_count}, |
| 1326 | }, |
| 1327 | "audio": { |
| 1328 | "enabled": audio_enabled, |
| 1329 | "requires_confirmation": True, |
| 1330 | "status": ( |
| 1331 | "disabled" |
| 1332 | if not audio_enabled |
| 1333 | else ( |
| 1334 | "coverage_complete" |
| 1335 | if audio_count == slide_count |
| 1336 | else "needs_audio" |
| 1337 | ) |
| 1338 | ), |
| 1339 | "coverage": {"present": audio_count, "total": slide_count}, |
| 1340 | "decodability": "unchecked", |
| 1341 | }, |
| 1342 | "timings": { |
| 1343 | "enabled": timings_enabled, |
| 1344 | "requires_confirmation": True, |
| 1345 | "status": ( |
| 1346 | "disabled" |
| 1347 | if not timings_enabled |
| 1348 | else ( |
| 1349 | "audio_coverage_complete" |
| 1350 | if audio_enabled and audio_count == slide_count |
| 1351 | else "blocked_until_audio" |
| 1352 | ) |
| 1353 | ), |
| 1354 | "source": "audio_duration", |
| 1355 | "narration_padding": resolved_padding, |
| 1356 | "narration_start_floor": resolved_start_floor, |
| 1357 | }, |
| 1358 | "transitions": { |
| 1359 | "enabled": transitions_enabled, |
| 1360 | "requires_confirmation": True, |
| 1361 | "status": ( |
| 1362 | "ready" |
| 1363 | if ( |
| 1364 | transitions_enabled |
| 1365 | or transition_config.get("effect") == "none" |
| 1366 | or bool(transition_config.get("slides")) |
| 1367 | ) |
| 1368 | else "disabled" |
| 1369 | ), |
| 1370 | **transition_config, |
| 1371 | }, |
| 1372 | }, |
| 1373 | "not_in_v1": [ |
| 1374 | "object_animation", |
| 1375 | "visible_watermark", |
| 1376 | "footer_or_logo_insertion", |
| 1377 | "background_music", |
| 1378 | "media_compression", |
| 1379 | ], |
| 1380 | } |
| 1381 | |
| 1382 | |
| 1383 | def _resolve_slide_enter( |
| 1384 | base: EnterUpdate, |
| 1385 | override: dict, |
| 1386 | *, |
| 1387 | slide_index: int, |
| 1388 | ) -> EnterUpdate: |
| 1389 | unknown = sorted(set(override) - _TRANSITION_OVERRIDE_FIELDS) |
| 1390 | if unknown: |
| 1391 | raise ValueError( |
| 1392 | f"transition slides.{slide_index} has unknown field(s): " |
| 1393 | + ", ".join(unknown) |
| 1394 | ) |
| 1395 | |
| 1396 | raw_duration = override.get("duration", base.duration) |
| 1397 | duration = validate_seconds( |
| 1398 | raw_duration, |
| 1399 | f"transition slides.{slide_index}.duration", |
| 1400 | allow_zero=False, |
| 1401 | ) |
| 1402 | effect = override.get("effect") |
| 1403 | if effect == "preserve": |
| 1404 | if "effect_options" in override: |
| 1405 | raise ValueError( |
| 1406 | f"transition slides.{slide_index} preserve cannot have " |
| 1407 | "effect_options" |
| 1408 | ) |
| 1409 | return EnterUpdate(policy="preserve", duration=duration) |
| 1410 | |
| 1411 | if effect is None: |
| 1412 | if base.policy == "preserve": |
| 1413 | if "effect_options" in override: |
| 1414 | raise ValueError( |
| 1415 | f"transition slides.{slide_index} effect_options requires " |
| 1416 | "a native effect" |
| 1417 | ) |
| 1418 | return EnterUpdate(policy="preserve", duration=duration) |
| 1419 | if base.policy == "none": |
| 1420 | if "effect_options" in override: |
| 1421 | raise ValueError( |
| 1422 | f"transition slides.{slide_index} none cannot have " |
| 1423 | "effect_options" |
| 1424 | ) |
| 1425 | return EnterUpdate(policy="none", effect=None, duration=duration) |
| 1426 | effect = base.effect |
| 1427 | effect_options = override.get( |
| 1428 | "effect_options", |
| 1429 | base.effect_options, |
| 1430 | ) |
| 1431 | else: |
| 1432 | if not isinstance(effect, str): |
| 1433 | raise ValueError( |
| 1434 | f"transition slides.{slide_index}.effect must be a string" |
| 1435 | ) |
| 1436 | effect_options = override.get("effect_options") |
| 1437 | |
| 1438 | return _resolve_enter_update( |
| 1439 | cli_effect=None, |
| 1440 | configured_effect=effect, |
| 1441 | configured_effect_options=effect_options, |
| 1442 | transitions_enabled=True, |
| 1443 | duration=duration, |
| 1444 | ) |
| 1445 | |
| 1446 | |
| 1447 | def _validate_plan_modules( |
| 1448 | plan: dict, |
| 1449 | *, |
| 1450 | allow_legacy_audio_without_notes: bool = False, |
| 1451 | ) -> None: |
| 1452 | if plan and plan.get("schema") != PLAN_SCHEMA: |
| 1453 | raise ValueError( |
| 1454 | f"unsupported enhancement plan schema: {plan.get('schema')!r}" |
| 1455 | ) |
| 1456 | modules_cfg = plan.get("modules") |
| 1457 | if modules_cfg is None: |
| 1458 | return |
| 1459 | if not isinstance(modules_cfg, dict): |
| 1460 | raise ValueError("enhancement plan modules must be an object") |
| 1461 | |
| 1462 | unknown_modules = sorted(set(modules_cfg) - set(_WRITABLE_MODULES)) |
| 1463 | if unknown_modules: |
| 1464 | raise ValueError( |
| 1465 | "enhancement plan has unknown module(s): " |
| 1466 | + ", ".join(unknown_modules) |
| 1467 | ) |
| 1468 | if plan.get("schema") == PLAN_SCHEMA: |
| 1469 | missing_modules = [ |
| 1470 | name |
| 1471 | for name in _WRITABLE_MODULES |
| 1472 | if name not in modules_cfg |
| 1473 | ] |
| 1474 | if missing_modules: |
| 1475 | raise ValueError( |
| 1476 | "enhancement plan is missing module(s): " |
| 1477 | + ", ".join(missing_modules) |
| 1478 | ) |
| 1479 | for name in _WRITABLE_MODULES: |
| 1480 | config = modules_cfg.get(name) |
| 1481 | if config is not None and not isinstance(config, dict): |
| 1482 | raise ValueError( |
| 1483 | f"enhancement plan module {name} must be an object" |
| 1484 | ) |
| 1485 | if ( |
| 1486 | isinstance(config, dict) |
| 1487 | and ( |
| 1488 | "enabled" not in config |
| 1489 | or not isinstance(config["enabled"], bool) |
| 1490 | ) |
| 1491 | ): |
| 1492 | raise ValueError( |
| 1493 | f"enhancement plan module {name}.enabled must be a boolean" |
| 1494 | ) |
| 1495 | notes_config = modules_cfg.get("notes") |
| 1496 | audio_config = modules_cfg.get("audio") |
| 1497 | legacy_audio_without_notes = ( |
| 1498 | allow_legacy_audio_without_notes |
| 1499 | and isinstance(notes_config, dict) |
| 1500 | and notes_config.get("enabled") is False |
| 1501 | ) |
| 1502 | if ( |
| 1503 | isinstance(audio_config, dict) |
| 1504 | and audio_config.get("enabled") is True |
| 1505 | and not legacy_audio_without_notes |
| 1506 | and ( |
| 1507 | not isinstance(notes_config, dict) |
| 1508 | or notes_config.get("enabled") is not True |
| 1509 | ) |
| 1510 | ): |
| 1511 | raise ValueError( |
| 1512 | "enhancement plan audio requires notes.enabled: true" |
| 1513 | ) |
| 1514 | |
| 1515 | |
| 1516 | def _resolve_transition_plan( |
| 1517 | project: dict, |
| 1518 | plan: dict, |
| 1519 | slides: list[SlidePart], |
| 1520 | *, |
| 1521 | cli_effect: str | None = None, |
| 1522 | cli_duration: float | None = None, |
| 1523 | cli_apply_without_audio: bool = False, |
| 1524 | ) -> ResolvedTransitionPlan: |
| 1525 | _validate_plan_modules(plan) |
| 1526 | plan_slide_count = plan.get("slide_count") |
| 1527 | if plan_slide_count is not None and plan_slide_count != len(slides): |
| 1528 | raise ValueError( |
| 1529 | "enhancement plan slide_count no longer matches the archived " |
| 1530 | f"source: {plan_slide_count!r} != {len(slides)}" |
| 1531 | ) |
| 1532 | |
| 1533 | modules = _enabled_modules(plan) |
| 1534 | transitions_cfg = _module_config(plan, "transitions") |
| 1535 | unknown = sorted(set(transitions_cfg) - _TRANSITION_MODULE_FIELDS) |
| 1536 | if unknown: |
| 1537 | raise ValueError( |
| 1538 | "transition module has unknown field(s): " + ", ".join(unknown) |
| 1539 | ) |
| 1540 | |
| 1541 | project_transition = ( |
| 1542 | project.get("transition") |
| 1543 | if isinstance(project.get("transition"), dict) |
| 1544 | else {} |
| 1545 | ) |
| 1546 | if ( |
| 1547 | cli_effect is None |
| 1548 | and "effect_options" in transitions_cfg |
| 1549 | and "effect" not in transitions_cfg |
| 1550 | ): |
| 1551 | raise ValueError("transition effect_options requires an explicit effect") |
| 1552 | if ( |
| 1553 | cli_effect is None |
| 1554 | and "effect_options" in project_transition |
| 1555 | and "effect" not in project_transition |
| 1556 | and "effect" not in transitions_cfg |
| 1557 | ): |
| 1558 | raise ValueError("transition effect_options requires an explicit effect") |
| 1559 | |
| 1560 | if "effect" in transitions_cfg: |
| 1561 | configured_effect = transitions_cfg["effect"] |
| 1562 | configured_options = transitions_cfg.get("effect_options") |
| 1563 | elif "effect" in project_transition: |
| 1564 | configured_effect = project_transition["effect"] |
| 1565 | configured_options = project_transition.get("effect_options") |
| 1566 | else: |
| 1567 | configured_effect = "fade" |
| 1568 | configured_options = None |
| 1569 | |
| 1570 | if cli_duration is not None: |
| 1571 | raw_duration: object = cli_duration |
| 1572 | elif "duration" in transitions_cfg: |
| 1573 | raw_duration = transitions_cfg["duration"] |
| 1574 | elif "duration" in project_transition: |
| 1575 | raw_duration = project_transition["duration"] |
| 1576 | else: |
| 1577 | raw_duration = 0.5 |
| 1578 | duration = validate_seconds( |
| 1579 | raw_duration, |
| 1580 | "transition duration", |
| 1581 | allow_zero=False, |
| 1582 | ) |
| 1583 | |
| 1584 | selected_base = _resolve_enter_update( |
| 1585 | cli_effect=cli_effect, |
| 1586 | configured_effect=configured_effect, |
| 1587 | configured_effect_options=configured_options, |
| 1588 | transitions_enabled=True, |
| 1589 | duration=duration, |
| 1590 | ) |
| 1591 | global_enter = _resolve_enter_update( |
| 1592 | cli_effect=cli_effect, |
| 1593 | configured_effect=configured_effect, |
| 1594 | configured_effect_options=configured_options, |
| 1595 | transitions_enabled="transitions" in modules, |
| 1596 | duration=duration, |
| 1597 | ) |
| 1598 | |
| 1599 | raw_apply_without_audio = transitions_cfg.get( |
| 1600 | "apply_without_audio", |
| 1601 | False, |
| 1602 | ) |
| 1603 | if not isinstance(raw_apply_without_audio, bool): |
| 1604 | raise ValueError("transition apply_without_audio must be a boolean") |
| 1605 | apply_without_audio = ( |
| 1606 | cli_apply_without_audio or raw_apply_without_audio |
| 1607 | ) |
| 1608 | if ( |
| 1609 | "audio" not in modules |
| 1610 | and ( |
| 1611 | "transitions" in modules |
| 1612 | or cli_effect is not None |
| 1613 | or global_enter.policy == "none" |
| 1614 | ) |
| 1615 | ): |
| 1616 | # A confirmed global transition is independently actionable. The |
| 1617 | # narrated-only scope switch matters only while audio is enabled. |
| 1618 | # Explicit none remains an action even though the module is disabled. |
| 1619 | apply_without_audio = True |
| 1620 | |
| 1621 | raw_slides = transitions_cfg.get("slides", {}) |
| 1622 | if not isinstance(raw_slides, dict): |
| 1623 | raise ValueError("transition slides must be an object") |
| 1624 | valid_indices = {slide.index for slide in slides} |
| 1625 | slide_enters: dict[int, EnterUpdate] = {} |
| 1626 | for raw_index, override in raw_slides.items(): |
| 1627 | if ( |
| 1628 | not isinstance(raw_index, str) |
| 1629 | or re.fullmatch(r"[1-9]\d*", raw_index) is None |
| 1630 | ): |
| 1631 | raise ValueError( |
| 1632 | f"transition slide key must be a canonical 1-based index: " |
| 1633 | f"{raw_index!r}" |
| 1634 | ) |
| 1635 | slide_index = int(raw_index) |
| 1636 | if slide_index not in valid_indices: |
| 1637 | raise ValueError( |
| 1638 | f"transition slide index is outside the source roster: " |
| 1639 | f"{slide_index}" |
| 1640 | ) |
| 1641 | if not isinstance(override, dict): |
| 1642 | raise ValueError( |
| 1643 | f"transition slides.{slide_index} must be an object" |
| 1644 | ) |
| 1645 | slide_enters[slide_index] = _resolve_slide_enter( |
| 1646 | selected_base, |
| 1647 | override, |
| 1648 | slide_index=slide_index, |
| 1649 | ) |
| 1650 | |
| 1651 | return ResolvedTransitionPlan( |
| 1652 | global_enter=global_enter, |
| 1653 | slide_enters=slide_enters, |
| 1654 | apply_without_audio=apply_without_audio, |
| 1655 | ) |
| 1656 | |
| 1657 | |
| 1658 | def _apply_transition_only( |
| 1659 | extract_dir: Path, |
| 1660 | slide: SlidePart, |
| 1661 | enter: EnterUpdate, |
| 1662 | ) -> bool: |
| 1663 | if enter.policy == "preserve": |
| 1664 | return False |
| 1665 | slide_xml_path = extract_dir / slide.part_name |
| 1666 | slide_xml = slide_xml_path.read_text(encoding="utf-8") |
| 1667 | source_animation_fingerprint = object_animation_fingerprint(slide_xml) |
| 1668 | slide_xml, _uses_timings = apply_slide_motion_xml( |
| 1669 | slide_xml, |
| 1670 | enter=enter, |
| 1671 | advance=AdvanceUpdate(mode="preserve"), |
| 1672 | ) |
| 1673 | if object_animation_fingerprint(slide_xml) != source_animation_fingerprint: |
| 1674 | raise RuntimeError( |
| 1675 | f"Slide {slide.index} object animations changed while updating " |
| 1676 | "the transition" |
| 1677 | ) |
| 1678 | slide_xml_path.write_text(slide_xml, encoding="utf-8") |
| 1679 | return True |
| 1680 | |
| 1681 | |
| 1682 | def init_project(args: argparse.Namespace) -> int: |
| 1683 | source_pptx = Path(args.source_pptx).expanduser().resolve() |
| 1684 | if not source_pptx.exists() or source_pptx.suffix.lower() != ".pptx": |
| 1685 | print(f"error: expected an existing .pptx file: {source_pptx}", file=sys.stderr) |
| 1686 | return 1 |
| 1687 | |
| 1688 | source_delivery = audit_pptx_delivery(source_pptx) |
| 1689 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 1690 | if fatal_delivery_messages: |
| 1691 | for message in fatal_delivery_messages: |
| 1692 | print(f"error: {message}", file=sys.stderr) |
| 1693 | return 1 |
| 1694 | |
| 1695 | stem = _sanitize_slug(args.name or source_pptx.stem) |
| 1696 | date = datetime.now().strftime("%Y%m%d") |
| 1697 | project_path = ( |
| 1698 | Path(args.project_dir).expanduser().resolve() |
| 1699 | if args.project_dir |
| 1700 | else Path(args.projects_root).expanduser().resolve() / f"{stem}_native_enhance_{date}" |
| 1701 | ) |
| 1702 | if project_path.exists() and any(project_path.iterdir()): |
| 1703 | print(f"error: project directory already exists and is not empty: {project_path}", file=sys.stderr) |
| 1704 | return 1 |
| 1705 | |
| 1706 | for dirname in ("sources", "analysis", "notes", "audio", "exports", "validation"): |
| 1707 | (project_path / dirname).mkdir(parents=True, exist_ok=True) |
| 1708 | |
| 1709 | archived_pptx = project_path / "sources" / source_pptx.name |
| 1710 | projects_root = Path(args.projects_root).expanduser().resolve() |
| 1711 | source_import_mode = _archive_source_pptx(source_pptx, archived_pptx, projects_root) |
| 1712 | |
| 1713 | source_md = project_path / "sources" / f"{source_pptx.stem}.md" |
| 1714 | ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py" |
| 1715 | result = subprocess.run( |
| 1716 | [sys.executable, str(ppt_to_md), str(archived_pptx), "-o", str(source_md)], |
| 1717 | check=False, |
| 1718 | text=True, |
| 1719 | capture_output=True, |
| 1720 | ) |
| 1721 | if result.returncode != 0: |
| 1722 | print(result.stderr or result.stdout, file=sys.stderr) |
| 1723 | return result.returncode |
| 1724 | |
| 1725 | with tempfile.TemporaryDirectory(prefix="native-enhance-intake-") as tmp: |
| 1726 | extract_dir = Path(tmp) / "pptx" |
| 1727 | _extract_pptx(archived_pptx, extract_dir) |
| 1728 | slide_parts = read_slide_parts(extract_dir) |
| 1729 | source_sha256 = _file_sha256(archived_pptx) |
| 1730 | |
| 1731 | slide_index = { |
| 1732 | "schema": "native_pptx_enhancement_slide_index.v1", |
| 1733 | "source_pptx": f"sources/{source_pptx.name}", |
| 1734 | "slide_count": len(slide_parts), |
| 1735 | "slides": [ |
| 1736 | { |
| 1737 | "index": slide.index, |
| 1738 | "note_file": f"notes/{slide.index:03d}.md", |
| 1739 | "audio_stem": f"{slide.index:03d}", |
| 1740 | "part_name": slide.part_name, |
| 1741 | "slide_number": slide.slide_number, |
| 1742 | } |
| 1743 | for slide in slide_parts |
| 1744 | ], |
| 1745 | } |
| 1746 | _write_json(project_path / "analysis" / "slide_index.json", slide_index) |
| 1747 | |
| 1748 | project = { |
| 1749 | "schema": PROJECT_SCHEMA, |
| 1750 | "kind": "native_pptx_enhancement", |
| 1751 | "modules": [ |
| 1752 | "notes", |
| 1753 | "audio", |
| 1754 | "timings", |
| 1755 | "transitions", |
| 1756 | "delivery.check", |
| 1757 | ], |
| 1758 | "source_pptx": f"sources/{source_pptx.name}", |
| 1759 | "source_markdown": f"sources/{source_pptx.stem}.md", |
| 1760 | "source_import": { |
| 1761 | "mode": source_import_mode, |
| 1762 | "original_path": str(source_pptx), |
| 1763 | }, |
| 1764 | "source_sha256": source_sha256, |
| 1765 | "slide_count": len(slide_parts), |
| 1766 | "slide_part_roster": [slide.part_name for slide in slide_parts], |
| 1767 | "notes_dir": "notes", |
| 1768 | "audio_dir": "audio", |
| 1769 | "exports_dir": "exports", |
| 1770 | "transition": _native_transition_config( |
| 1771 | args.transition, |
| 1772 | args.transition_duration, |
| 1773 | ), |
| 1774 | "audio": { |
| 1775 | "provider": "", |
| 1776 | "voice": "", |
| 1777 | "rate": "", |
| 1778 | }, |
| 1779 | } |
| 1780 | _write_json(project_path / "project.json", project) |
| 1781 | plan = _build_enhancement_plan( |
| 1782 | project, |
| 1783 | slide_count=len(slide_parts), |
| 1784 | notes_count=0, |
| 1785 | audio_count=0, |
| 1786 | transition=args.transition, |
| 1787 | transition_duration=args.transition_duration, |
| 1788 | narration_padding=args.narration_padding, |
| 1789 | narration_start_floor=args.narration_start_floor, |
| 1790 | apply_transition_without_audio=args.apply_transition_without_audio, |
| 1791 | ) |
| 1792 | _write_json(_plan_path(project_path), plan) |
| 1793 | source_delivery_file = source_delivery.get("file") |
| 1794 | if isinstance(source_delivery_file, dict): |
| 1795 | source_delivery_file["path"] = str(archived_pptx.resolve()) |
| 1796 | _write_json( |
| 1797 | project_path / "validation" / "report.json", |
| 1798 | { |
| 1799 | "schema": VALIDATION_SCHEMA, |
| 1800 | "status": ( |
| 1801 | "passed-with-advisories" |
| 1802 | if _delivery_has_findings(source_delivery) |
| 1803 | else "passed" |
| 1804 | ), |
| 1805 | "phase": "intake", |
| 1806 | "source_delivery_policy": "preserve-baseline", |
| 1807 | "delivery_check": source_delivery, |
| 1808 | }, |
| 1809 | ) |
| 1810 | |
| 1811 | print(f"Project: {project_path}", file=sys.stderr) |
| 1812 | print(f"Slides: {len(slide_parts)}", file=sys.stderr) |
| 1813 | print(f"Source import: {source_import_mode}", file=sys.stderr) |
| 1814 | print(f"Source markdown: {source_md}", file=sys.stderr) |
| 1815 | print(f"Draft enhancement plan: {_plan_path(project_path)}", file=sys.stderr) |
| 1816 | print( |
| 1817 | "Review the plan with the user and set status to \"confirmed\" before generating notes/audio/applying.", |
| 1818 | file=sys.stderr, |
| 1819 | ) |
| 1820 | return 0 |
| 1821 | |
| 1822 | |
| 1823 | def plan_project(args: argparse.Namespace) -> int: |
| 1824 | project_path = Path(args.project_path).expanduser().resolve() |
| 1825 | project = _read_json(project_path / "project.json") |
| 1826 | if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: |
| 1827 | print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) |
| 1828 | return 1 |
| 1829 | |
| 1830 | source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) |
| 1831 | source_delivery = audit_pptx_delivery(source_pptx) |
| 1832 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 1833 | if fatal_delivery_messages: |
| 1834 | for message in fatal_delivery_messages: |
| 1835 | print(f"error: {message}", file=sys.stderr) |
| 1836 | return 1 |
| 1837 | |
| 1838 | with tempfile.TemporaryDirectory(prefix="native-enhance-plan-") as tmp: |
| 1839 | extract_dir = Path(tmp) / "pptx" |
| 1840 | _extract_pptx(source_pptx, extract_dir) |
| 1841 | slides = read_slide_parts(extract_dir) |
| 1842 | |
| 1843 | source_errors = _source_state_errors( |
| 1844 | project_path, |
| 1845 | project, |
| 1846 | source_pptx, |
| 1847 | slides, |
| 1848 | ) |
| 1849 | if source_errors: |
| 1850 | for error in source_errors: |
| 1851 | print(f"error: {error}", file=sys.stderr) |
| 1852 | return 1 |
| 1853 | |
| 1854 | existing_plan = _load_enhancement_plan(project_path) |
| 1855 | try: |
| 1856 | _validate_plan_modules( |
| 1857 | existing_plan, |
| 1858 | allow_legacy_audio_without_notes=True, |
| 1859 | ) |
| 1860 | readiness = _collect_material_readiness( |
| 1861 | slides, |
| 1862 | notes_dir, |
| 1863 | audio_dir, |
| 1864 | set(), |
| 1865 | ) |
| 1866 | plan = _build_enhancement_plan( |
| 1867 | project, |
| 1868 | slide_count=len(slides), |
| 1869 | notes_count=readiness.notes_count, |
| 1870 | audio_count=readiness.audio_count, |
| 1871 | transition=args.transition, |
| 1872 | transition_duration=args.transition_duration, |
| 1873 | narration_padding=args.narration_padding, |
| 1874 | narration_start_floor=args.narration_start_floor, |
| 1875 | apply_transition_without_audio=args.apply_transition_without_audio, |
| 1876 | existing_plan=existing_plan, |
| 1877 | ) |
| 1878 | _resolve_transition_plan(project, plan, slides) |
| 1879 | except ValueError as exc: |
| 1880 | print(f"error: {exc}", file=sys.stderr) |
| 1881 | return 1 |
| 1882 | _write_json(_plan_path(project_path), plan) |
| 1883 | print(json.dumps(plan, ensure_ascii=False, indent=2)) |
| 1884 | print(f"Plan written: {_plan_path(project_path)}", file=sys.stderr) |
| 1885 | print( |
| 1886 | "Confirm by editing status to \"confirmed\" after user approval, then run apply.", |
| 1887 | file=sys.stderr, |
| 1888 | ) |
| 1889 | return 0 |
| 1890 | |
| 1891 | |
| 1892 | def apply_project(args: argparse.Namespace) -> int: |
| 1893 | project_path = Path(args.project_path).expanduser().resolve() |
| 1894 | project = _read_json(project_path / "project.json") |
| 1895 | if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: |
| 1896 | print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) |
| 1897 | return 1 |
| 1898 | |
| 1899 | source_pptx, notes_dir, audio_dir, exports_dir = _project_paths(project_path) |
| 1900 | plan = _load_enhancement_plan(project_path) |
| 1901 | modules = _enabled_modules(plan) |
| 1902 | |
| 1903 | def fail_preflight( |
| 1904 | messages: list[str], |
| 1905 | *, |
| 1906 | status: str = "failed", |
| 1907 | **details: object, |
| 1908 | ) -> int: |
| 1909 | _write_preflight_report( |
| 1910 | project_path, |
| 1911 | plan, |
| 1912 | modules, |
| 1913 | status=status, |
| 1914 | errors=messages, |
| 1915 | **details, |
| 1916 | ) |
| 1917 | for message in messages: |
| 1918 | print(f"error: {message}", file=sys.stderr) |
| 1919 | return 1 |
| 1920 | |
| 1921 | _write_preflight_report( |
| 1922 | project_path, |
| 1923 | plan, |
| 1924 | modules, |
| 1925 | status="running", |
| 1926 | ) |
| 1927 | if not _plan_confirmed(plan) and not args.force: |
| 1928 | return fail_preflight( |
| 1929 | [ |
| 1930 | f"enhancement plan is not confirmed: {_plan_path(project_path)} " |
| 1931 | "(run plan, get user confirmation, set status to " |
| 1932 | "\"confirmed\", or pass --force)" |
| 1933 | ] |
| 1934 | ) |
| 1935 | |
| 1936 | try: |
| 1937 | _validate_plan_modules(plan) |
| 1938 | except ValueError as exc: |
| 1939 | return fail_preflight( |
| 1940 | [str(exc)], |
| 1941 | plan_errors=[str(exc)], |
| 1942 | ) |
| 1943 | |
| 1944 | source_delivery = audit_pptx_delivery(source_pptx) |
| 1945 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 1946 | if fatal_delivery_messages: |
| 1947 | return fail_preflight( |
| 1948 | fatal_delivery_messages, |
| 1949 | fatal_delivery_errors=fatal_delivery_messages, |
| 1950 | delivery_check=source_delivery, |
| 1951 | ) |
| 1952 | |
| 1953 | modules_cfg = plan.get("modules") if isinstance(plan.get("modules"), dict) else {} |
| 1954 | timings_cfg = modules_cfg.get("timings", {}) |
| 1955 | if not isinstance(timings_cfg, dict): |
| 1956 | timings_cfg = {} |
| 1957 | |
| 1958 | if args.narration_padding is not None: |
| 1959 | raw_narration_padding = args.narration_padding |
| 1960 | elif "narration_padding" in timings_cfg: |
| 1961 | raw_narration_padding = timings_cfg["narration_padding"] |
| 1962 | else: |
| 1963 | raw_narration_padding = 0.4 |
| 1964 | |
| 1965 | try: |
| 1966 | if "timings" in modules: |
| 1967 | narration_padding = validate_seconds( |
| 1968 | raw_narration_padding, |
| 1969 | "narration padding", |
| 1970 | allow_zero=True, |
| 1971 | ) |
| 1972 | else: |
| 1973 | narration_padding = 0.4 |
| 1974 | except ValueError as exc: |
| 1975 | return fail_preflight([str(exc)]) |
| 1976 | |
| 1977 | if args.narration_start_floor is not None: |
| 1978 | raw_narration_start_floor = args.narration_start_floor |
| 1979 | elif "narration_start_floor" in timings_cfg: |
| 1980 | raw_narration_start_floor = timings_cfg["narration_start_floor"] |
| 1981 | else: |
| 1982 | raw_narration_start_floor = DEFAULT_NARRATION_START_FLOOR |
| 1983 | |
| 1984 | try: |
| 1985 | if "audio" in modules: |
| 1986 | narration_start_floor = validate_seconds( |
| 1987 | raw_narration_start_floor, |
| 1988 | "narration start floor", |
| 1989 | allow_zero=True, |
| 1990 | ) |
| 1991 | else: |
| 1992 | narration_start_floor = DEFAULT_NARRATION_START_FLOOR |
| 1993 | except ValueError as exc: |
| 1994 | return fail_preflight([str(exc)]) |
| 1995 | |
| 1996 | output_path = ( |
| 1997 | Path(args.output).expanduser().resolve() |
| 1998 | if args.output |
| 1999 | else exports_dir / f"{source_pptx.stem}_enhanced.pptx" |
| 2000 | ) |
| 2001 | output_error = _output_path_error( |
| 2002 | project_path, |
| 2003 | project, |
| 2004 | source_pptx, |
| 2005 | output_path, |
| 2006 | ) |
| 2007 | if output_error: |
| 2008 | return fail_preflight([output_error]) |
| 2009 | if output_path.exists() and not args.overwrite: |
| 2010 | return fail_preflight( |
| 2011 | [f"output already exists, pass --overwrite: {output_path}"] |
| 2012 | ) |
| 2013 | |
| 2014 | with tempfile.TemporaryDirectory(prefix="native-enhance-pptx-") as tmp: |
| 2015 | extract_dir = Path(tmp) / "pptx" |
| 2016 | _extract_pptx(source_pptx, extract_dir) |
| 2017 | slides = read_slide_parts(extract_dir) |
| 2018 | |
| 2019 | source_errors = _source_state_errors( |
| 2020 | project_path, |
| 2021 | project, |
| 2022 | source_pptx, |
| 2023 | slides, |
| 2024 | ) |
| 2025 | if source_errors: |
| 2026 | return fail_preflight( |
| 2027 | source_errors, |
| 2028 | source_errors=source_errors, |
| 2029 | delivery_check=source_delivery, |
| 2030 | ) |
| 2031 | |
| 2032 | try: |
| 2033 | resolved_transitions = _resolve_transition_plan( |
| 2034 | project, |
| 2035 | plan, |
| 2036 | slides, |
| 2037 | cli_effect=args.transition, |
| 2038 | cli_duration=args.transition_duration, |
| 2039 | cli_apply_without_audio=args.apply_transition_without_audio, |
| 2040 | ) |
| 2041 | except ValueError as exc: |
| 2042 | return fail_preflight( |
| 2043 | [str(exc)], |
| 2044 | transition_errors=[str(exc)], |
| 2045 | delivery_check=source_delivery, |
| 2046 | ) |
| 2047 | |
| 2048 | readiness = _collect_material_readiness( |
| 2049 | slides, |
| 2050 | notes_dir, |
| 2051 | audio_dir, |
| 2052 | modules, |
| 2053 | ) |
| 2054 | if not readiness.ready: |
| 2055 | return fail_preflight( |
| 2056 | _material_readiness_messages(readiness), |
| 2057 | status=( |
| 2058 | "failed" |
| 2059 | if readiness.module_errors |
| 2060 | else "needs-materials" |
| 2061 | ), |
| 2062 | notes_required="notes" in modules, |
| 2063 | audio_required=( |
| 2064 | "audio" in modules or "timings" in modules |
| 2065 | ), |
| 2066 | delivery_check=source_delivery, |
| 2067 | **_material_readiness_report_fields(readiness), |
| 2068 | ) |
| 2069 | |
| 2070 | if "audio" in modules: |
| 2071 | existing_carriers = _native_audio_carriers(extract_dir, slides) |
| 2072 | if existing_carriers: |
| 2073 | details = "; ".join( |
| 2074 | f"slide {index}: {', '.join(names)}" |
| 2075 | for index, names in sorted(existing_carriers.items()) |
| 2076 | ) |
| 2077 | return fail_preflight( |
| 2078 | [ |
| 2079 | "source PPTX already contains native-enhance narration " |
| 2080 | "carrier(s); refusing to append duplicate audio: " |
| 2081 | + details |
| 2082 | ], |
| 2083 | existing_native_audio_carriers=existing_carriers, |
| 2084 | delivery_check=source_delivery, |
| 2085 | ) |
| 2086 | |
| 2087 | note_parts: set[str] = set() |
| 2088 | audio_exts: set[str] = set() |
| 2089 | audio_count = 0 |
| 2090 | transition_only_count = 0 |
| 2091 | wrote_auto_advance = False |
| 2092 | for slide in slides: |
| 2093 | has_slide_transition = ( |
| 2094 | slide.index in resolved_transitions.slide_enters |
| 2095 | ) |
| 2096 | enter_update = resolved_transitions.slide_enters.get( |
| 2097 | slide.index, |
| 2098 | resolved_transitions.global_enter, |
| 2099 | ) |
| 2100 | note = readiness.note_paths.get(slide.index) |
| 2101 | if "notes" in modules and note: |
| 2102 | notes_part = _apply_notes(extract_dir, slide, note) |
| 2103 | if notes_part is not None: |
| 2104 | note_parts.add(notes_part) |
| 2105 | |
| 2106 | audio = readiness.audio_paths.get(slide.index) |
| 2107 | if "audio" in modules and audio: |
| 2108 | wrote_auto_advance = _apply_audio( |
| 2109 | extract_dir, |
| 2110 | slide, |
| 2111 | audio, |
| 2112 | enter=enter_update, |
| 2113 | timings_enabled="timings" in modules, |
| 2114 | narration_padding=narration_padding, |
| 2115 | narration_start_floor=narration_start_floor, |
| 2116 | audio_duration=readiness.audio_durations.get(slide.index), |
| 2117 | ) or wrote_auto_advance |
| 2118 | audio_exts.add(audio.suffix.lower()) |
| 2119 | audio_count += 1 |
| 2120 | continue |
| 2121 | |
| 2122 | if ( |
| 2123 | has_slide_transition |
| 2124 | or resolved_transitions.apply_without_audio |
| 2125 | ): |
| 2126 | transition_only_count += int( |
| 2127 | _apply_transition_only( |
| 2128 | extract_dir, |
| 2129 | slide, |
| 2130 | enter_update, |
| 2131 | ) |
| 2132 | ) |
| 2133 | |
| 2134 | _update_content_types(extract_dir, note_parts, audio_exts) |
| 2135 | if wrote_auto_advance: |
| 2136 | set_directory_use_timings(extract_dir) |
| 2137 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 2138 | with tempfile.TemporaryDirectory( |
| 2139 | prefix="native-enhance-output-", |
| 2140 | dir=output_path.parent, |
| 2141 | ) as output_tmp: |
| 2142 | candidate_path = Path(output_tmp) / output_path.name |
| 2143 | _zip_dir(extract_dir, candidate_path) |
| 2144 | try: |
| 2145 | validate_pptx_transition_package( |
| 2146 | candidate_path, |
| 2147 | require_use_timings=wrote_auto_advance, |
| 2148 | ) |
| 2149 | except ValueError as exc: |
| 2150 | raise RuntimeError( |
| 2151 | f"PPTX transition package validation failed: {exc}" |
| 2152 | ) from exc |
| 2153 | try: |
| 2154 | validate_pptx_animation_package( |
| 2155 | candidate_path, |
| 2156 | require_supported_effects=False, |
| 2157 | ) |
| 2158 | except ValueError as exc: |
| 2159 | raise RuntimeError( |
| 2160 | f"PPTX animation/timing package validation failed: {exc}" |
| 2161 | ) from exc |
| 2162 | candidate_delivery = audit_pptx_delivery(candidate_path) |
| 2163 | introduced_delivery_errors = _new_delivery_errors( |
| 2164 | source_delivery, |
| 2165 | candidate_delivery, |
| 2166 | ) |
| 2167 | if introduced_delivery_errors: |
| 2168 | raise RuntimeError( |
| 2169 | "PPTX delivery postflight introduced structural error(s): " |
| 2170 | + "; ".join( |
| 2171 | str(issue.get("message") or issue) |
| 2172 | for issue in introduced_delivery_errors |
| 2173 | ) |
| 2174 | ) |
| 2175 | candidate_slides = candidate_delivery.get("slides") |
| 2176 | candidate_slide_count = ( |
| 2177 | candidate_slides.get("count") |
| 2178 | if isinstance(candidate_slides, dict) |
| 2179 | else None |
| 2180 | ) |
| 2181 | if candidate_slide_count != len(slides): |
| 2182 | raise RuntimeError( |
| 2183 | "PPTX delivery postflight slide count changed: " |
| 2184 | f"{candidate_slide_count!r} != {len(slides)}" |
| 2185 | ) |
| 2186 | source_hidden_slides = _delivery_hidden_slide_indices( |
| 2187 | source_delivery |
| 2188 | ) |
| 2189 | candidate_hidden_slides = _delivery_hidden_slide_indices( |
| 2190 | candidate_delivery |
| 2191 | ) |
| 2192 | if ( |
| 2193 | source_hidden_slides is None |
| 2194 | or candidate_hidden_slides is None |
| 2195 | or candidate_hidden_slides != source_hidden_slides |
| 2196 | ): |
| 2197 | raise RuntimeError( |
| 2198 | "PPTX delivery postflight changed or could not verify " |
| 2199 | "hidden-slide state" |
| 2200 | ) |
| 2201 | candidate_path.replace(output_path) |
| 2202 | |
| 2203 | candidate_file = candidate_delivery.get("file") |
| 2204 | if isinstance(candidate_file, dict): |
| 2205 | candidate_file["path"] = str(output_path.resolve()) |
| 2206 | report_status = ( |
| 2207 | "passed-with-advisories" |
| 2208 | if ( |
| 2209 | _delivery_has_findings(source_delivery) |
| 2210 | or _delivery_has_findings(candidate_delivery) |
| 2211 | ) |
| 2212 | else "passed" |
| 2213 | ) |
| 2214 | validation_dir = project_path / "validation" |
| 2215 | validation_dir.mkdir(exist_ok=True) |
| 2216 | _write_json( |
| 2217 | validation_dir / "report.json", |
| 2218 | { |
| 2219 | "schema": VALIDATION_SCHEMA, |
| 2220 | "status": report_status, |
| 2221 | "phase": "postflight", |
| 2222 | "plan_status": plan.get("status") or "missing", |
| 2223 | "enabled_modules": sorted(modules), |
| 2224 | "slide_count": len(slides), |
| 2225 | "applied": { |
| 2226 | "notes": len(note_parts), |
| 2227 | "audio": audio_count, |
| 2228 | "transition_only_slides": transition_only_count, |
| 2229 | "automatic_advance": wrote_auto_advance, |
| 2230 | }, |
| 2231 | "transition_scope": { |
| 2232 | "global_enabled": "transitions" in modules, |
| 2233 | "global_policy": ( |
| 2234 | resolved_transitions.global_enter.policy |
| 2235 | ), |
| 2236 | "apply_without_audio": ( |
| 2237 | resolved_transitions.apply_without_audio |
| 2238 | ), |
| 2239 | "selected_slides": sorted( |
| 2240 | resolved_transitions.slide_enters |
| 2241 | ), |
| 2242 | }, |
| 2243 | "source_delivery_check": source_delivery, |
| 2244 | "output_delivery_check": candidate_delivery, |
| 2245 | "source_delivery_policy": "preserve-baseline", |
| 2246 | "introduced_delivery_errors": introduced_delivery_errors, |
| 2247 | }, |
| 2248 | ) |
| 2249 | |
| 2250 | print(f"Output: {output_path}", file=sys.stderr) |
| 2251 | print(f"Notes applied: {len(note_parts)}", file=sys.stderr) |
| 2252 | print(f"Audio embedded: {audio_count}", file=sys.stderr) |
| 2253 | if transition_only_count: |
| 2254 | print(f"Transition-only slides: {transition_only_count}", file=sys.stderr) |
| 2255 | return 0 |
| 2256 | |
| 2257 | |
| 2258 | def validate_project(args: argparse.Namespace) -> int: |
| 2259 | project_path = Path(args.project_path).expanduser().resolve() |
| 2260 | project = _read_json(project_path / "project.json") |
| 2261 | if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: |
| 2262 | print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) |
| 2263 | return 1 |
| 2264 | |
| 2265 | source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) |
| 2266 | plan = _load_enhancement_plan(project_path) |
| 2267 | modules = _enabled_modules(plan) |
| 2268 | material_modules = {"notes"} if args.materials == "notes" else modules |
| 2269 | source_delivery = audit_pptx_delivery(source_pptx) |
| 2270 | validation_dir = project_path / "validation" |
| 2271 | validation_dir.mkdir(exist_ok=True) |
| 2272 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 2273 | if fatal_delivery_messages: |
| 2274 | report = _write_preflight_report( |
| 2275 | project_path, |
| 2276 | plan, |
| 2277 | modules, |
| 2278 | status="failed", |
| 2279 | material_scope=args.materials, |
| 2280 | fatal_delivery_errors=fatal_delivery_messages, |
| 2281 | delivery_check=source_delivery, |
| 2282 | ) |
| 2283 | print(json.dumps(report, ensure_ascii=False, indent=2)) |
| 2284 | return 1 |
| 2285 | |
| 2286 | with tempfile.TemporaryDirectory(prefix="native-enhance-validate-") as tmp: |
| 2287 | extract_dir = Path(tmp) / "pptx" |
| 2288 | _extract_pptx(source_pptx, extract_dir) |
| 2289 | slides = read_slide_parts(extract_dir) |
| 2290 | existing_carriers = ( |
| 2291 | _native_audio_carriers(extract_dir, slides) |
| 2292 | if "audio" in modules |
| 2293 | else {} |
| 2294 | ) |
| 2295 | |
| 2296 | source_errors = _source_state_errors( |
| 2297 | project_path, |
| 2298 | project, |
| 2299 | source_pptx, |
| 2300 | slides, |
| 2301 | ) |
| 2302 | try: |
| 2303 | _validate_plan_modules(plan) |
| 2304 | except ValueError as exc: |
| 2305 | plan_errors = [str(exc)] |
| 2306 | else: |
| 2307 | plan_errors = [] |
| 2308 | try: |
| 2309 | resolved_transitions = ( |
| 2310 | _resolve_transition_plan( |
| 2311 | project, |
| 2312 | plan, |
| 2313 | slides, |
| 2314 | ) |
| 2315 | if not plan_errors |
| 2316 | else None |
| 2317 | ) |
| 2318 | except ValueError as exc: |
| 2319 | transition_errors = [str(exc)] |
| 2320 | transition_slide_count = 0 |
| 2321 | transition_scope = None |
| 2322 | else: |
| 2323 | transition_errors = [] |
| 2324 | if resolved_transitions is None: |
| 2325 | transition_slide_count = 0 |
| 2326 | transition_scope = None |
| 2327 | else: |
| 2328 | transition_slide_count = len( |
| 2329 | resolved_transitions.slide_enters |
| 2330 | ) |
| 2331 | transition_scope = { |
| 2332 | "global_enabled": "transitions" in modules, |
| 2333 | "global_policy": resolved_transitions.global_enter.policy, |
| 2334 | "apply_without_audio": ( |
| 2335 | resolved_transitions.apply_without_audio |
| 2336 | ), |
| 2337 | "selected_slides": sorted( |
| 2338 | resolved_transitions.slide_enters |
| 2339 | ), |
| 2340 | } |
| 2341 | readiness = _collect_material_readiness( |
| 2342 | slides, |
| 2343 | notes_dir, |
| 2344 | audio_dir, |
| 2345 | modules, |
| 2346 | required_modules=material_modules, |
| 2347 | ) |
| 2348 | hard_failure = bool( |
| 2349 | source_errors |
| 2350 | or readiness.module_errors |
| 2351 | or plan_errors |
| 2352 | or transition_errors |
| 2353 | or existing_carriers |
| 2354 | ) |
| 2355 | if hard_failure: |
| 2356 | status = "failed" |
| 2357 | elif not readiness.ready: |
| 2358 | status = "needs-materials" |
| 2359 | else: |
| 2360 | status = ( |
| 2361 | "passed-with-advisories" |
| 2362 | if _delivery_has_findings(source_delivery) |
| 2363 | else "passed" |
| 2364 | ) |
| 2365 | report = _write_preflight_report( |
| 2366 | project_path, |
| 2367 | plan, |
| 2368 | modules, |
| 2369 | status=status, |
| 2370 | material_scope=args.materials, |
| 2371 | slide_count=len(slides), |
| 2372 | notes_required="notes" in material_modules, |
| 2373 | audio_required=( |
| 2374 | "audio" in material_modules or "timings" in material_modules |
| 2375 | ), |
| 2376 | plan_errors=plan_errors, |
| 2377 | transition_errors=transition_errors, |
| 2378 | transition_override_count=transition_slide_count, |
| 2379 | transition_scope=transition_scope, |
| 2380 | source_errors=source_errors, |
| 2381 | existing_native_audio_carriers=existing_carriers, |
| 2382 | source_delivery_policy="preserve-baseline", |
| 2383 | delivery_check=source_delivery, |
| 2384 | **_material_readiness_report_fields(readiness), |
| 2385 | ) |
| 2386 | print(json.dumps(report, ensure_ascii=False, indent=2)) |
| 2387 | if hard_failure: |
| 2388 | return 1 |
| 2389 | return 0 if readiness.ready else 2 |
| 2390 | |
| 2391 | |
| 2392 | def build_parser() -> argparse.ArgumentParser: |
| 2393 | parser = argparse.ArgumentParser( |
| 2394 | description="Create/apply a native existing-PPTX enhancement project without SVG conversion.", |
| 2395 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 2396 | ) |
| 2397 | subparsers = parser.add_subparsers(dest="command", required=True) |
| 2398 | |
| 2399 | init = subparsers.add_parser("init", help="create a native PPTX enhancement project") |
| 2400 | init.add_argument("source_pptx", help="source .pptx file") |
| 2401 | init.add_argument("--name", default=None, help="ASCII project name slug") |
| 2402 | init.add_argument("--project-dir", default=None, help="explicit project directory") |
| 2403 | init.add_argument("--projects-root", default="projects", help="projects root (default: projects)") |
| 2404 | init.add_argument( |
| 2405 | "--transition", |
| 2406 | default="fade", |
| 2407 | choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], |
| 2408 | help="PowerPoint-native effect; old names are compatibility inputs", |
| 2409 | ) |
| 2410 | init.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5) |
| 2411 | init.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4) |
| 2412 | init.add_argument( |
| 2413 | "--narration-start-floor", |
| 2414 | type=_non_negative_seconds_arg, |
| 2415 | default=DEFAULT_NARRATION_START_FLOOR, |
| 2416 | help="minimum seconds from transition start to narration start", |
| 2417 | ) |
| 2418 | init.add_argument( |
| 2419 | "--apply-transition-without-audio", |
| 2420 | action="store_true", |
| 2421 | help=( |
| 2422 | "when audio is enabled, draft transitions for slides without audio " |
| 2423 | "as well" |
| 2424 | ), |
| 2425 | ) |
| 2426 | init.set_defaults(func=init_project) |
| 2427 | |
| 2428 | plan = subparsers.add_parser("plan", help="draft an enhancement module plan") |
| 2429 | plan.add_argument("project_path", help="native enhancement project directory") |
| 2430 | plan.add_argument( |
| 2431 | "--transition", |
| 2432 | default=None, |
| 2433 | choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], |
| 2434 | help=( |
| 2435 | "replace the saved global PowerPoint-native effect; omitted values " |
| 2436 | "preserve the current plan" |
| 2437 | ), |
| 2438 | ) |
| 2439 | plan.add_argument( |
| 2440 | "--transition-duration", |
| 2441 | type=_positive_seconds_arg, |
| 2442 | default=None, |
| 2443 | ) |
| 2444 | plan.add_argument( |
| 2445 | "--narration-padding", |
| 2446 | type=_non_negative_seconds_arg, |
| 2447 | default=None, |
| 2448 | ) |
| 2449 | plan.add_argument( |
| 2450 | "--narration-start-floor", |
| 2451 | type=_non_negative_seconds_arg, |
| 2452 | default=None, |
| 2453 | help="replace the saved narration start floor; omitted values preserve it", |
| 2454 | ) |
| 2455 | plan.add_argument( |
| 2456 | "--apply-transition-without-audio", |
| 2457 | action="store_true", |
| 2458 | default=None, |
| 2459 | help=( |
| 2460 | "when audio is enabled, include page transitions for slides " |
| 2461 | "without audio" |
| 2462 | ), |
| 2463 | ) |
| 2464 | plan.set_defaults(func=plan_project) |
| 2465 | |
| 2466 | apply = subparsers.add_parser( |
| 2467 | "apply", |
| 2468 | help="patch confirmed notes/audio/timings/transitions into a copied PPTX", |
| 2469 | ) |
| 2470 | apply.add_argument("project_path", help="native enhancement project directory") |
| 2471 | apply.add_argument("-o", "--output", default=None, help="output .pptx path") |
| 2472 | apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists") |
| 2473 | apply.add_argument( |
| 2474 | "--transition", |
| 2475 | default=None, |
| 2476 | choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], |
| 2477 | help="PowerPoint-native effect; old names are compatibility inputs", |
| 2478 | ) |
| 2479 | apply.add_argument("--transition-duration", type=_positive_seconds_arg, default=None) |
| 2480 | apply.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=None) |
| 2481 | apply.add_argument( |
| 2482 | "--narration-start-floor", |
| 2483 | type=_non_negative_seconds_arg, |
| 2484 | default=None, |
| 2485 | help="override the confirmed narration start floor for this export", |
| 2486 | ) |
| 2487 | apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan") |
| 2488 | apply.add_argument( |
| 2489 | "--apply-transition-without-audio", |
| 2490 | action="store_true", |
| 2491 | help=( |
| 2492 | "when audio is enabled, also write page transitions on slides " |
| 2493 | "without audio" |
| 2494 | ), |
| 2495 | ) |
| 2496 | apply.set_defaults(func=apply_project) |
| 2497 | |
| 2498 | validate = subparsers.add_parser( |
| 2499 | "validate", |
| 2500 | help="check source integrity, plan semantics, and material readiness", |
| 2501 | ) |
| 2502 | validate.add_argument("project_path", help="native enhancement project directory") |
| 2503 | validate.add_argument( |
| 2504 | "--materials", |
| 2505 | choices=("all", "notes"), |
| 2506 | default="all", |
| 2507 | help=( |
| 2508 | "required material scope: all enabled modules, or notes only " |
| 2509 | "before narration audio exists (default: all)" |
| 2510 | ), |
| 2511 | ) |
| 2512 | validate.set_defaults(func=validate_project) |
| 2513 | return parser |
| 2514 | |
| 2515 | |
| 2516 | def _record_preflight_exception( |
| 2517 | args: argparse.Namespace, |
| 2518 | exc: Exception, |
| 2519 | ) -> None: |
| 2520 | command = str(args.command) |
| 2521 | try: |
| 2522 | project_path = Path(args.project_path).expanduser().resolve() |
| 2523 | project = _read_json(project_path / "project.json") |
| 2524 | except (OSError, ValueError, KeyError, json.JSONDecodeError) as project_exc: |
| 2525 | if ( |
| 2526 | project_path.is_dir() |
| 2527 | and (project_path / "validation").is_dir() |
| 2528 | ): |
| 2529 | try: |
| 2530 | _write_preflight_report( |
| 2531 | project_path, |
| 2532 | {}, |
| 2533 | set(), |
| 2534 | status="failed", |
| 2535 | errors=[f"{command} aborted: {exc}"], |
| 2536 | project_errors=[ |
| 2537 | f"unable to read project.json: {project_exc}" |
| 2538 | ], |
| 2539 | ) |
| 2540 | except OSError: |
| 2541 | pass |
| 2542 | return |
| 2543 | if project.get("schema") not in { |
| 2544 | PROJECT_SCHEMA, |
| 2545 | *LEGACY_PROJECT_SCHEMAS, |
| 2546 | }: |
| 2547 | return |
| 2548 | plan_errors: list[str] = [] |
| 2549 | try: |
| 2550 | plan = _load_enhancement_plan(project_path) |
| 2551 | except (OSError, ValueError, json.JSONDecodeError) as plan_exc: |
| 2552 | plan = {} |
| 2553 | plan_errors.append(f"unable to read enhancement plan: {plan_exc}") |
| 2554 | try: |
| 2555 | _write_preflight_report( |
| 2556 | project_path, |
| 2557 | plan, |
| 2558 | set() if plan_errors else _enabled_modules(plan), |
| 2559 | status="failed", |
| 2560 | errors=[f"{command} aborted: {exc}"], |
| 2561 | plan_errors=plan_errors, |
| 2562 | ) |
| 2563 | except OSError: |
| 2564 | return |
| 2565 | |
| 2566 | |
| 2567 | def main(argv: list[str] | None = None) -> int: |
| 2568 | require_skill_integrity() |
| 2569 | parser = build_parser() |
| 2570 | args = parser.parse_args(argv) |
| 2571 | try: |
| 2572 | return args.func(args) |
| 2573 | except ( |
| 2574 | OSError, |
| 2575 | RuntimeError, |
| 2576 | ValueError, |
| 2577 | zipfile.BadZipFile, |
| 2578 | ET.ParseError, |
| 2579 | ) as exc: |
| 2580 | if args.command in {"apply", "validate"}: |
| 2581 | _record_preflight_exception(args, exc) |
| 2582 | print(f"error: {exc}", file=sys.stderr) |
| 2583 | return 1 |
| 2584 | |
| 2585 | |
| 2586 | if __name__ == "__main__": |
| 2587 | raise SystemExit(main()) |
| 2588 |