Skip to content

Commit 9be8b46

Browse files
felixinglaclaude
andcommitted
Add project methods: list_projects, list_project_images, create_project, add_images_to_project
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43867fe commit 9be8b46

4 files changed

Lines changed: 333 additions & 1 deletion

File tree

src/pedra/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99
from .client import Pedra
1010
from .errors import PedraAPIError, PedraError
1111
from .models import (
12+
AddImagesResponse,
1213
CreditsResponse,
1314
FeedbackResponse,
1415
ImageResponse,
16+
MusicLibraryResponse,
17+
ProjectImagesResponse,
18+
ProjectResponse,
19+
ProjectsResponse,
20+
ScriptResponse,
1521
VideoResponse,
22+
VoiceResponse,
1623
)
1724

1825
__version__ = "0.1.1"
@@ -25,5 +32,12 @@
2532
"VideoResponse",
2633
"CreditsResponse",
2734
"FeedbackResponse",
35+
"ScriptResponse",
36+
"VoiceResponse",
37+
"MusicLibraryResponse",
38+
"ProjectsResponse",
39+
"ProjectImagesResponse",
40+
"ProjectResponse",
41+
"AddImagesResponse",
2842
"__version__",
2943
]

src/pedra/client.py

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@
88
from typing import Any, Dict, List, Optional
99

1010
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+
)
1224

1325
DEFAULT_BASE_URL = "https://app.pedra.ai/api"
1426
# createVideo blocks server-side until the video is rendered (up to ~10 min).
@@ -221,6 +233,149 @@ def create_video(
221233
raw=data,
222234
)
223235

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+
224379
def credits(self) -> CreditsResponse:
225380
"""Read the account's remaining credits and plan. Never deducts credits."""
226381
data = self._post("/credits")

src/pedra/models.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,61 @@ class FeedbackResponse:
4343
message: Optional[str]
4444
credited_back: Optional[bool]
4545
raw: Any = field(repr=False, default=None)
46+
47+
48+
@dataclass
49+
class ScriptResponse:
50+
message: Optional[str]
51+
script: str
52+
raw: Any = field(repr=False, default=None)
53+
54+
55+
@dataclass
56+
class VoiceResponse:
57+
message: Optional[str]
58+
audio_id: str
59+
audio_url: str
60+
alignment_url: Optional[str] = None
61+
duration: Optional[int] = None
62+
raw: Any = field(repr=False, default=None)
63+
64+
65+
@dataclass
66+
class MusicLibraryResponse:
67+
tracks: List[Any]
68+
variants_per_track: int
69+
default_track: str
70+
voice_languages: List[str]
71+
raw: Any = field(repr=False, default=None)
72+
73+
74+
@dataclass
75+
class ProjectsResponse:
76+
projects: List[Any]
77+
raw: Any = field(repr=False, default=None)
78+
79+
80+
@dataclass
81+
class ProjectImagesResponse:
82+
project_id: str
83+
name: Optional[str]
84+
images: List[Any]
85+
raw: Any = field(repr=False, default=None)
86+
87+
88+
@dataclass
89+
class ProjectResponse:
90+
message: Optional[str]
91+
project_id: str
92+
app_url: Optional[str] = None
93+
raw: Any = field(repr=False, default=None)
94+
95+
96+
@dataclass
97+
class AddImagesResponse:
98+
message: Optional[str]
99+
project_id: str
100+
added: List[Any]
101+
failed: List[Any]
102+
app_url: Optional[str] = None
103+
raw: Any = field(repr=False, default=None)

tests/test_client.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,111 @@ def test_create_video_deep_camelizes(self):
112112
self.assertTrue(captured["body"]["isVertical"])
113113
self.assertEqual(res.video_url, "https://v/1")
114114

