|
8 | 8 | from typing import Any, Dict, List, Optional |
9 | 9 |
|
10 | 10 | from .errors import PedraAPIError, PedraError |
11 | | -from .models import CreditsResponse, FeedbackResponse, ImageResponse, VideoResponse |
| 11 | +from .models import ( |
| 12 | + AddImagesResponse, |
| 13 | + CreditsResponse, |
| 14 | + FeedbackResponse, |
| 15 | + ImageResponse, |
| 16 | + MusicLibraryResponse, |
| 17 | + ProjectImagesResponse, |
| 18 | + ProjectResponse, |
| 19 | + ProjectsResponse, |
| 20 | + ScriptResponse, |
| 21 | + VideoResponse, |
| 22 | + VoiceResponse, |
| 23 | +) |
12 | 24 |
|
13 | 25 | DEFAULT_BASE_URL = "https://app.pedra.ai/api" |
14 | 26 | # createVideo blocks server-side until the video is rendered (up to ~10 min). |
@@ -221,6 +233,149 @@ def create_video( |
221 | 233 | raw=data, |
222 | 234 | ) |
223 | 235 |
|
| 236 | + def update_video( |
| 237 | + self, |
| 238 | + video_id: str, |
| 239 | + *, |
| 240 | + images: Optional[List[Dict[str, Any]]] = None, |
| 241 | + music: Optional[Dict[str, Any]] = None, |
| 242 | + voice: Optional[Dict[str, Any]] = None, |
| 243 | + branding: Optional[Dict[str, Any]] = None, |
| 244 | + ending_title: Optional[str] = None, |
| 245 | + ending_subtitle: Optional[str] = None, |
| 246 | + is_vertical: Optional[bool] = None, |
| 247 | + property_characteristics: Optional[List[Dict[str, Any]]] = None, |
| 248 | + ) -> VideoResponse: |
| 249 | + """Edit an existing video without re-rendering unchanged clips. |
| 250 | +
|
| 251 | + Only new/changed photos re-animate (and cost credits); reordering, |
| 252 | + music, voice, branding and text re-stitch for free. Omit ``images`` to |
| 253 | + change only audio/text/branding while keeping the timeline; omit |
| 254 | + ``music``/``voice``/``branding``/ending text to leave them unchanged. |
| 255 | + Blocks until rendered and returns the new video URL. (``/update_video``) |
| 256 | + """ |
| 257 | + data = self._post( |
| 258 | + "/update_video", |
| 259 | + video_id=video_id, |
| 260 | + images=images, |
| 261 | + music=music, |
| 262 | + voice=voice, |
| 263 | + branding=branding, |
| 264 | + ending_title=ending_title, |
| 265 | + ending_subtitle=ending_subtitle, |
| 266 | + is_vertical=is_vertical, |
| 267 | + property_characteristics=property_characteristics, |
| 268 | + ) |
| 269 | + return VideoResponse( |
| 270 | + message=data.get("message"), |
| 271 | + video_id=data.get("videoId", video_id), |
| 272 | + video_url=data.get("videoUrl", ""), |
| 273 | + raw=data, |
| 274 | + ) |
| 275 | + |
| 276 | + def generate_voice_script( |
| 277 | + self, |
| 278 | + *, |
| 279 | + images: Optional[List[Any]] = None, |
| 280 | + property_characteristics: Optional[List[Dict[str, Any]]] = None, |
| 281 | + language: Optional[str] = None, |
| 282 | + ) -> ScriptResponse: |
| 283 | + """Write a voiceover script from photos (and optional property facts). |
| 284 | +
|
| 285 | + GPT-4o vision reads the images so the script reflects what's shown. |
| 286 | + ``images`` accepts URL strings or ``{"image_url": ...}`` dicts. Feed the |
| 287 | + result to :meth:`generate_voice`. (``/generate_voice_script``) |
| 288 | + """ |
| 289 | + data = self._post( |
| 290 | + "/generate_voice_script", |
| 291 | + images=images, |
| 292 | + property_characteristics=property_characteristics, |
| 293 | + language=language, |
| 294 | + ) |
| 295 | + return ScriptResponse( |
| 296 | + message=data.get("message"), |
| 297 | + script=data.get("script", ""), |
| 298 | + raw=data, |
| 299 | + ) |
| 300 | + |
| 301 | + def generate_voice( |
| 302 | + self, text: str, *, language: Optional[str] = None |
| 303 | + ) -> VoiceResponse: |
| 304 | + """Render a voiceover from a script via TTS. |
| 305 | +
|
| 306 | + Returns an ``audio_id`` to pass as a video's ``voice={"audio_id": ...}`` |
| 307 | + (which also drives synced subtitles). (``/generate_voice``) |
| 308 | + """ |
| 309 | + data = self._post("/generate_voice", text=text, language=language) |
| 310 | + return VoiceResponse( |
| 311 | + message=data.get("message"), |
| 312 | + audio_id=data.get("audioId", ""), |
| 313 | + audio_url=data.get("audioUrl", ""), |
| 314 | + alignment_url=data.get("alignmentUrl"), |
| 315 | + duration=data.get("duration"), |
| 316 | + raw=data, |
| 317 | + ) |
| 318 | + |
| 319 | + def music_library(self) -> MusicLibraryResponse: |
| 320 | + """List the background-music catalog and voice languages. Read-only. |
| 321 | +
|
| 322 | + Returns the valid ``music`` ``track`` values (genre keys) with labels. |
| 323 | + (``/music_library``) |
| 324 | + """ |
| 325 | + data = self._post("/music_library") |
| 326 | + return MusicLibraryResponse( |
| 327 | + tracks=data.get("tracks", []), |
| 328 | + variants_per_track=int(data.get("variantsPerTrack", 0) or 0), |
| 329 | + default_track=data.get("defaultTrack", ""), |
| 330 | + voice_languages=data.get("voiceLanguages", []), |
| 331 | + raw=data, |
| 332 | + ) |
| 333 | + |
| 334 | + def list_projects(self) -> ProjectsResponse: |
| 335 | + """List the account's projects (id, name, photo count, appUrl). Use it to |
| 336 | + find photos already in the account. (``/list_projects``)""" |
| 337 | + data = self._post("/list_projects") |
| 338 | + return ProjectsResponse(projects=data.get("projects", []), raw=data) |
| 339 | + |
| 340 | + def list_project_images(self, project_id: str) -> ProjectImagesResponse: |
| 341 | + """List a project's photos as public URLs, ready to pass to |
| 342 | + :meth:`create_video` or the edit methods. (``/list_project_images``)""" |
| 343 | + data = self._post("/list_project_images", project_id=project_id) |
| 344 | + return ProjectImagesResponse( |
| 345 | + project_id=data.get("projectId", project_id), |
| 346 | + name=data.get("name"), |
| 347 | + images=data.get("images", []), |
| 348 | + raw=data, |
| 349 | + ) |
| 350 | + |
| 351 | + def create_project(self, *, name: Optional[str] = None) -> ProjectResponse: |
| 352 | + """Create a project. Returns its id and an ``app_url`` to open it in Pedra |
| 353 | + (the way to add brand-new local photos). (``/create_project``)""" |
| 354 | + data = self._post("/create_project", name=name) |
| 355 | + return ProjectResponse( |
| 356 | + message=data.get("message"), |
| 357 | + project_id=data.get("projectId", ""), |
| 358 | + app_url=data.get("appUrl"), |
| 359 | + raw=data, |
| 360 | + ) |
| 361 | + |
| 362 | + def add_images_to_project( |
| 363 | + self, project_id: str, image_urls: List[str] |
| 364 | + ) -> AddImagesResponse: |
| 365 | + """Add photos to a project by URL — the server fetches and stores each one, |
| 366 | + so any public https image URL works. (``/add_images_to_project``)""" |
| 367 | + data = self._post( |
| 368 | + "/add_images_to_project", project_id=project_id, image_urls=image_urls |
| 369 | + ) |
| 370 | + return AddImagesResponse( |
| 371 | + message=data.get("message"), |
| 372 | + project_id=data.get("projectId", project_id), |
| 373 | + added=data.get("added", []), |
| 374 | + failed=data.get("failed", []), |
| 375 | + app_url=data.get("appUrl"), |
| 376 | + raw=data, |
| 377 | + ) |
| 378 | + |
224 | 379 | def credits(self) -> CreditsResponse: |
225 | 380 | """Read the account's remaining credits and plan. Never deducts credits.""" |
226 | 381 | data = self._post("/credits") |
|
0 commit comments