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.
- synfig_mcp/__init__.py +5 -0
- synfig_mcp/__main__.py +8 -0
- synfig_mcp/assets/__init__.py +8 -0
- synfig_mcp/assets/models.py +129 -0
- synfig_mcp/assets/pipeline.py +226 -0
- synfig_mcp/assets/providers/base.py +37 -0
- synfig_mcp/assets/providers/mock_provider.py +103 -0
- synfig_mcp/assets/providers/openverse.py +108 -0
- synfig_mcp/assets/security/raster_hardening.py +71 -0
- synfig_mcp/assets/security/svg_sanitizer.py +152 -0
- synfig_mcp/assets/security/url_validator.py +82 -0
- synfig_mcp/backend/__init__.py +1 -0
- synfig_mcp/backend/auto.py +589 -0
- synfig_mcp/backend/base.py +543 -0
- synfig_mcp/backend/detect.py +63 -0
- synfig_mcp/backend/headless.py +1401 -0
- synfig_mcp/backend/live.py +1056 -0
- synfig_mcp/backend/select.py +58 -0
- synfig_mcp/characters/__init__.py +25 -0
- synfig_mcp/characters/cutout.py +1038 -0
- synfig_mcp/characters/models.py +107 -0
- synfig_mcp/characters/production.py +451 -0
- synfig_mcp/characters/rigging.py +423 -0
- synfig_mcp/characters/sheet.py +403 -0
- synfig_mcp/characters/storyboard.py +102 -0
- synfig_mcp/cli/__init__.py +1 -0
- synfig_mcp/cli/runner.py +432 -0
- synfig_mcp/config.py +130 -0
- synfig_mcp/doctor.py +415 -0
- synfig_mcp/errors.py +126 -0
- synfig_mcp/http_transport.py +141 -0
- synfig_mcp/logging.py +42 -0
- synfig_mcp/prompts/__init__.py +1 -0
- synfig_mcp/protocol/__init__.py +1 -0
- synfig_mcp/protocol/client.py +272 -0
- synfig_mcp/protocol/fake_bridge.py +1146 -0
- synfig_mcp/protocol/models.py +212 -0
- synfig_mcp/recipes/__init__.py +19 -0
- synfig_mcp/recipes/engine.py +250 -0
- synfig_mcp/recipes/models.py +68 -0
- synfig_mcp/recipes/registry.py +370 -0
- synfig_mcp/recipes/styles.py +85 -0
- synfig_mcp/render/__init__.py +8 -0
- synfig_mcp/render/cache.py +52 -0
- synfig_mcp/render/jobs.py +166 -0
- synfig_mcp/render/pipeline.py +335 -0
- synfig_mcp/render/targets.py +128 -0
- synfig_mcp/resources/__init__.py +1 -0
- synfig_mcp/resources/guides.py +180 -0
- synfig_mcp/review/__init__.py +7 -0
- synfig_mcp/review/metrics.py +287 -0
- synfig_mcp/review/rubrics.py +85 -0
- synfig_mcp/review/service.py +227 -0
- synfig_mcp/schema/__init__.py +20 -0
- synfig_mcp/schema/cache.py +37 -0
- synfig_mcp/schema/param_types.json +566 -0
- synfig_mcp/schema/param_types.py +71 -0
- synfig_mcp/schema/probe.py +322 -0
- synfig_mcp/schema/registry.py +159 -0
- synfig_mcp/schema/scene_spec.py +61 -0
- synfig_mcp/security/__init__.py +1 -0
- synfig_mcp/security/sandbox.py +173 -0
- synfig_mcp/server.py +133 -0
- synfig_mcp/sif/__init__.py +23 -0
- synfig_mcp/sif/numerics.py +105 -0
- synfig_mcp/sif/parser.py +2213 -0
- synfig_mcp/sif/valuenodes.py +621 -0
- synfig_mcp/sif/writer.py +44 -0
- synfig_mcp/tools/__init__.py +5 -0
- synfig_mcp/tools/register.py +1704 -0
- synfig_mcp/validation.py +177 -0
- synfig_mcp-1.0.3.dist-info/METADATA +137 -0
- synfig_mcp-1.0.3.dist-info/RECORD +75 -0
- synfig_mcp-1.0.3.dist-info/WHEEL +4 -0
- synfig_mcp-1.0.3.dist-info/entry_points.txt +2 -0
synfig_mcp/__init__.py
ADDED
synfig_mcp/__main__.py
ADDED
|
@@ -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
|