返回 ppt-master
powerpoint_video.py
根目录 / skills / ppt-master / scripts / powerpoint_video.py
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
408 lines PYTHON