synfig-mcp 1.0.3__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.
Files changed (75) hide show
  1. synfig_mcp/__init__.py +5 -0
  2. synfig_mcp/__main__.py +8 -0
  3. synfig_mcp/assets/__init__.py +8 -0
  4. synfig_mcp/assets/models.py +129 -0
  5. synfig_mcp/assets/pipeline.py +226 -0
  6. synfig_mcp/assets/providers/base.py +37 -0
  7. synfig_mcp/assets/providers/mock_provider.py +103 -0
  8. synfig_mcp/assets/providers/openverse.py +108 -0
  9. synfig_mcp/assets/security/raster_hardening.py +71 -0
  10. synfig_mcp/assets/security/svg_sanitizer.py +152 -0
  11. synfig_mcp/assets/security/url_validator.py +82 -0
  12. synfig_mcp/backend/__init__.py +1 -0
  13. synfig_mcp/backend/auto.py +589 -0
  14. synfig_mcp/backend/base.py +543 -0
  15. synfig_mcp/backend/detect.py +63 -0
  16. synfig_mcp/backend/headless.py +1401 -0
  17. synfig_mcp/backend/live.py +1056 -0
  18. synfig_mcp/backend/select.py +58 -0
  19. synfig_mcp/characters/__init__.py +25 -0
  20. synfig_mcp/characters/cutout.py +1038 -0
  21. synfig_mcp/characters/models.py +107 -0
  22. synfig_mcp/characters/production.py +451 -0
  23. synfig_mcp/characters/rigging.py +423 -0
  24. synfig_mcp/characters/sheet.py +403 -0
  25. synfig_mcp/characters/storyboard.py +102 -0
  26. synfig_mcp/cli/__init__.py +1 -0
  27. synfig_mcp/cli/runner.py +432 -0
  28. synfig_mcp/config.py +130 -0
  29. synfig_mcp/doctor.py +415 -0
  30. synfig_mcp/errors.py +126 -0
  31. synfig_mcp/http_transport.py +141 -0
  32. synfig_mcp/logging.py +42 -0
  33. synfig_mcp/prompts/__init__.py +1 -0
  34. synfig_mcp/protocol/__init__.py +1 -0
  35. synfig_mcp/protocol/client.py +272 -0
  36. synfig_mcp/protocol/fake_bridge.py +1146 -0
  37. synfig_mcp/protocol/models.py +212 -0
  38. synfig_mcp/recipes/__init__.py +19 -0
  39. synfig_mcp/recipes/engine.py +250 -0
  40. synfig_mcp/recipes/models.py +68 -0
  41. synfig_mcp/recipes/registry.py +370 -0
  42. synfig_mcp/recipes/styles.py +85 -0
  43. synfig_mcp/render/__init__.py +8 -0
  44. synfig_mcp/render/cache.py +52 -0
  45. synfig_mcp/render/jobs.py +166 -0
  46. synfig_mcp/render/pipeline.py +335 -0
  47. synfig_mcp/render/targets.py +128 -0
  48. synfig_mcp/resources/__init__.py +1 -0
  49. synfig_mcp/resources/guides.py +180 -0
  50. synfig_mcp/review/__init__.py +7 -0
  51. synfig_mcp/review/metrics.py +287 -0
  52. synfig_mcp/review/rubrics.py +85 -0
  53. synfig_mcp/review/service.py +227 -0
  54. synfig_mcp/schema/__init__.py +20 -0
  55. synfig_mcp/schema/cache.py +37 -0
  56. synfig_mcp/schema/param_types.json +566 -0
  57. synfig_mcp/schema/param_types.py +71 -0
  58. synfig_mcp/schema/probe.py +322 -0
  59. synfig_mcp/schema/registry.py +159 -0
  60. synfig_mcp/schema/scene_spec.py +61 -0
  61. synfig_mcp/security/__init__.py +1 -0
  62. synfig_mcp/security/sandbox.py +173 -0
  63. synfig_mcp/server.py +133 -0
  64. synfig_mcp/sif/__init__.py +23 -0
  65. synfig_mcp/sif/numerics.py +105 -0
  66. synfig_mcp/sif/parser.py +2213 -0
  67. synfig_mcp/sif/valuenodes.py +621 -0
  68. synfig_mcp/sif/writer.py +44 -0
  69. synfig_mcp/tools/__init__.py +5 -0
  70. synfig_mcp/tools/register.py +1704 -0
  71. synfig_mcp/validation.py +177 -0
  72. synfig_mcp-1.0.3.dist-info/METADATA +137 -0
  73. synfig_mcp-1.0.3.dist-info/RECORD +75 -0
  74. synfig_mcp-1.0.3.dist-info/WHEEL +4 -0
  75. synfig_mcp-1.0.3.dist-info/entry_points.txt +2 -0
