返回 ppt-master
backend_common.py
根目录 / skills / ppt-master / scripts / tts_backends / backend_common.py
1 """Shared helpers for TTS backends."""
2
3 from __future__ import annotations
4
5 import json
6 import os
7 import tempfile
8 from pathlib import Path
9 from urllib import error, request
10
11
12 def read_api_key(*env_names: str, label: str) -> str:
13 for env_name in env_names:
14 api_key = os.environ.get(env_name, "").strip()
15 if api_key:
16 return api_key
17 joined = " or ".join(env_names)
18 raise RuntimeError(f"Missing {label} API key. Set {joined}=<key>.")
19
20
21 def read_http_error(exc: error.HTTPError) -> str:
22 try:
23 body = exc.read().decode("utf-8", errors="replace")
24 except Exception:
25 body = ""
26 return f"HTTP {exc.code}: {body or exc.reason}"
27
28
29 def post_json(url: str, *, headers: dict[str, str], payload: dict, timeout: int = 120) -> dict:
30 body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
31 req = request.Request(
32 url,
33 data=body,
34 headers={
35 "Content-Type": "application/json",
36 **headers,
37 },
38 method="POST",
39 )
40 try:
41 with request.urlopen(req, timeout=timeout) as response:
42 return json.loads(response.read().decode("utf-8"))
43 except error.HTTPError as exc:
44 raise RuntimeError(read_http_error(exc)) from exc
45 except error.URLError as exc:
46 raise RuntimeError(f"HTTP request failed: {exc.reason}") from exc
47
48
49 def get_bytes(url: str, *, timeout: int = 180) -> bytes:
50 req = request.Request(url, method="GET")
51 try:
52 with request.urlopen(req, timeout=timeout) as response:
53 return response.read()
54 except error.HTTPError as exc:
55 raise RuntimeError(read_http_error(exc)) from exc
56 except error.URLError as exc:
57 raise RuntimeError(f"Audio download failed: {exc.reason}") from exc
58
59
60 def download_audio(url: str, output_path: Path) -> None:
61 publish_audio_bytes(get_bytes(url), output_path)
62
63
64 def temporary_path(target: Path, suffix: str) -> tuple[int, Path]:
65 """Create a temporary file beside its eventual target."""
66 target.parent.mkdir(parents=True, exist_ok=True)
67 descriptor, raw_path = tempfile.mkstemp(
68 prefix=f".{target.name}.",
69 suffix=suffix,
70 dir=target.parent,
71 )
72 return descriptor, Path(raw_path)
73
74
75 def publish_staged_pair(
76 staged_first: Path,
77 first_target: Path,
78 staged_second: Path,
79 second_target: Path,
80 ) -> None:
81 """Publish two staged files together, restoring prior targets on failure."""
82 targets = (first_target, second_target)
83 if first_target.resolve() == second_target.resolve():
84 raise ValueError("paired outputs must use different paths")
85
86 backups: dict[Path, Path] = {}
87 published: set[Path] = set()
88 try:
89 for target in targets:
90 if not target.exists():
91 continue
92 descriptor, backup = temporary_path(target, ".bak")
93 os.close(descriptor)
94 backup.unlink()
95 os.replace(target, backup)
96 backups[target] = backup
97
98 for staged, target in (
99 (staged_first, first_target),
100 (staged_second, second_target),
101 ):
102 os.replace(staged, target)
103 published.add(target)
104 except Exception:
105 for target in published:
106 target.unlink(missing_ok=True)
107 for target, backup in backups.items():
108 if backup.exists():
109 os.replace(backup, target)
110 raise
111 finally:
112 staged_first.unlink(missing_ok=True)
113 staged_second.unlink(missing_ok=True)
114 for backup in backups.values():
115 backup.unlink(missing_ok=True)
116
117
118 def publish_audio_bytes(audio: bytes, output_path: Path) -> None:
119 """Publish non-empty provider audio without exposing a partial target."""
120 if not audio:
121 raise RuntimeError("TTS provider returned empty audio data")
122
123 descriptor, staged_path = temporary_path(output_path, ".tmp")
124 try:
125 with os.fdopen(descriptor, "wb") as stream:
126 descriptor = -1
127 stream.write(audio)
128 stream.flush()
129 os.fsync(stream.fileno())
130 if staged_path.stat().st_size <= 0:
131 raise RuntimeError("TTS provider returned empty audio data")
132 os.replace(staged_path, output_path)
133 finally:
134 if descriptor >= 0:
135 os.close(descriptor)
136 staged_path.unlink(missing_ok=True)
137
138
139 def publish_audio_subtitle(
140 audio: bytes,
141 output_path: Path,
142 subtitle: str,
143 subtitle_path: Path,
144 ) -> None:
145 """Publish one audio/SRT pair without exposing mismatched targets."""
146 if not audio:
147 raise RuntimeError("TTS provider returned empty audio data")
148 if not subtitle.strip():
149 raise RuntimeError("TTS provider returned empty subtitle data")
150
151 audio_descriptor = -1
152 subtitle_descriptor = -1
153 staged_audio: Path | None = None
154 staged_subtitle: Path | None = None
155 try:
156 audio_descriptor, staged_audio = temporary_path(output_path, ".tmp")
157 subtitle_descriptor, staged_subtitle = temporary_path(subtitle_path, ".tmp")
158
159 audio_stream = os.fdopen(audio_descriptor, "wb")
160 audio_descriptor = -1
161 with audio_stream:
162 audio_stream.write(audio)
163 audio_stream.flush()
164 os.fsync(audio_stream.fileno())
165
166 subtitle_stream = os.fdopen(
167 subtitle_descriptor,
168 "w",
169 encoding="utf-8",
170 newline="\n",
171 )
172 subtitle_descriptor = -1
173 with subtitle_stream:
174 subtitle_stream.write(subtitle)
175 subtitle_stream.flush()
176 os.fsync(subtitle_stream.fileno())
177
178 publish_staged_pair(
179 staged_audio,
180 output_path,
181 staged_subtitle,
182 subtitle_path,
183 )
184 finally:
185 if audio_descriptor >= 0:
186 os.close(audio_descriptor)
187 if subtitle_descriptor >= 0:
188 os.close(subtitle_descriptor)
189 if staged_audio is not None:
190 staged_audio.unlink(missing_ok=True)
191 if staged_subtitle is not None:
192 staged_subtitle.unlink(missing_ok=True)
193
194
195 def extension_from_format(audio_format: str) -> str:
196 normalized = audio_format.strip().lower()
197 if normalized in {"mp3", "wav"}:
198 return f".{normalized}"
199 raise RuntimeError(
200 f"Unsupported audio format for PPT narration: {audio_format}. "
201 "Use mp3 or wav, or transcode provider output before embedding."
202 )
203
203 lines PYTHON