| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - PowerPoint Video Export |
| 4 | |
| 5 | Export a narrated PPTX through the installed Windows PowerPoint application and |
| 6 | wait until its native video encoder finishes. |
| 7 | |
| 8 | See workflows/stages/generate-audio.md for the narration handoff. |
| 9 | |
| 10 | Usage: |
| 11 | python3 scripts/powerpoint_video.py <pptx> [-o <video>] |
| 12 | python3 scripts/powerpoint_video.py --check |
| 13 | |
| 14 | Examples: |
| 15 | python3 scripts/powerpoint_video.py projects/demo/exports/demo_narrated.pptx |
| 16 | python3 scripts/powerpoint_video.py deck.pptx -o deck.mp4 --resolution 1080 |
| 17 | |
| 18 | Dependencies: |
| 19 | Windows PowerPoint with the CreateVideo automation API |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import argparse |
| 25 | import base64 |
| 26 | import os |
| 27 | import shutil |
| 28 | import subprocess |
| 29 | import sys |
| 30 | from pathlib import Path |
| 31 | |
| 32 | from console_encoding import configure_utf8_stdio |
| 33 | |
| 34 | configure_utf8_stdio() |
| 35 | |
| 36 | |
| 37 | _CHECK_SCRIPT = r""" |
| 38 | $ErrorActionPreference = "Stop" |
| 39 | $ProgressPreference = "SilentlyContinue" |
| 40 | $powerPoint = $null |
| 41 | $ownsApplication = $false |
| 42 | |
| 43 | try { |
| 44 | try { |
| 45 | $powerPoint = [Runtime.InteropServices.Marshal]::GetActiveObject( |
| 46 | "PowerPoint.Application" |
| 47 | ) |
| 48 | } |
| 49 | catch { |
| 50 | $powerPoint = New-Object -ComObject PowerPoint.Application |
| 51 | $ownsApplication = $true |
| 52 | } |
| 53 | |
| 54 | $version = [version]$powerPoint.Version |
| 55 | if ($version.Major -lt 16) { |
| 56 | throw "PowerPoint $version is older than the supported Office 2016 baseline." |
| 57 | } |
| 58 | [Console]::Out.WriteLine( |
| 59 | "PowerPoint video export available (version {0})." -f $version |
| 60 | ) |
| 61 | } |
| 62 | catch { |
| 63 | [Console]::Error.WriteLine( |
| 64 | "PowerPoint video export is unavailable: {0}" -f $_.Exception.Message |
| 65 | ) |
| 66 | exit 1 |
| 67 | } |
| 68 | finally { |
| 69 | if ($null -ne $powerPoint) { |
| 70 | if ($ownsApplication) { |
| 71 | try { $powerPoint.Quit() } catch {} |
| 72 | } |
| 73 | try { |
| 74 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject( |
| 75 | $powerPoint |
| 76 | ) |
| 77 | } |
| 78 | catch {} |
| 79 | } |
| 80 | [GC]::Collect() |
| 81 | [GC]::WaitForPendingFinalizers() |
| 82 | } |
| 83 | """ |
| 84 | |
| 85 | |
| 86 | _EXPORT_SCRIPT = r""" |
| 87 | $ErrorActionPreference = "Stop" |
| 88 | $ProgressPreference = "SilentlyContinue" |
| 89 | $inputPath = [Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_INPUT") |
| 90 | $outputPath = [Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_OUTPUT") |
| 91 | $resolution = [int][Environment]::GetEnvironmentVariable( |
| 92 | "PPT_MASTER_VIDEO_RESOLUTION" |
| 93 | ) |
| 94 | $framesPerSecond = [int][Environment]::GetEnvironmentVariable( |
| 95 | "PPT_MASTER_VIDEO_FPS" |
| 96 | ) |
| 97 | $quality = [int][Environment]::GetEnvironmentVariable("PPT_MASTER_VIDEO_QUALITY") |
| 98 | $defaultSlideDuration = [int][Environment]::GetEnvironmentVariable( |
| 99 | "PPT_MASTER_VIDEO_DEFAULT_SLIDE_DURATION" |
| 100 | ) |
| 101 | $timeoutSeconds = [int][Environment]::GetEnvironmentVariable( |
| 102 | "PPT_MASTER_VIDEO_TIMEOUT" |
| 103 | ) |
| 104 | |
| 105 | $powerPoint = $null |
| 106 | $presentation = $null |
| 107 | $ownsApplication = $false |
| 108 | |
| 109 | try { |
| 110 | try { |
| 111 | $powerPoint = [Runtime.InteropServices.Marshal]::GetActiveObject( |
| 112 | "PowerPoint.Application" |
| 113 | ) |
| 114 | } |
| 115 | catch { |
| 116 | $powerPoint = New-Object -ComObject PowerPoint.Application |
| 117 | $ownsApplication = $true |
| 118 | } |
| 119 | |
| 120 | $version = [version]$powerPoint.Version |
| 121 | if ($version.Major -lt 16) { |
| 122 | throw "PowerPoint $version is older than the supported Office 2016 baseline." |
| 123 | } |
| 124 | |
| 125 | # ReadOnly=-1, Untitled=0, WithWindow=0. |
| 126 | $presentation = $powerPoint.Presentations.Open($inputPath, -1, 0, 0) |
| 127 | $startMessage = "PowerPoint video export started: {0}p, {1} fps." -f @( |
| 128 | $resolution, |
| 129 | $framesPerSecond |
| 130 | ) |
| 131 | [Console]::Error.WriteLine($startMessage) |
| 132 | $presentation.CreateVideo( |
| 133 | $outputPath, |
| 134 | $true, |
| 135 | $defaultSlideDuration, |
| 136 | $resolution, |
| 137 | $framesPerSecond, |
| 138 | $quality |
| 139 | ) |
| 140 | |
| 141 | $deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds) |
| 142 | $nextProgress = [DateTime]::UtcNow.AddSeconds(15) |
| 143 | while ($true) { |
| 144 | $status = [int]$presentation.CreateVideoStatus |
| 145 | if ($status -eq 3) { |
| 146 | break |
| 147 | } |
| 148 | if ($status -eq 4) { |
| 149 | throw "PowerPoint reported that video creation failed." |
| 150 | } |
| 151 | if ([DateTime]::UtcNow -ge $deadline) { |
| 152 | throw "PowerPoint video creation exceeded the ${timeoutSeconds}-second timeout." |
| 153 | } |
| 154 | if ([DateTime]::UtcNow -ge $nextProgress) { |
| 155 | [Console]::Error.WriteLine("PowerPoint video export is still running.") |
| 156 | $nextProgress = [DateTime]::UtcNow.AddSeconds(15) |
| 157 | } |
| 158 | Start-Sleep -Milliseconds 1000 |
| 159 | } |
| 160 | |
| 161 | if (-not (Test-Path -LiteralPath $outputPath)) { |
| 162 | throw "PowerPoint reported success but did not create the output file." |
| 163 | } |
| 164 | $outputFile = Get-Item -LiteralPath $outputPath |
| 165 | if ($outputFile.Length -le 0) { |
| 166 | throw "PowerPoint created an empty video file." |
| 167 | } |
| 168 | |
| 169 | [Console]::Out.WriteLine($outputFile.FullName) |
| 170 | } |
| 171 | catch { |
| 172 | $errorMessage = ( |
| 173 | "PowerPoint video export failed: {0} " + |
| 174 | "Close any PowerPoint dialog and retry." |
| 175 | ) -f $_.Exception.Message |
| 176 | [Console]::Error.WriteLine($errorMessage) |
| 177 | exit 1 |
| 178 | } |
| 179 | finally { |
| 180 | if ($null -ne $presentation) { |
| 181 | try { $presentation.Close() } catch {} |
| 182 | try { |
| 183 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject( |
| 184 | $presentation |
| 185 | ) |
| 186 | } |
| 187 | catch {} |
| 188 | } |
| 189 | if ($null -ne $powerPoint) { |
| 190 | if ($ownsApplication) { |
| 191 | try { $powerPoint.Quit() } catch {} |
| 192 | } |
| 193 | try { |
| 194 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject( |
| 195 | $powerPoint |
| 196 | ) |
| 197 | } |
| 198 | catch {} |
| 199 | } |
| 200 | [GC]::Collect() |
| 201 | [GC]::WaitForPendingFinalizers() |
| 202 | } |
| 203 | """ |
| 204 | |
| 205 | |
| 206 | def build_parser() -> argparse.ArgumentParser: |
| 207 | parser = argparse.ArgumentParser( |
| 208 | description=( |
| 209 | "Export a PPTX with Windows PowerPoint's native video encoder and " |
| 210 | "wait for completion." |
| 211 | ), |
| 212 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 213 | ) |
| 214 | parser.add_argument("pptx", nargs="?", help="Input PPTX file.") |
| 215 | parser.add_argument( |
| 216 | "-o", |
| 217 | "--output", |
| 218 | help="Output .mp4 or .wmv path. Default: beside the PPTX as .mp4.", |
| 219 | ) |
| 220 | parser.add_argument( |
| 221 | "--resolution", |
| 222 | type=int, |
| 223 | default=1080, |
| 224 | help="Vertical video resolution in pixels (default: 1080).", |
| 225 | ) |
| 226 | parser.add_argument( |
| 227 | "--fps", |
| 228 | type=int, |
| 229 | default=30, |
| 230 | help="Frames per second, from 1 to 60 (default: 30).", |
| 231 | ) |
| 232 | parser.add_argument( |
| 233 | "--quality", |
| 234 | type=int, |
| 235 | default=85, |
| 236 | help="PowerPoint encoder quality, from 1 to 100 (default: 85).", |
| 237 | ) |
| 238 | parser.add_argument( |
| 239 | "--default-slide-duration", |
| 240 | type=int, |
| 241 | default=5, |
| 242 | help="Fallback seconds for slides without recorded timings (default: 5).", |
| 243 | ) |
| 244 | parser.add_argument( |
| 245 | "--timeout", |
| 246 | type=int, |
| 247 | default=7200, |
| 248 | help="Maximum seconds to wait for PowerPoint (default: 7200).", |
| 249 | ) |
| 250 | parser.add_argument( |
| 251 | "--force", |
| 252 | action="store_true", |
| 253 | help="Replace an existing output video.", |
| 254 | ) |
| 255 | parser.add_argument( |
| 256 | "--check", |
| 257 | action="store_true", |
| 258 | help="Check whether compatible Windows PowerPoint automation is available.", |
| 259 | ) |
| 260 | return parser |
| 261 | |
| 262 | |
| 263 | def _find_powershell() -> str | None: |
| 264 | """Return the local Windows PowerShell executable.""" |
| 265 | return shutil.which("powershell.exe") or shutil.which("powershell") |
| 266 | |
| 267 | |
| 268 | def _run_powershell(script: str, *, env: dict[str, str], timeout: int) -> int: |
| 269 | """Run an encoded PowerShell automation script.""" |
| 270 | executable = _find_powershell() |
| 271 | if executable is None: |
| 272 | print( |
| 273 | "PowerPoint video export requires Windows PowerShell. " |
| 274 | "Install or restore powershell.exe, then retry.", |
| 275 | file=sys.stderr, |
| 276 | ) |
| 277 | return 1 |
| 278 | |
| 279 | encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") |
| 280 | try: |
| 281 | completed = subprocess.run( |
| 282 | [ |
| 283 | executable, |
| 284 | "-NoLogo", |
| 285 | "-NoProfile", |
| 286 | "-NonInteractive", |
| 287 | "-STA", |
| 288 | "-OutputFormat", |
| 289 | "Text", |
| 290 | "-EncodedCommand", |
| 291 | encoded, |
| 292 | ], |
| 293 | check=False, |
| 294 | env=env, |
| 295 | timeout=timeout, |
| 296 | ) |
| 297 | except subprocess.TimeoutExpired: |
| 298 | print( |
| 299 | "PowerPoint automation exceeded the command timeout. " |
| 300 | "Close any PowerPoint dialog and retry.", |
| 301 | file=sys.stderr, |
| 302 | ) |
| 303 | return 1 |
| 304 | return completed.returncode |
| 305 | |
| 306 | |
| 307 | def _validate_positive( |
| 308 | parser: argparse.ArgumentParser, |
| 309 | *, |
| 310 | name: str, |
| 311 | value: int, |
| 312 | maximum: int | None = None, |
| 313 | ) -> None: |
| 314 | if value <= 0 or (maximum is not None and value > maximum): |
| 315 | suffix = f" and no greater than {maximum}" if maximum is not None else "" |
| 316 | parser.error(f"{name} must be greater than 0{suffix}") |
| 317 | |
| 318 | |
| 319 | def main(argv: list[str] | None = None) -> int: |
| 320 | parser = build_parser() |
| 321 | args = parser.parse_args(argv) |
| 322 | |
| 323 | if sys.platform != "win32": |
| 324 | print( |
| 325 | "PowerPoint video export currently requires Windows PowerPoint. " |
| 326 | "Keep the narrated PPTX and export it manually on another platform.", |
| 327 | file=sys.stderr, |
| 328 | ) |
| 329 | return 1 |
| 330 | |
| 331 | if args.check: |
| 332 | return _run_powershell( |
| 333 | _CHECK_SCRIPT, |
| 334 | env=os.environ.copy(), |
| 335 | timeout=60, |
| 336 | ) |
| 337 | |
| 338 | if not args.pptx: |
| 339 | parser.error("pptx is required unless --check is used") |
| 340 | |
| 341 | _validate_positive(parser, name="resolution", value=args.resolution) |
| 342 | _validate_positive(parser, name="fps", value=args.fps, maximum=60) |
| 343 | _validate_positive(parser, name="quality", value=args.quality, maximum=100) |
| 344 | _validate_positive( |
| 345 | parser, |
| 346 | name="default-slide-duration", |
| 347 | value=args.default_slide_duration, |
| 348 | ) |
| 349 | _validate_positive(parser, name="timeout", value=args.timeout) |
| 350 | |
| 351 | input_path = Path(args.pptx).expanduser().resolve() |
| 352 | if not input_path.is_file(): |
| 353 | print(f"Input PPTX does not exist: {input_path}", file=sys.stderr) |
| 354 | return 1 |
| 355 | if input_path.suffix.lower() != ".pptx": |
| 356 | print(f"Input must be a .pptx file: {input_path}", file=sys.stderr) |
| 357 | return 1 |
| 358 | |
| 359 | output_path = ( |
| 360 | Path(args.output).expanduser().resolve() |
| 361 | if args.output |
| 362 | else input_path.with_suffix(".mp4") |
| 363 | ) |
| 364 | if output_path.suffix.lower() not in {".mp4", ".wmv"}: |
| 365 | print( |
| 366 | f"Output must use the .mp4 or .wmv extension: {output_path}", |
| 367 | file=sys.stderr, |
| 368 | ) |
| 369 | return 1 |
| 370 | if output_path == input_path: |
| 371 | print("Input PPTX and output video paths must differ.", file=sys.stderr) |
| 372 | return 1 |
| 373 | if output_path.exists() and not args.force: |
| 374 | print( |
| 375 | f"Output already exists: {output_path}. " |
| 376 | "Use --force to replace it.", |
| 377 | file=sys.stderr, |
| 378 | ) |
| 379 | return 1 |
| 380 | |
| 381 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 382 | if output_path.exists(): |
| 383 | output_path.unlink() |
| 384 | |
| 385 | env = os.environ.copy() |
| 386 | env.update( |
| 387 | { |
| 388 | "PPT_MASTER_VIDEO_INPUT": str(input_path), |
| 389 | "PPT_MASTER_VIDEO_OUTPUT": str(output_path), |
| 390 | "PPT_MASTER_VIDEO_RESOLUTION": str(args.resolution), |
| 391 | "PPT_MASTER_VIDEO_FPS": str(args.fps), |
| 392 | "PPT_MASTER_VIDEO_QUALITY": str(args.quality), |
| 393 | "PPT_MASTER_VIDEO_DEFAULT_SLIDE_DURATION": str( |
| 394 | args.default_slide_duration |
| 395 | ), |
| 396 | "PPT_MASTER_VIDEO_TIMEOUT": str(args.timeout), |
| 397 | } |
| 398 | ) |
| 399 | return _run_powershell( |
| 400 | _EXPORT_SCRIPT, |
| 401 | env=env, |
| 402 | timeout=args.timeout + 60, |
| 403 | ) |
| 404 | |
| 405 | |
| 406 | if __name__ == "__main__": |
| 407 | raise SystemExit(main()) |
| 408 |