fotor-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fotor_sdk/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Fotor OpenAPI SDK -- lightweight, standalone, async-first."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import FotorClient, FotorAPIError
6
+ from .models import TaskResult, TaskSpec, TaskStatus
7
+ from .runner import TaskRunner
8
+ from .tasks import (
9
+ text2image,
10
+ image2image,
11
+ image_upscale,
12
+ background_remove,
13
+ text2video,
14
+ single_image2video,
15
+ start_end_frame2video,
16
+ multiple_image2video,
17
+ )
18
+
19
+ __all__ = [
20
+ "FotorClient",
21
+ "FotorAPIError",
22
+ "TaskResult",
23
+ "TaskSpec",
24
+ "TaskStatus",
25
+ "TaskRunner",
26
+ "text2image",
27
+ "image2image",
28
+ "image_upscale",
29
+ "background_remove",
30
+ "text2video",
31
+ "single_image2video",
32
+ "start_end_frame2video",
33
+ "multiple_image2video",
34
+ ]
fotor_sdk/client.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from typing import Any, Callable
7
+
8
+ import aiohttp
9
+
10
+ from .models import TaskResult, TaskStatus
11
+
12
+ logger = logging.getLogger("fotor_sdk")
13
+
14
+ _OK_CODE = "000"
15
+ _TASK_STATUS_PATH = "/v1/aiart/tasks"
16
+
17
+ _DEFAULT_ENDPOINT = "https://api.fotor.com"
18
+ _DEFAULT_POLL_INTERVAL = 2.0
19
+ _DEFAULT_MAX_POLL_SECONDS = 1200
20
+
21
+
22
+ class FotorAPIError(Exception):
23
+ """Raised when the Fotor API returns an error response."""
24
+
25
+ def __init__(self, message: str, code: str | None = None):
26
+ super().__init__(message)
27
+ self.code = code
28
+
29
+
30
+ class FotorClient:
31
+ """Lightweight async client for the Fotor OpenAPI.
32
+
33
+ Only requires an API key; no S3, no credit checks, no model config.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str,
39
+ endpoint: str = _DEFAULT_ENDPOINT,
40
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
41
+ max_poll_seconds: float = _DEFAULT_MAX_POLL_SECONDS,
42
+ ):
43
+ if not api_key:
44
+ raise ValueError("api_key is required")
45
+ self._api_key = api_key
46
+ self._endpoint = endpoint.rstrip("/")
47
+ self._poll_interval = poll_interval
48
+ self._max_poll_seconds = max_poll_seconds
49
+
50
+ def _headers(self) -> dict[str, str]:
51
+ return {
52
+ "Accept": "application/json",
53
+ "Content-Type": "application/json",
54
+ "Authorization": f"Bearer {self._api_key}",
55
+ }
56
+
57
+ # ------------------------------------------------------------------
58
+ # Core async methods
59
+ # ------------------------------------------------------------------
60
+
61
+ async def create_task(self, path: str, payload: dict[str, Any]) -> str:
62
+ """Submit a task and return the ``task_id``."""
63
+ url = f"{self._endpoint}{path}"
64
+ logger.debug("POST %s payload=%s", url, payload)
65
+
66
+ async with aiohttp.ClientSession() as session:
67
+ async with session.post(
68
+ url,
69
+ headers=self._headers(),
70
+ json=payload,
71
+ timeout=aiohttp.ClientTimeout(total=30),
72
+ ) as resp:
73
+ if resp.status != 200:
74
+ body = await resp.text()
75
+ raise FotorAPIError(
76
+ f"HTTP {resp.status} from {url}: {body}", code=str(resp.status)
77
+ )
78
+ data = await resp.json()
79
+
80
+ if data.get("code") != _OK_CODE:
81
+ raise FotorAPIError(data.get("msg", "unknown error"), code=data.get("code"))
82
+
83
+ task_id = data.get("data", {}).get("taskId")
84
+ if not task_id:
85
+ raise FotorAPIError("API response missing data.taskId")
86
+ logger.info("Task created: %s", task_id)
87
+ return task_id
88
+
89
+ async def get_task_status(self, task_id: str) -> TaskResult:
90
+ """Query current status of *task_id* and return a ``TaskResult``."""
91
+ url = f"{self._endpoint}{_TASK_STATUS_PATH}/{task_id}"
92
+ logger.debug("GET %s", url)
93
+
94
+ async with aiohttp.ClientSession() as session:
95
+ async with session.get(
96
+ url,
97
+ headers=self._headers(),
98
+ timeout=aiohttp.ClientTimeout(total=15),
99
+ ) as resp:
100
+ if resp.status != 200:
101
+ return TaskResult(task_id=task_id, status=TaskStatus.FAILED,
102
+ error=f"HTTP {resp.status}")
103
+ data = await resp.json()
104
+
105
+ if data.get("code") != _OK_CODE:
106
+ return TaskResult(task_id=task_id, status=TaskStatus.FAILED,
107
+ error=data.get("msg", "bad code"))
108
+
109
+ task_data = data.get("data", {})
110
+ api_status = task_data.get("status", -1)
111
+
112
+ if api_status == TaskStatus.COMPLETED:
113
+ return TaskResult(
114
+ task_id=task_id,
115
+ status=TaskStatus.COMPLETED,
116
+ result_url=task_data.get("resultUrl"),
117
+ )
118
+ if api_status == TaskStatus.FAILED:
119
+ error_msg = "NSFW_CONTENT" if task_data.get("hasHsfw") else task_data.get("msg", "task failed")
120
+ return TaskResult(task_id=task_id, status=TaskStatus.FAILED, error=error_msg)
121
+ return TaskResult(task_id=task_id, status=TaskStatus.IN_PROGRESS)
122
+
123
+ async def wait_for_task(
124
+ self,
125
+ task_id: str,
126
+ on_poll: Callable[[TaskResult], None] | None = None,
127
+ ) -> TaskResult:
128
+ """Poll *task_id* until it completes, fails, or times out.
129
+
130
+ *on_poll* is invoked after every poll cycle with the latest
131
+ ``TaskResult`` so callers can implement progress UIs.
132
+ """
133
+ start = time.monotonic()
134
+ while True:
135
+ result = await self.get_task_status(task_id)
136
+ result.elapsed_seconds = time.monotonic() - start
137
+
138
+ if on_poll is not None:
139
+ on_poll(result)
140
+
141
+ if result.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
142
+ return result
143
+
144
+ if result.elapsed_seconds >= self._max_poll_seconds:
145
+ result.status = TaskStatus.TIMEOUT
146
+ result.error = f"polling timed out after {self._max_poll_seconds}s"
147
+ return result
148
+
149
+ await asyncio.sleep(self._poll_interval)
150
+
151
+ async def submit_and_wait(
152
+ self,
153
+ path: str,
154
+ payload: dict[str, Any],
155
+ on_poll: Callable[[TaskResult], None] | None = None,
156
+ ) -> TaskResult:
157
+ """Convenience: create a task then poll until done."""
158
+ start = time.monotonic()
159
+ task_id = await self.create_task(path, payload)
160
+ result = await self.wait_for_task(task_id, on_poll=on_poll)
161
+ result.elapsed_seconds = time.monotonic() - start
162
+ return result
163
+
164
+ # ------------------------------------------------------------------
165
+ # Sync wrappers
166
+ # ------------------------------------------------------------------
167
+
168
+ def create_task_sync(self, path: str, payload: dict[str, Any]) -> str:
169
+ return asyncio.run(self.create_task(path, payload))
170
+
171
+ def wait_for_task_sync(self, task_id: str) -> TaskResult:
172
+ return asyncio.run(self.wait_for_task(task_id))
173
+
174
+ def submit_and_wait_sync(self, path: str, payload: dict[str, Any]) -> TaskResult:
175
+ return asyncio.run(self.submit_and_wait(path, payload))
fotor_sdk/models.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import IntEnum
5
+ from typing import Any
6
+
7
+
8
+ class TaskStatus(IntEnum):
9
+ UNKNOWN = -1
10
+ IN_PROGRESS = 0
11
+ COMPLETED = 1
12
+ FAILED = 2
13
+ TIMEOUT = 3
14
+ CANCELLED = 4
15
+
16
+
17
+ @dataclass
18
+ class TaskResult:
19
+ task_id: str
20
+ status: TaskStatus = TaskStatus.UNKNOWN
21
+ result_url: str | None = None
22
+ error: str | None = None
23
+ elapsed_seconds: float = 0.0
24
+ metadata: dict[str, Any] = field(default_factory=dict)
25
+
26
+ @property
27
+ def success(self) -> bool:
28
+ return self.status == TaskStatus.COMPLETED and self.result_url is not None
29
+
30
+ def __repr__(self) -> str:
31
+ status_name = self.status.name
32
+ if self.success:
33
+ return f"TaskResult(task_id={self.task_id!r}, status={status_name}, url={self.result_url!r})"
34
+ return f"TaskResult(task_id={self.task_id!r}, status={status_name}, error={self.error!r})"
35
+
36
+
37
+ @dataclass
38
+ class TaskSpec:
39
+ """Describes a single task to be submitted to the runner."""
40
+ task_type: str
41
+ params: dict[str, Any] = field(default_factory=dict)
42
+ tag: str = ""
fotor_sdk/runner.py ADDED
@@ -0,0 +1,159 @@
1
+ """Parallel task runner with concurrency control and status monitoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Any, Callable
9
+
10
+ from .client import FotorClient
11
+ from .models import TaskResult, TaskSpec, TaskStatus
12
+ from . import tasks as _tasks
13
+
14
+ logger = logging.getLogger("fotor_sdk")
15
+
16
+ # Map task_type strings to the async functions in tasks.py
17
+ _TASK_DISPATCH: dict[str, Callable[..., Any]] = {
18
+ "text2image": _tasks.text2image,
19
+ "image2image": _tasks.image2image,
20
+ "image_upscale": _tasks.image_upscale,
21
+ "background_remove": _tasks.background_remove,
22
+ "text2video": _tasks.text2video,
23
+ "single_image2video": _tasks.single_image2video,
24
+ "start_end_frame2video": _tasks.start_end_frame2video,
25
+ "multiple_image2video": _tasks.multiple_image2video,
26
+ }
27
+
28
+
29
+ class _ProgressTracker:
30
+ """Thread-safe progress state shared across concurrent tasks."""
31
+
32
+ def __init__(self, total: int, on_progress: Callable[..., None] | None):
33
+ self.total = total
34
+ self.completed = 0
35
+ self.failed = 0
36
+ self.in_progress = total
37
+ self._on_progress = on_progress
38
+ self._lock = asyncio.Lock()
39
+
40
+ async def mark_done(self, result: TaskResult) -> None:
41
+ async with self._lock:
42
+ self.in_progress -= 1
43
+ if result.status == TaskStatus.COMPLETED:
44
+ self.completed += 1
45
+ else:
46
+ self.failed += 1
47
+ if self._on_progress:
48
+ self._on_progress(
49
+ total=self.total,
50
+ completed=self.completed,
51
+ failed=self.failed,
52
+ in_progress=self.in_progress,
53
+ latest=result,
54
+ )
55
+
56
+ def summary(self) -> dict[str, int]:
57
+ return {
58
+ "total": self.total,
59
+ "completed": self.completed,
60
+ "failed": self.failed,
61
+ }
62
+
63
+
64
+ class TaskRunner:
65
+ """Execute many Fotor tasks in parallel with bounded concurrency.
66
+
67
+ Usage::
68
+
69
+ client = FotorClient(api_key="...")
70
+ runner = TaskRunner(client, max_concurrent=5)
71
+
72
+ specs = [
73
+ TaskSpec("text2image", {"prompt": "A cat", "model_id": "seedream-4-5-251128"}),
74
+ TaskSpec("text2video", {"prompt": "Ocean", "model_id": "kling-v3", "duration": 5}),
75
+ ]
76
+ results = await runner.run(specs, on_progress=print)
77
+ """
78
+
79
+ def __init__(self, client: FotorClient, max_concurrent: int = 5):
80
+ self._client = client
81
+ self._semaphore = asyncio.Semaphore(max_concurrent)
82
+
83
+ async def _execute_one(
84
+ self,
85
+ spec: TaskSpec,
86
+ tracker: _ProgressTracker,
87
+ on_task_poll: Callable[[TaskResult], None] | None,
88
+ ) -> TaskResult:
89
+ fn = _TASK_DISPATCH.get(spec.task_type)
90
+ if fn is None:
91
+ result = TaskResult(
92
+ task_id="",
93
+ status=TaskStatus.FAILED,
94
+ error=f"Unknown task_type: {spec.task_type!r}. "
95
+ f"Available: {list(_TASK_DISPATCH)}",
96
+ )
97
+ await tracker.mark_done(result)
98
+ return result
99
+
100
+ async with self._semaphore:
101
+ start = time.monotonic()
102
+ try:
103
+ result = await fn(self._client, on_poll=on_task_poll, **spec.params)
104
+ result.metadata["tag"] = spec.tag
105
+ except Exception as exc:
106
+ result = TaskResult(
107
+ task_id="",
108
+ status=TaskStatus.FAILED,
109
+ error=str(exc),
110
+ elapsed_seconds=time.monotonic() - start,
111
+ metadata={"tag": spec.tag},
112
+ )
113
+ await tracker.mark_done(result)
114
+ return result
115
+
116
+ async def run(
117
+ self,
118
+ specs: list[TaskSpec],
119
+ on_progress: Callable[..., None] | None = None,
120
+ on_task_poll: Callable[[TaskResult], None] | None = None,
121
+ ) -> list[TaskResult]:
122
+ """Run all *specs* in parallel (up to ``max_concurrent``).
123
+
124
+ Parameters
125
+ ----------
126
+ specs:
127
+ List of :class:`TaskSpec` describing what to execute.
128
+ on_progress:
129
+ Called after each task finishes with keyword args
130
+ ``total``, ``completed``, ``failed``, ``in_progress``, ``latest``.
131
+ on_task_poll:
132
+ Called during polling of each individual task (per-poll-cycle).
133
+
134
+ Returns
135
+ -------
136
+ list[TaskResult]
137
+ Results in the same order as *specs*.
138
+ """
139
+ if not specs:
140
+ return []
141
+
142
+ tracker = _ProgressTracker(len(specs), on_progress)
143
+ coros = [self._execute_one(spec, tracker, on_task_poll) for spec in specs]
144
+ results = await asyncio.gather(*coros, return_exceptions=False)
145
+
146
+ summary = tracker.summary()
147
+ logger.info(
148
+ "Batch complete: %d/%d succeeded, %d failed",
149
+ summary["completed"], summary["total"], summary["failed"],
150
+ )
151
+ return list(results)
152
+
153
+ def run_sync(
154
+ self,
155
+ specs: list[TaskSpec],
156
+ on_progress: Callable[..., None] | None = None,
157
+ ) -> list[TaskResult]:
158
+ """Synchronous wrapper around :meth:`run`."""
159
+ return asyncio.run(self.run(specs, on_progress=on_progress))
fotor_sdk/tasks.py ADDED
@@ -0,0 +1,248 @@
1
+ """High-level task helpers that build payloads and call :class:`FotorClient`.
2
+
3
+ Each function accepts plain Python arguments (no Pydantic models required),
4
+ constructs the correct API payload, submits the task, and polls until done.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Callable
10
+
11
+ from .client import FotorClient
12
+ from .models import TaskResult
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # API path constants (mirrors openapi/urls.py)
16
+ # ---------------------------------------------------------------------------
17
+ _IMAGE_GENERATION = "/v1/aiart/imagegeneration"
18
+ _IMG_UPSCALER = "/v1/aiart/imgupscale"
19
+ _BG_REMOVER = "/v1/aiart/backgroundremover"
20
+ _TXT2VIDEO = "/v1/aiart/text2video"
21
+ _IMG2VIDEO = "/v1/aiart/img2video"
22
+ _STARTEND2VIDEO = "/v1/aiart/startend2video"
23
+ _CHARACTER2VIDEO = "/v1/aiart/character2video"
24
+
25
+ # Default image sizes per aspect ratio.
26
+ # The API requires at least 3,686,400 total pixels (1920×1920 for 1:1).
27
+ _DEFAULT_SIZES: dict[str, tuple[int, int]] = {
28
+ "1:1": (1920, 1920),
29
+ "16:9": (2560, 1440),
30
+ "9:16": (1440, 2560),
31
+ "4:3": (2240, 1680),
32
+ "3:4": (1680, 2240),
33
+ "3:2": (2400, 1600),
34
+ "2:3": (1600, 2400),
35
+ "21:9": (2940, 1260),
36
+ }
37
+
38
+
39
+ def _resolve_size(
40
+ aspect_ratio: str, resolution: str
41
+ ) -> tuple[int, int]:
42
+ w, h = _DEFAULT_SIZES.get(aspect_ratio, (1920, 1920))
43
+ if resolution == "2k":
44
+ w, h = w * 2, h * 2
45
+ elif resolution == "4k":
46
+ w, h = w * 4, h * 4
47
+ return w, h
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Image tasks
52
+ # ---------------------------------------------------------------------------
53
+
54
+ async def text2image(
55
+ client: FotorClient,
56
+ *,
57
+ prompt: str,
58
+ model_id: str,
59
+ aspect_ratio: str = "auto",
60
+ resolution: str = "auto",
61
+ on_poll: Callable[[TaskResult], None] | None = None,
62
+ **extra: Any,
63
+ ) -> TaskResult:
64
+ """Generate an image from a text prompt."""
65
+ w, h = _resolve_size(aspect_ratio, resolution)
66
+ payload: dict[str, Any] = {
67
+ "width": w,
68
+ "height": h,
69
+ "content": [{"type": "text", "text": prompt}],
70
+ "quality": "medium",
71
+ **extra,
72
+ }
73
+ path = f"{_IMAGE_GENERATION}/{model_id}"
74
+ return await client.submit_and_wait(path, payload, on_poll=on_poll)
75
+
76
+
77
+ async def image2image(
78
+ client: FotorClient,
79
+ *,
80
+ prompt: str,
81
+ model_id: str,
82
+ image_urls: list[str],
83
+ aspect_ratio: str = "auto",
84
+ resolution: str = "auto",
85
+ on_poll: Callable[[TaskResult], None] | None = None,
86
+ **extra: Any,
87
+ ) -> TaskResult:
88
+ """Generate an image from text + reference image(s)."""
89
+ if not image_urls:
90
+ raise ValueError("image_urls must contain at least one URL")
91
+ w, h = _resolve_size(aspect_ratio, resolution)
92
+ content: list[dict[str, str]] = [{"type": "text", "text": prompt}]
93
+ for url in image_urls:
94
+ content.append({"type": "image_url", "url": url.strip()})
95
+ payload: dict[str, Any] = {
96
+ "width": w,
97
+ "height": h,
98
+ "content": content,
99
+ "quality": "medium",
100
+ **extra,
101
+ }
102
+ path = f"{_IMAGE_GENERATION}/{model_id}"
103
+ return await client.submit_and_wait(path, payload, on_poll=on_poll)
104
+
105
+
106
+ async def image_upscale(
107
+ client: FotorClient,
108
+ *,
109
+ image_url: str,
110
+ upscale_ratio: float = 2.0,
111
+ on_poll: Callable[[TaskResult], None] | None = None,
112
+ ) -> TaskResult:
113
+ """Upscale an image by 2x or 4x."""
114
+ payload = {
115
+ "upscaling_resize": upscale_ratio,
116
+ "userImageUrl": image_url,
117
+ "max_image_width": 2048,
118
+ "max_image_height": 2048,
119
+ }
120
+ return await client.submit_and_wait(_IMG_UPSCALER, payload, on_poll=on_poll)
121
+
122
+
123
+ async def background_remove(
124
+ client: FotorClient,
125
+ *,
126
+ image_url: str,
127
+ on_poll: Callable[[TaskResult], None] | None = None,
128
+ ) -> TaskResult:
129
+ """Remove the background from an image."""
130
+ payload = {
131
+ "userImageUrl": image_url,
132
+ "action": "auto",
133
+ }
134
+ return await client.submit_and_wait(_BG_REMOVER, payload, on_poll=on_poll)
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Video tasks
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def _video_payload(
142
+ prompt: str,
143
+ duration: int,
144
+ resolution: str,
145
+ aspect_ratio: str,
146
+ audio_enable: bool,
147
+ image_urls: list[str] | None = None,
148
+ **extra: Any,
149
+ ) -> dict[str, Any]:
150
+ payload: dict[str, Any] = {
151
+ "prompt": prompt,
152
+ "duration": duration,
153
+ "resolution": resolution,
154
+ "aspect_ratio": aspect_ratio if aspect_ratio != "auto" else "",
155
+ "scenes": "normal",
156
+ "enableAudio": audio_enable,
157
+ }
158
+ if image_urls is not None:
159
+ payload["imageUrls"] = image_urls
160
+ payload.update(extra)
161
+ return payload
162
+
163
+
164
+ async def text2video(
165
+ client: FotorClient,
166
+ *,
167
+ prompt: str,
168
+ model_id: str,
169
+ duration: int = 5,
170
+ resolution: str = "1080p",
171
+ aspect_ratio: str = "16:9",
172
+ audio_enable: bool = False,
173
+ on_poll: Callable[[TaskResult], None] | None = None,
174
+ **extra: Any,
175
+ ) -> TaskResult:
176
+ """Generate a video from a text prompt."""
177
+ payload = _video_payload(prompt, duration, resolution, aspect_ratio, audio_enable, **extra)
178
+ path = f"{_TXT2VIDEO}/{model_id}"
179
+ return await client.submit_and_wait(path, payload, on_poll=on_poll)
180
+
181
+
182
+ async def single_image2video(
183
+ client: FotorClient,
184
+ *,
185
+ prompt: str,
186
+ model_id: str,
187
+ image_url: str,
188
+ duration: int = 5,
189
+ resolution: str = "1080p",
190
+ aspect_ratio: str = "auto",
191
+ audio_enable: bool = False,
192
+ on_poll: Callable[[TaskResult], None] | None = None,
193
+ **extra: Any,
194
+ ) -> TaskResult:
195
+ """Animate a single image into a video."""
196
+ payload = _video_payload(
197
+ prompt, duration, resolution, aspect_ratio, audio_enable,
198
+ image_urls=[image_url], **extra,
199
+ )
200
+ path = f"{_IMG2VIDEO}/{model_id}"
201
+ return await client.submit_and_wait(path, payload, on_poll=on_poll)
202
+
203
+
204
+ async def start_end_frame2video(
205
+ client: FotorClient,
206
+ *,
207
+ prompt: str,
208
+ model_id: str,
209
+ start_image_url: str,
210
+ end_image_url: str,
211
+ duration: int = 5,
212
+ resolution: str = "1080p",
213
+ aspect_ratio: str = "auto",
214
+ audio_enable: bool = False,
215
+ on_poll: Callable[[TaskResult], None] | None = None,
216
+ **extra: Any,
217
+ ) -> TaskResult:
218
+ """Generate a video from start and end frame images."""
219
+ payload = _video_payload(
220
+ prompt, duration, resolution, aspect_ratio, audio_enable,
221
+ image_urls=[start_image_url, end_image_url], **extra,
222
+ )
223
+ path = f"{_STARTEND2VIDEO}/{model_id}"
224
+ return await client.submit_and_wait(path, payload, on_poll=on_poll)
225
+
226
+
227
+ async def multiple_image2video(
228
+ client: FotorClient,
229
+ *,
230
+ prompt: str,
231
+ model_id: str,
232
+ image_urls: list[str],
233
+ duration: int = 5,
234
+ resolution: str = "1080p",
235
+ aspect_ratio: str = "auto",
236
+ audio_enable: bool = False,
237
+ on_poll: Callable[[TaskResult], None] | None = None,
238
+ **extra: Any,
239
+ ) -> TaskResult:
240
+ """Generate a video from multiple reference images."""
241
+ if not image_urls or len(image_urls) < 2:
242
+ raise ValueError("image_urls must contain at least 2 URLs")
243
+ payload = _video_payload(
244
+ prompt, duration, resolution, aspect_ratio, audio_enable,
245
+ image_urls=image_urls, **extra,
246
+ )
247
+ path = f"{_CHARACTER2VIDEO}/{model_id}"
248
+ return await client.submit_and_wait(path, payload, on_poll=on_poll)
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: fotor-sdk
3
+ Version: 0.1.0
4
+ Summary: Lightweight async Python SDK for the Fotor OpenAPI
5
+ Project-URL: Homepage, https://github.com/zeng121/fotor-sdk
6
+ Project-URL: Documentation, https://github.com/zeng121/fotor-sdk#readme
7
+ Project-URL: Issues, https://github.com/zeng121/fotor-sdk/issues
8
+ License-Expression: MIT
9
+ Keywords: ai,async,fotor,image-generation,video-generation
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia :: Graphics
20
+ Classifier: Topic :: Multimedia :: Video
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: aiohttp>=3.9
23
+ Requires-Dist: python-dotenv>=1.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # fotor-sdk
27
+
28
+ Lightweight, async-first Python SDK for the [Fotor OpenAPI](https://api.fotor.com).
29
+ Generate images and videos with a single API key -- no MCP server, no S3, no
30
+ internal services required.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install -e .
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ### Single Task
41
+
42
+ ```python
43
+ import asyncio
44
+ import os
45
+ from fotor_sdk import FotorClient, text2image
46
+
47
+ async def main():
48
+ client = FotorClient(api_key=os.environ["FOTOR_OPENAPI_KEY"])
49
+ result = await text2image(
50
+ client,
51
+ prompt="A diamond kitten on velvet, studio lighting",
52
+ model_id="seedream-4-5-251128",
53
+ resolution="2k",
54
+ aspect_ratio="1:1",
55
+ )
56
+ print(result.result_url)
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ ### Parallel Batch with Progress
62
+
63
+ ```python
64
+ import asyncio
65
+ import os
66
+ from fotor_sdk import FotorClient, TaskRunner, TaskSpec
67
+
68
+ async def main():
69
+ client = FotorClient(api_key=os.environ["FOTOR_OPENAPI_KEY"])
70
+ runner = TaskRunner(client, max_concurrent=5)
71
+
72
+ specs = [
73
+ TaskSpec("text2image", {"prompt": "A cat", "model_id": "seedream-4-5-251128"}, tag="cat"),
74
+ TaskSpec("text2image", {"prompt": "A dog", "model_id": "seedream-4-5-251128"}, tag="dog"),
75
+ TaskSpec("text2video", {"prompt": "Sunset", "model_id": "kling-v3", "duration": 5}, tag="sunset"),
76
+ ]
77
+
78
+ def on_progress(total, completed, failed, in_progress, latest):
79
+ print(f" {completed + failed}/{total} done, latest: {latest.metadata.get('tag')}")
80
+
81
+ results = await runner.run(specs, on_progress=on_progress)
82
+ for r in results:
83
+ print(f"{r.metadata.get('tag')}: {r.status.name} -> {r.result_url}")
84
+
85
+ asyncio.run(main())
86
+ ```
87
+
88
+ ## Configuration
89
+
90
+ | Environment Variable | Required | Default | Description |
91
+ |---|---|---|---|
92
+ | `FOTOR_OPENAPI_KEY` | Yes | -- | Your Fotor OpenAPI key |
93
+ | `FOTOR_OPENAPI_ENDPOINT` | No | `https://api.fotor.com` | API base URL |
94
+
95
+ ## Available Task Functions
96
+
97
+ | Function | Description |
98
+ |---|---|
99
+ | `text2image()` | Generate image from text |
100
+ | `image2image()` | Edit / multi-reference generation |
101
+ | `image_upscale()` | 2x or 4x upscale |
102
+ | `background_remove()` | Remove background |
103
+ | `text2video()` | Generate video from text |
104
+ | `single_image2video()` | Animate a single image |
105
+ | `start_end_frame2video()` | Interpolate between two frames |
106
+ | `multiple_image2video()` | Video from multiple images |
107
+
108
+ ## Core Classes
109
+
110
+ ### FotorClient
111
+
112
+ ```python
113
+ FotorClient(
114
+ api_key: str,
115
+ endpoint: str = "https://api.fotor.com",
116
+ poll_interval: float = 2.0,
117
+ max_poll_seconds: float = 1200,
118
+ )
119
+ ```
120
+
121
+ **Methods:**
122
+ - `await create_task(path, payload) -> task_id`
123
+ - `await get_task_status(task_id) -> TaskResult`
124
+ - `await wait_for_task(task_id) -> TaskResult`
125
+ - `await submit_and_wait(path, payload) -> TaskResult`
126
+ - `submit_and_wait_sync(path, payload) -> TaskResult`
127
+
128
+ ### TaskRunner
129
+
130
+ ```python
131
+ TaskRunner(client: FotorClient, max_concurrent: int = 5)
132
+ ```
133
+
134
+ - `await run(specs, on_progress=None) -> list[TaskResult]`
135
+ - `run_sync(specs, on_progress=None) -> list[TaskResult]`
136
+
137
+ ### TaskResult
138
+
139
+ ```python
140
+ TaskResult(task_id, status, result_url, error, elapsed_seconds, metadata)
141
+ result.success # True when COMPLETED with a result_url
142
+ ```
143
+
144
+ ### TaskSpec
145
+
146
+ ```python
147
+ TaskSpec(task_type: str, params: dict, tag: str = "")
148
+ ```
149
+
150
+ ## Error Handling
151
+
152
+ ```python
153
+ from fotor_sdk import FotorAPIError
154
+
155
+ try:
156
+ result = await text2image(client, prompt="...", model_id="bad-model")
157
+ except FotorAPIError as e:
158
+ print(f"API error: {e} code={e.code}")
159
+ ```
160
+
161
+ For batch runs, failed tasks appear in results with `status=FAILED` and the
162
+ `error` field populated. The runner never raises on individual task failures.
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,8 @@
1
+ fotor_sdk/__init__.py,sha256=63CfGXev2WrgsBj0BBwsMAPgn_8gc5wnh6nEDyj2FIU,720
2
+ fotor_sdk/client.py,sha256=PUIa8Z8tSygRo9MmkXzW19lh9_WpVbPLqKSULZhb1Nk,6289
3
+ fotor_sdk/models.py,sha256=EHAW3jSJrkjcyptsOM2wXLsuod9SzUxLDVnU-_0ZjHU,1123
4
+ fotor_sdk/runner.py,sha256=mxdmc0IGt_-xscCfbmgLWRQCYTHub8L2yg1GcCpgRjA,5295
5
+ fotor_sdk/tasks.py,sha256=QCstuMlVEqDIDHAvWyyPd2atYGeTPh7UYJOI08hi5AA,7481
6
+ fotor_sdk-0.1.0.dist-info/METADATA,sha256=L5r4mvlMRRdZ3uvoCzSYlKETxH0kULULf2d7YFMMNxM,4687
7
+ fotor_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ fotor_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any