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 +34 -0
- fotor_sdk/client.py +175 -0
- fotor_sdk/models.py +42 -0
- fotor_sdk/runner.py +159 -0
- fotor_sdk/tasks.py +248 -0
- fotor_sdk-0.1.0.dist-info/METADATA +166 -0
- fotor_sdk-0.1.0.dist-info/RECORD +8 -0
- fotor_sdk-0.1.0.dist-info/WHEEL +4 -0
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,,
|