115+
def test_update_video_camelizes_and_targets_endpoint(self):
116+
patcher, captured = patch_urlopen(
117+
json.dumps({"videoId": "v1", "videoUrl": "https://v/2"})
118+
)
119+
with patcher:
120+
res = Pedra("k").update_video(
121+
"v1",
122+
music={"track": "cinematic"},
123+
voice={"audio_id": "a1", "show_subtitles": False},
124+
)
125+
self.assertEqual(captured["url"], "https://app.pedra.ai/api/update_video")
126+
self.assertEqual(captured["body"]["videoId"], "v1")
127+
self.assertEqual(captured["body"]["voice"]["audioId"], "a1")
128+
self.assertFalse(captured["body"]["voice"]["showSubtitles"])
129+
# images omitted → patch-style metadata edit
130+
self.assertNotIn("images", captured["body"])
131+
self.assertEqual(res.video_url, "https://v/2")
132+
133+
def test_generate_voice(self):
134+
patcher, captured = patch_urlopen(
135+
json.dumps(
136+
{"audioId": "a1", "audioUrl": "https://aud/1", "duration": 7}
137+
)
138+
)
139+
with patcher:
140+
res = Pedra("k").generate_voice("A bright home.", language="Español")
141+
self.assertEqual(captured["url"], "https://app.pedra.ai/api/generate_voice")
142+
self.assertEqual(captured["body"]["text"], "A bright home.")
143+
self.assertEqual(captured["body"]["language"], "Español")
144+
self.assertEqual(res.audio_id, "a1")
145+
self.assertEqual(res.duration, 7)
146+
147+
def test_generate_voice_script(self):
148+
patcher, captured = patch_urlopen(json.dumps({"script": "Lovely place."}))
149+
with patcher:
150+
res = Pedra("k").generate_voice_script(
151+
images=["https://a"],
152+
property_characteristics=[{"label": "Bedrooms", "value": "3"}],
153+
)
154+
self.assertEqual(
155+
captured["url"], "https://app.pedra.ai/api/generate_voice_script"
156+
)
157+
self.assertEqual(captured["body"]["images"], ["https://a"])
158+
self.assertEqual(
159+
captured["body"]["propertyCharacteristics"][0]["label"], "Bedrooms"
160+
)
161+
self.assertEqual(res.script, "Lovely place.")
162+
163+
def test_music_library(self):
164+
patcher, _ = patch_urlopen(
165+
json.dumps(
166+
{
167+
"tracks": [{"track": "chill", "label": "Chill Beats"}],
168+
"variantsPerTrack": 6,
169+
"defaultTrack": "chill",
170+
"voiceLanguages": ["English"],
171+
}
172+
)
173+
)
174+
with patcher:
175+
res = Pedra("k").music_library()
176+
self.assertEqual(res.default_track, "chill")
177+
self.assertEqual(res.variants_per_track, 6)
178+
self.assertEqual(res.tracks[0]["track"], "chill")
179+
180+
def test_list_projects(self):
181+
patcher, captured = patch_urlopen(
182+
json.dumps({"projects": [{"projectId": "p1", "name": "Listing", "photoCount": 3}]})
183+
)
184+
with patcher:
185+
res = Pedra("k").list_projects()
186+
self.assertEqual(captured["url"], "https://app.pedra.ai/api/list_projects")
187+
self.assertEqual(res.projects[0]["projectId"], "p1")
188+
189+
def test_list_project_images(self):
190+
patcher, captured = patch_urlopen(
191+
json.dumps({"projectId": "p1", "images": [{"imageId": "i1", "url": "https://img.pedra.ai/i1"}]})
192+
)
193+
with patcher:
194+
res = Pedra("k").list_project_images("p1")
195+
self.assertEqual(captured["body"]["projectId"], "p1")
196+
self.assertEqual(res.images[0]["url"], "https://img.pedra.ai/i1")
197+
198+
def test_create_project(self):
199+
patcher, captured = patch_urlopen(
200+
json.dumps({"message": "Project created", "projectId": "p2", "appUrl": "https://app.pedra.ai/?projectId=p2"})
201+
)
202+
with patcher:
203+
res = Pedra("k").create_project(name="New listing")
204+
self.assertEqual(captured["url"], "https://app.pedra.ai/api/create_project")
205+
self.assertEqual(captured["body"]["name"], "New listing")
206+
self.assertEqual(res.project_id, "p2")
207+
self.assertIn("projectId=p2", res.app_url)
208+
209+
def test_add_images_to_project_camelizes(self):
210+
patcher, captured = patch_urlopen(
211+
json.dumps({"message": "Added 1 image(s)", "projectId": "p1", "added": [{"imageId": "i9", "url": "https://img.pedra.ai/i9"}], "failed": []})
212+
)
213+
with patcher:
214+
res = Pedra("k").add_images_to_project("p1", ["https://x/a.jpg"])
215+
self.assertEqual(captured["url"], "https://app.pedra.ai/api/add_images_to_project")
216+
self.assertEqual(captured["body"]["projectId"], "p1")
217+
self.assertEqual(captured["body"]["imageUrls"], ["https://x/a.jpg"])
218+
self.assertEqual(res.added[0]["url"], "https://img.pedra.ai/i9")
219+
115220

116221
class CamelizeTests(unittest.TestCase):
117222
def test_to_camel_keys_only(self):

0 commit comments

Comments
 (0)