synfig_mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ synfig-mcp: Live Model Context Protocol control of Synfig Studio.
3
+ """
4
+
5
+ __version__ = "0.8.0-rc2"
synfig_mcp/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Entrypoint for python -m synfig_mcp.
3
+ """
4
+
5
+ from synfig_mcp.server import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,8 @@
1
+ """
2
+ Licensed Asset Pipeline (Phase A5).
3
+ """
4
+
5
+ from synfig_mcp.assets.models import AssetManifest, AssetMetadata, LicensePolicy
6
+ from synfig_mcp.assets.pipeline import AssetPipeline
7
+
8
+ __all__ = ["AssetManifest", "AssetMetadata", "AssetPipeline", "LicensePolicy"]
@@ -0,0 +1,129 @@
1
+ """
2
+ Data models for the Licensed Asset Pipeline, Provenance Manifest, and License Policies (Phase A5).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class LicensePolicy(BaseModel):
13
+ """
14
+ Configurable license policy for external assets.
15
+ Default configuration is strict: allows CC0/PDM, blocks ND/NC, flags BY/BY-SA.
16
+ """
17
+
18
+ allow_cc0: bool = Field(default=True, description="Allow CC0 / Public Domain Dedication.")
19
+ allow_pdm: bool = Field(default=True, description="Allow Public Domain Mark.")
20
+ allow_by: bool = Field(default=False, description="Allow CC BY (attribution required).")
21
+ allow_by_sa: bool = Field(default=False, description="Allow CC BY-SA (Share-Alike required).")
22
+ allow_commercial: bool = Field(
23
+ default=True, description="Whether project requires commercial use."
24
+ )
25
+ block_nd: bool = Field(default=True, description="Block NoDerivatives (we transform assets).")
26
+ block_nc: bool = Field(default=True, description="Block NonCommercial if commercial.")
27
+ skip_mature: bool = Field(default=True, description="Reject mature/NSFW flagged content.")
28
+
29
+ def evaluate_license(self, license_code: str, is_mature: bool = False) -> tuple[bool, str]:
30
+ """
31
+ Evaluate a license code against policy.
32
+ Returns: (allowed: bool, reason: str).
33
+ """
34
+ if self.skip_mature and is_mature:
35
+ return False, "Asset is flagged as mature content."
36
+
37
+ code = license_code.lower().strip()
38
+
39
+ if code in ("cc0", "pdm", "public-domain"):
40
+ return True, "Permitted by policy (Public Domain / CC0)."
41
+
42
+ if "nd" in code and self.block_nd:
43
+ return (
44
+ False,
45
+ f"License '{code}' contains No-Derivatives (ND) clause; animation transformations prohibited.",
46
+ )
47
+
48
+ if "nc" in code and self.block_nc and self.allow_commercial:
49
+ return (
50
+ False,
51
+ f"License '{code}' contains Non-Commercial (NC) restriction on a commercial project.",
52
+ )
53
+
54
+ if code == "by":
55
+ if self.allow_by:
56
+ return True, "Permitted by policy with attribution."
57
+ return False, "License 'by' requires attribution and is disabled under strict policy."
58
+
59
+ if code in ("by-sa", "sa"):
60
+ if self.allow_by_sa:
61
+ return True, "Permitted by policy with Share-Alike obligation."
62
+ return (
63
+ False,
64
+ "License 'by-sa' requires Share-Alike and is disabled under strict policy.",
65
+ )
66
+
67
+ return False, f"License '{code}' is not permitted under active policy."
68
+
69
+
70
+ class AssetMetadata(BaseModel):
71
+ """Provenance and licensing metadata captured at download time."""
72
+
73
+ id: str = Field(description="Unique asset identifier (e.g. 'openverse_12345').")
74
+ provider: str = Field(description="Source provider ('openverse', 'wikimedia', 'local').")
75
+ title: str = Field(description="Asset title or filename.")
76
+ creator: str = Field(default="Unknown", description="Original creator or copyright holder.")
77
+ license_code: str = Field(description="Standard license identifier ('cc0', 'pdm', 'by', etc.).")
78
+ license_url: str = Field(default="", description="Authoritative URL for license terms.")
79
+ attribution_text: str = Field(default="", description="Ready-to-use attribution snippet.")
80
+ download_url: str = Field(default="", description="Original HTTPS source download URL.")
81
+ thumbnail_url: str | None = Field(default=None, description="Small preview URL.")
82
+ filesize_bytes: int = Field(default=0, description="Size on disk in bytes.")
83
+ sha256: str = Field(default="", description="Cryptographic SHA-256 hash.")
84
+ local_path: str = Field(default="", description="Local file path within workspace.")
85
+ retrieved_at: str = Field(
86
+ default_factory=lambda: datetime.datetime.now(datetime.UTC).isoformat(),
87
+ description="ISO 8601 UTC timestamp of download.",
88
+ )
89
+ rights_declaration: str = Field(
90
+ default="", description="Declared rights basis ('own', 'licensed', 'cc0', 'public-domain')."
91
+ )
92
+ mature: bool = Field(default=False, description="Whether asset is flagged as mature.")
93
+
94
+
95
+ class AssetManifest(BaseModel):
96
+ """Collection manifest of all imported assets stored at assets/manifest.json."""
97
+
98
+ version: str = Field(default="1.0")
99
+ assets: list[AssetMetadata] = Field(default_factory=list)
100
+
101
+ def generate_attribution_md(self) -> str:
102
+ """Auto-generate ATTRIBUTION.md for legal compliance and project distribution."""
103
+ lines = [
104
+ "# Project Asset Attribution & License Provenance",
105
+ "",
106
+ "> Auto-generated by Synfig MCP Asset Pipeline.",
107
+ "> Every external asset used in this animation project is recorded with source and terms snapshot.",
108
+ "",
109
+ "| Asset Title | Creator | License | Provider | SHA-256 | Local File |",
110
+ "|---|---|---|---|---|---|",
111
+ ]
112
+ for a in self.assets:
113
+ c_link = f"[{a.creator}]({a.license_url})" if a.license_url else a.creator
114
+ lines.append(
115
+ f"| **{a.title}** | {c_link} | `{a.license_code.upper()}` | {a.provider} | `{a.sha256[:12]}...` | `{a.local_path}` |"
116
+ )
117
+
118
+ lines.extend(
119
+ [
120
+ "",
121
+ "## Attribution Statements",
122
+ "",
123
+ ]
124
+ )
125
+ for a in self.assets:
126
+ if a.attribution_text:
127
+ lines.append(f"- **{a.title}**: {a.attribution_text}")
128
+
129
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,226 @@
1
+ """
2
+ Asset Pipeline: Manages downloading, hardened sanitization, cryptographic hashing,
3
+ manifest recording, and attribution generation (Phase A5).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import json
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from synfig_mcp.assets.models import AssetManifest, AssetMetadata, LicensePolicy
16
+ from synfig_mcp.assets.providers.base import BaseAssetProvider
17
+ from synfig_mcp.assets.providers.mock_provider import MockAssetProvider
18
+ from synfig_mcp.assets.providers.openverse import OpenverseAssetProvider
19
+ from synfig_mcp.assets.security.raster_hardening import harden_raster_image
20
+ from synfig_mcp.assets.security.svg_sanitizer import sanitize_svg
21
+ from synfig_mcp.assets.security.url_validator import validate_asset_url
22
+ from synfig_mcp.security.sandbox import get_default_allowed_roots
23
+
24
+
25
+ class AssetPipeline:
26
+ """Orchestrator for the licensed internet asset pipeline."""
27
+
28
+ def __init__(
29
+ self,
30
+ policy: LicensePolicy | None = None,
31
+ providers: list[BaseAssetProvider] | None = None,
32
+ ) -> None:
33
+ self.policy = policy or LicensePolicy()
34
+ self.providers: dict[str, BaseAssetProvider] = {
35
+ "openverse": OpenverseAssetProvider(),
36
+ "mock": MockAssetProvider(),
37
+ }
38
+ if providers:
39
+ for p in providers:
40
+ self.providers[p.name] = p
41
+
42
+ def get_provider(self, name: str) -> BaseAssetProvider:
43
+ """Resolve asset provider, falling back to mock provider for offline resilience."""
44
+ return self.providers.get(name.lower(), self.providers["mock"])
45
+
46
+ def _get_assets_dir(self) -> Path:
47
+ """Return workspace assets directory."""
48
+ workspace = get_default_allowed_roots()[0]
49
+ assets_dir = workspace / "assets"
50
+ assets_dir.mkdir(parents=True, exist_ok=True)
51
+ return assets_dir
52
+
53
+ def _load_manifest(self) -> AssetManifest:
54
+ """Load manifest.json from workspace assets directory."""
55
+ manifest_file = self._get_assets_dir() / "manifest.json"
56
+ if manifest_file.exists():
57
+ try:
58
+ data = json.loads(manifest_file.read_text(encoding="utf-8"))
59
+ return AssetManifest.model_validate(data)
60
+ except Exception:
61
+ pass
62
+ return AssetManifest()
63
+
64
+ def _save_manifest(self, manifest: AssetManifest) -> None:
65
+ """Save updated manifest.json and regenerate ATTRIBUTION.md."""
66
+ assets_dir = self._get_assets_dir()
67
+ manifest_file = assets_dir / "manifest.json"
68
+ manifest_file.write_text(json.dumps(manifest.model_dump(), indent=2), encoding="utf-8")
69
+
70
+ attribution_file = assets_dir / "ATTRIBUTION.md"
71
+ attribution_file.write_text(manifest.generate_attribution_md(), encoding="utf-8")
72
+
73
+ async def search(
74
+ self,
75
+ query: str,
76
+ provider: str = "openverse",
77
+ license_type: str | None = None,
78
+ page: int = 1,
79
+ ) -> list[dict[str, Any]]:
80
+ """Search provider for media assets returning token-efficient text descriptors."""
81
+ prov = self.get_provider(provider)
82
+ return await prov.search(query=query, license_type=license_type, page=page)
83
+
84
+ async def preview(
85
+ self,
86
+ asset_id: str,
87
+ provider: str = "openverse",
88
+ ) -> dict[str, Any]:
89
+ """Fetch small thumbnail preview image as base64."""
90
+ prov = self.get_provider(provider)
91
+ raw_bytes = await prov.fetch_preview(asset_id)
92
+ if not raw_bytes:
93
+ return {"success": False, "error": f"Preview unavailable for asset '{asset_id}'"}
94
+
95
+ return {
96
+ "success": True,
97
+ "asset_id": asset_id,
98
+ "thumbnail_base64": base64.b64encode(raw_bytes).decode("utf-8"),
99
+ }
100
+
101
+ async def download_asset(
102
+ self,
103
+ asset_id: str,
104
+ provider: str = "openverse",
105
+ ) -> dict[str, Any]:
106
+ """
107
+ Download, validate, sanitize, hash, and record external asset.
108
+ """
109
+ prov = self.get_provider(provider)
110
+ meta = await prov.get_details(asset_id)
111
+ if not meta:
112
+ return {"success": False, "error": f"Asset '{asset_id}' not found."}
113
+
114
+ # 1. License Policy evaluation
115
+ allowed, reason = self.policy.evaluate_license(meta.license_code)
116
+ if not allowed:
117
+ return {
118
+ "success": False,
119
+ "error": f"License check failed: {reason}",
120
+ "license_code": meta.license_code,
121
+ }
122
+
123
+ # 2. SSRF URL validation
124
+ validate_asset_url(meta.download_url)
125
+
126
+ # 3. Download bytes with limits
127
+ req = urllib.request.Request(meta.download_url, headers={"User-Agent": "SynfigMCP/0.8.0"})
128
+ with urllib.request.urlopen(req, timeout=15.0) as resp:
129
+ content_bytes = resp.read(25 * 1024 * 1024) # 25 MB max download ceiling
130
+
131
+ assets_dir = self._get_assets_dir()
132
+ is_svg = b"<svg" in content_bytes[:2048].lower()
133
+
134
+ ext = ".svg" if is_svg else ".png"
135
+ clean_filename = f"{meta.id}{ext}"
136
+ target_path = assets_dir / clean_filename
137
+
138
+ # 4. Content sanitization and hardening
139
+ if is_svg:
140
+ sanitized_xml = sanitize_svg(content_bytes)
141
+ target_path.write_text(sanitized_xml, encoding="utf-8")
142
+ else:
143
+ temp_raw = assets_dir / f"tmp_{meta.id}"
144
+ temp_raw.write_bytes(content_bytes)
145
+ try:
146
+ harden_raster_image(temp_raw, target_path)
147
+ finally:
148
+ if temp_raw.exists():
149
+ temp_raw.unlink()
150
+
151
+ # 5. Cryptographic SHA-256 hash & Provenance record
152
+ final_bytes = target_path.read_bytes()
153
+ sha256_hash = hashlib.sha256(final_bytes).hexdigest()
154
+
155
+ meta.sha256 = sha256_hash
156
+ meta.local_path = str(target_path)
157
+ meta.filesize_bytes = len(final_bytes)
158
+
159
+ manifest = self._load_manifest()
160
+ # Update or append
161
+ manifest.assets = [a for a in manifest.assets if a.id != meta.id]
162
+ manifest.assets.append(meta)
163
+ self._save_manifest(manifest)
164
+
165
+ return {
166
+ "success": True,
167
+ "asset_id": meta.id,
168
+ "title": meta.title,
169
+ "license": meta.license_code,
170
+ "local_path": str(target_path),
171
+ "sha256": sha256_hash,
172
+ "attribution": meta.attribution_text,
173
+ }
174
+
175
+ async def import_local_asset(
176
+ self,
177
+ file_path: str,
178
+ rights: str,
179
+ title: str,
180
+ creator: str,
181
+ license_code: str = "own",
182
+ ) -> dict[str, Any]:
183
+ """
184
+ Import local asset with mandatory rights declaration in {own, licensed, cc0, public-domain}.
185
+ """
186
+ valid_rights = {"own", "licensed", "cc0", "public-domain"}
187
+ declared = rights.lower().strip()
188
+ if declared not in valid_rights:
189
+ return {
190
+ "success": False,
191
+ "error": f"Invalid rights declaration '{rights}'. Must be one of: {sorted(valid_rights)}",
192
+ }
193
+
194
+ p = Path(file_path)
195
+ if not p.exists():
196
+ return {"success": False, "error": f"File '{file_path}' does not exist."}
197
+
198
+ raw_bytes = p.read_bytes()
199
+ sha256_hash = hashlib.sha256(raw_bytes).hexdigest()
200
+ asset_id = f"local_{sha256_hash[:10]}"
201
+
202
+ meta = AssetMetadata(
203
+ id=asset_id,
204
+ provider="local",
205
+ title=title or p.name,
206
+ creator=creator or "Local User",
207
+ license_code=license_code,
208
+ rights_declaration=declared,
209
+ download_url="local://workspace",
210
+ sha256=sha256_hash,
211
+ local_path=str(p.resolve()),
212
+ filesize_bytes=len(raw_bytes),
213
+ )
214
+
215
+ manifest = self._load_manifest()
216
+ manifest.assets = [a for a in manifest.assets if a.id != meta.id]
217
+ manifest.assets.append(meta)
218
+ self._save_manifest(manifest)
219
+
220
+ return {
221
+ "success": True,
222
+ "asset_id": asset_id,
223
+ "rights": declared,
224
+ "sha256": sha256_hash,
225
+ "local_path": str(p.resolve()),
226
+ }
@@ -0,0 +1,37 @@
1
+ """
2
+ Abstract base class for Asset Provider adapters (Phase A5).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any
9
+
10
+ from synfig_mcp.assets.models import AssetMetadata
11
+
12
+
13
+ class BaseAssetProvider(ABC):
14
+ """Abstract interface for external media and asset providers."""
15
+
16
+ @property
17
+ @abstractmethod
18
+ def name(self) -> str:
19
+ """Provider identifier."""
20
+
21
+ @abstractmethod
22
+ async def search(
23
+ self,
24
+ query: str,
25
+ license_type: str | None = None,
26
+ page: int = 1,
27
+ page_size: int = 10,
28
+ ) -> list[dict[str, Any]]:
29
+ """Search provider for media assets, returning compact text descriptors."""
30
+
31
+ @abstractmethod
32
+ async def get_details(self, asset_id: str) -> AssetMetadata | None:
33
+ """Fetch complete licensing and provenance details for an asset."""
34
+
35
+ @abstractmethod
36
+ async def fetch_preview(self, asset_id: str) -> bytes | None:
37
+ """Fetch small thumbnail image bytes for LLM preview."""
@@ -0,0 +1,103 @@
1
+ """
2
+ Offline Mock Asset Provider: Serves deterministic licensed fixtures for air-gapped CI and testing.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from synfig_mcp.assets.models import AssetMetadata
10
+ from synfig_mcp.assets.providers.base import BaseAssetProvider
11
+
12
+ # 1x1 valid PNG bytes
13
+ MOCK_PNG_BYTES = (
14
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
15
+ b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf"
16
+ b"\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99=\x1d\x00\x00\x00\x00IEND\xaeB`\x82"
17
+ )
18
+
19
+ MOCK_FIXTURES: dict[str, dict[str, Any]] = {
20
+ "mock_cloud_01": {
21
+ "id": "mock_cloud_01",
22
+ "provider": "mock",
23
+ "title": "Stylized Vector Cloud",
24
+ "creator": "OpenArtist",
25
+ "license_code": "cc0",
26
+ "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
27
+ "attribution_text": "Stylized Vector Cloud by OpenArtist is dedicated to the public domain via CC0 1.0.",
28
+ "download_url": "https://raw.githubusercontent.com/synfig/assets/cloud.png",
29
+ "thumbnail_url": "https://raw.githubusercontent.com/synfig/assets/cloud_thumb.png",
30
+ "filesize_bytes": len(MOCK_PNG_BYTES),
31
+ "mature": False,
32
+ },
33
+ "mock_tree_02": {
34
+ "id": "mock_tree_02",
35
+ "provider": "mock",
36
+ "title": "Silhouette Pine Tree",
37
+ "creator": "CommonsUser",
38
+ "license_code": "by",
39
+ "license_url": "https://creativecommons.org/licenses/by/4.0/",
40
+ "attribution_text": "Silhouette Pine Tree by CommonsUser, used under CC BY 4.0.",
41
+ "download_url": "https://raw.githubusercontent.com/synfig/assets/tree.png",
42
+ "thumbnail_url": "https://raw.githubusercontent.com/synfig/assets/tree_thumb.png",
43
+ "filesize_bytes": len(MOCK_PNG_BYTES),
44
+ "mature": False,
45
+ },
46
+ "mock_nd_restricted": {
47
+ "id": "mock_nd_restricted",
48
+ "provider": "mock",
49
+ "title": "Restricted Photo",
50
+ "creator": "StrictPhotographer",
51
+ "license_code": "by-nd",
52
+ "license_url": "https://creativecommons.org/licenses/by-nd/4.0/",
53
+ "attribution_text": "Restricted Photo by StrictPhotographer (CC BY-ND 4.0)",
54
+ "download_url": "https://raw.githubusercontent.com/synfig/assets/photo.png",
55
+ "filesize_bytes": len(MOCK_PNG_BYTES),
56
+ "mature": False,
57
+ },
58
+ }
59
+
60
+
61
+ class MockAssetProvider(BaseAssetProvider):
62
+ """In-memory fixture provider for testing license filtering and pipeline execution."""
63
+
64
+ @property
65
+ def name(self) -> str:
66
+ return "mock"
67
+
68
+ async def search(
69
+ self,
70
+ query: str,
71
+ license_type: str | None = None,
72
+ page: int = 1,
73
+ page_size: int = 10,
74
+ ) -> list[dict[str, Any]]:
75
+ del page, page_size
76
+ results: list[dict[str, Any]] = []
77
+ q = query.lower()
78
+
79
+ for f in MOCK_FIXTURES.values():
80
+ if q in f["title"].lower() or q in f["id"].lower():
81
+ if license_type and f["license_code"] != license_type.lower():
82
+ continue
83
+ results.append(
84
+ {
85
+ "id": f["id"],
86
+ "title": f["title"],
87
+ "creator": f["creator"],
88
+ "license": f["license_code"],
89
+ "provider": self.name,
90
+ }
91
+ )
92
+ return results
93
+
94
+ async def get_details(self, asset_id: str) -> AssetMetadata | None:
95
+ raw = MOCK_FIXTURES.get(asset_id)
96
+ if not raw:
97
+ return None
98
+ return AssetMetadata(**raw)
99
+
100
+ async def fetch_preview(self, asset_id: str) -> bytes | None:
101
+ if asset_id in MOCK_FIXTURES:
102
+ return MOCK_PNG_BYTES
103
+ return None
@@ -0,0 +1,108 @@
1
+ """
2
+ Openverse API Provider Adapter (Phase A5).
3
+ Searches CC-licensed media via https://api.openverse.org/v1/images/.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any
12
+
13
+ from synfig_mcp.assets.models import AssetMetadata
14
+ from synfig_mcp.assets.providers.base import BaseAssetProvider
15
+ from synfig_mcp.assets.security.url_validator import validate_asset_url
16
+
17
+ OPENVERSE_API_URL = "https://api.openverse.org/v1/images/"
18
+
19
+
20
+ class OpenverseAssetProvider(BaseAssetProvider):
21
+ """Adapter for searching and retrieving open-licensed assets from Openverse."""
22
+
23
+ @property
24
+ def name(self) -> str:
25
+ return "openverse"
26
+
27
+ async def search(
28
+ self,
29
+ query: str,
30
+ license_type: str | None = None,
31
+ page: int = 1,
32
+ page_size: int = 10,
33
+ ) -> list[dict[str, Any]]:
34
+ params = {
35
+ "q": query,
36
+ "page": str(page),
37
+ "page_size": str(min(page_size, 20)),
38
+ }
39
+ if license_type:
40
+ params["license"] = license_type.lower()
41
+
42
+ url = f"{OPENVERSE_API_URL}?{urllib.parse.urlencode(params)}"
43
+ validate_asset_url(url)
44
+
45
+ req = urllib.request.Request(
46
+ url, headers={"User-Agent": "SynfigMCP/0.8.0 (Studio-Grade Animation)"}
47
+ )
48
+ try:
49
+ with urllib.request.urlopen(req, timeout=10.0) as resp:
50
+ data = json.loads(resp.read().decode("utf-8"))
51
+ except Exception:
52
+ return []
53
+
54
+ results: list[dict[str, Any]] = []
55
+ for item in data.get("results", []):
56
+ results.append(
57
+ {
58
+ "id": f"ov_{item.get('id', '')}",
59
+ "title": item.get("title", "Untitled"),
60
+ "creator": item.get("creator", "Unknown"),
61
+ "license": item.get("license", "unknown"),
62
+ "provider": self.name,
63
+ "filesize": item.get("filesize", 0),
64
+ }
65
+ )
66
+ return results
67
+
68
+ async def get_details(self, asset_id: str) -> AssetMetadata | None:
69
+ clean_id = asset_id.replace("ov_", "")
70
+ url = f"{OPENVERSE_API_URL}{clean_id}/"
71
+ validate_asset_url(url)
72
+
73
+ req = urllib.request.Request(
74
+ url, headers={"User-Agent": "SynfigMCP/0.8.0 (Studio-Grade Animation)"}
75
+ )
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=10.0) as resp:
78
+ data = json.loads(resp.read().decode("utf-8"))
79
+ except Exception:
80
+ return None
81
+
82
+ return AssetMetadata(
83
+ id=asset_id,
84
+ provider=self.name,
85
+ title=data.get("title", "Untitled"),
86
+ creator=data.get("creator", "Unknown"),
87
+ license_code=data.get("license", "unknown"),
88
+ license_url=data.get("license_url", ""),
89
+ attribution_text=data.get("attribution", ""),
90
+ download_url=data.get("url", ""),
91
+ thumbnail_url=data.get("thumbnail", None),
92
+ filesize_bytes=data.get("filesize", 0),
93
+ mature=bool(data.get("mature", False)),
94
+ )
95
+
96
+ async def fetch_preview(self, asset_id: str) -> bytes | None:
97
+ meta = await self.get_details(asset_id)
98
+ if not meta or not meta.thumbnail_url:
99
+ return None
100
+
101
+ validate_asset_url(meta.thumbnail_url)
102
+ req = urllib.request.Request(meta.thumbnail_url, headers={"User-Agent": "SynfigMCP/0.8.0"})
103
+ try:
104
+ with urllib.request.urlopen(req, timeout=10.0) as resp:
105
+ data = resp.read()
106
+ return bytes(data) if isinstance(data, (bytes, bytearray)) else None
107
+ except Exception:
108
+ return None