diff --git a/README.md b/README.md index dc9be53..bfc2855 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ uses — just exposed at scene granularity for interactive tools. - [x] Scene chaining with first/last frame conditioning - [x] Character bank with reference images - [x] TTS + subtitle integration -- [x] Auto-publishing — TikTok + YouTube (Instagram in progress) +- [x] Auto-publishing — TikTok, YouTube, Instagram Reels + Facebook (Meta Graph API) - [x] Scheduler for autonomous daily generation - [x] Quality validation and scene regeneration - [x] Agentic editing — in-app agent (kimi) + MCP server over the tool registry diff --git a/config/config.example.toml b/config/config.example.toml index 7698e17..4b209ee 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -61,10 +61,17 @@ voice_transcript = "" # Transcript of the voice sample [publish] enabled = false -platforms = ["tiktok", "youtube", "instagram"] +platforms = ["tiktok", "youtube", "instagram", "facebook"] tiktok_access_token = "" youtube_client_id = "" youtube_client_secret = "" +# Meta (Instagram Reels + Facebook Page) share one Graph API auth. Get a +# long-lived Page access token + link an IG Business/Creator account to the Page. +# Instagram Reels publish by public URL; Facebook also accepts a direct upload. +meta_page_access_token = "" +facebook_page_id = "" +instagram_business_account_id = "" +meta_graph_version = "v21.0" [scheduler] enabled = false diff --git a/src/flow/config.py b/src/flow/config.py index 19bf560..27754bd 100644 --- a/src/flow/config.py +++ b/src/flow/config.py @@ -37,10 +37,15 @@ class TTSConfig(BaseModel): class PublishConfig(BaseModel): enabled: bool = False - platforms: list[str] = ["tiktok", "youtube", "instagram"] + platforms: list[str] = ["tiktok", "youtube", "instagram", "facebook"] tiktok_access_token: str = "" youtube_client_id: str = "" youtube_client_secret: str = "" + # Meta (Instagram Reels + Facebook Page) — shared Graph API auth. + meta_page_access_token: str = "" # long-lived Page access token + facebook_page_id: str = "" # FB Page id (Facebook publishing) + instagram_business_account_id: str = "" # IG Business account id (IG publishing) + meta_graph_version: str = "v21.0" class SchedulerConfig(BaseModel): diff --git a/src/flow/publisher.py b/src/flow/publisher.py index 26b6b08..62e9498 100644 --- a/src/flow/publisher.py +++ b/src/flow/publisher.py @@ -19,8 +19,14 @@ class Publisher: def __init__(self, config: Config) -> None: self.config = config - def upload(self, video_path: Path, shot_list: ShotList) -> None: - """Upload video to configured platforms.""" + def upload(self, video_path: Path, shot_list: ShotList, + video_url: str | None = None) -> None: + """Upload video to configured platforms. + + ``video_url`` is a public URL for the video — **required** for Instagram + Reels (the Graph API ingests by URL, not binary) and optional for + Facebook (falls back to a binary upload when omitted). + """ metadata = self._generate_metadata(shot_list) for platform in self.config.publish.platforms: @@ -30,7 +36,9 @@ def upload(self, video_path: Path, shot_list: ShotList) -> None: elif platform == "youtube": self._upload_youtube(video_path, metadata) elif platform == "instagram": - self._upload_instagram(video_path, metadata) + self._upload_instagram(video_path, metadata, video_url) + elif platform == "facebook": + self._upload_facebook(video_path, metadata, video_url) else: console.print(f" ⚠ Unknown platform: {platform}") except Exception as e: @@ -118,12 +126,108 @@ def _upload_youtube( ) console.print(" ✓ YouTube: uploaded") + def _caption(self, metadata: dict) -> str: + """Build a caption from metadata (title + description + hashtags).""" + parts = [metadata.get("title", ""), metadata.get("description", "")] + tags = " ".join(f"#{t}" for t in metadata.get("tags", []) if t) + if tags: + parts.append(tags) + return "\n\n".join(p for p in parts if p)[:2200] + def _upload_instagram( - self, video_path: Path, metadata: dict + self, video_path: Path, metadata: dict, video_url: str | None = None ) -> None: - """Upload to Instagram Reels via Graph API. + """Publish an Instagram Reel via the Meta Graph API. - Requires Facebook Business account + app review. - Two-step: create media container → publish. + Three steps: create a REELS media container (by public ``video_url``) → + poll until processing FINISHED → publish. Needs a long-lived Page access + token + the linked IG Business account id. """ - console.print(" → Instagram: not yet implemented") + import time + + p = self.config.publish + token = p.meta_page_access_token + ig_id = p.instagram_business_account_id + if not token or not ig_id: + console.print( + " → Instagram: not configured " + "(meta_page_access_token + instagram_business_account_id)" + ) + return + if not video_url: + console.print( + " → Instagram: Reels require a public video_url — none provided, skipping" + ) + return + + base = f"https://graph.facebook.com/{p.meta_graph_version}" + # 1. Create the media container. + r = httpx.post( + f"{base}/{ig_id}/media", + data={ + "media_type": "REELS", "video_url": video_url, + "caption": self._caption(metadata), "access_token": token, + }, + timeout=60, + ) + r.raise_for_status() + creation_id = r.json()["id"] + + # 2. Poll until the container finishes processing. + for _ in range(30): + s = httpx.get( + f"{base}/{creation_id}", + params={"fields": "status_code", "access_token": token}, timeout=30, + ) + s.raise_for_status() + status = s.json().get("status_code") + if status == "FINISHED": + break + if status == "ERROR": + raise RuntimeError("Instagram container processing failed") + time.sleep(5) + else: + raise RuntimeError("Instagram container not ready in time") + + # 3. Publish. + pub = httpx.post( + f"{base}/{ig_id}/media_publish", + data={"creation_id": creation_id, "access_token": token}, timeout=60, + ) + pub.raise_for_status() + console.print(" ✓ Instagram: published") + + def _upload_facebook( + self, video_path: Path, metadata: dict, video_url: str | None = None + ) -> None: + """Publish a video to a Facebook Page via the Meta Graph API. + + Uses the same Page access token as Instagram. Posts by ``file_url`` when a + public URL is available, otherwise uploads the local file directly. + """ + p = self.config.publish + token = p.meta_page_access_token + page_id = p.facebook_page_id + if not token or not page_id: + console.print( + " → Facebook: not configured (meta_page_access_token + facebook_page_id)" + ) + return + + url = f"https://graph.facebook.com/{p.meta_graph_version}/{page_id}/videos" + data = { + "title": metadata.get("title", "")[:255], + "description": metadata.get("description", "")[:1000], + "access_token": token, + } + if video_url: + data["file_url"] = video_url + r = httpx.post(url, data=data, timeout=120) + else: + with open(video_path, "rb") as f: + r = httpx.post( + url, data=data, + files={"source": ("video.mp4", f, "video/mp4")}, timeout=600, + ) + r.raise_for_status() + console.print(" ✓ Facebook: published") diff --git a/tests/test_publisher.py b/tests/test_publisher.py new file mode 100644 index 0000000..fc2484c --- /dev/null +++ b/tests/test_publisher.py @@ -0,0 +1,98 @@ +"""Tests for the Meta Graph API publishers (Instagram Reels + Facebook Page).""" + +from pathlib import Path + +from flow.config import Config +from flow.publisher import Publisher +from flow.schemas import ShotList + + +class _Resp: + def __init__(self, payload=None): + self._payload = payload or {} + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +def _shotlist(): + return ShotList(title="My Reel", narration="A short story.", scenes=[]) + + +def test_instagram_publishes_via_graph_api(monkeypatch): + cfg = Config() + cfg.publish.platforms = ["instagram"] + cfg.publish.meta_page_access_token = "tok" + cfg.publish.instagram_business_account_id = "ig123" + calls = [] + + def fake_post(url, data=None, timeout=None, **kw): + calls.append(url) + if url.endswith("/media"): + return _Resp({"id": "creation1"}) + return _Resp({"id": "published1"}) + + def fake_get(url, params=None, timeout=None, **kw): + return _Resp({"status_code": "FINISHED"}) + + monkeypatch.setattr("flow.publisher.httpx.post", fake_post) + monkeypatch.setattr("flow.publisher.httpx.get", fake_get) + + Publisher(cfg).upload(Path("clip.mp4"), _shotlist(), video_url="https://cdn/x.mp4") + + assert any(u.endswith("/ig123/media") for u in calls) + assert any(u.endswith("/ig123/media_publish") for u in calls) + + +def test_instagram_skips_without_public_url(monkeypatch): + cfg = Config() + cfg.publish.platforms = ["instagram"] + cfg.publish.meta_page_access_token = "tok" + cfg.publish.instagram_business_account_id = "ig123" + called = [] + monkeypatch.setattr("flow.publisher.httpx.post", lambda *a, **k: called.append(1)) + + Publisher(cfg).upload(Path("clip.mp4"), _shotlist()) # no video_url + assert called == [] # honest skip, no API call + + +def test_instagram_skips_when_unconfigured(monkeypatch): + cfg = Config() + cfg.publish.platforms = ["instagram"] + called = [] + monkeypatch.setattr("flow.publisher.httpx.post", lambda *a, **k: called.append(1)) + + Publisher(cfg).upload(Path("clip.mp4"), _shotlist(), video_url="https://cdn/x.mp4") + assert called == [] # no token/ig id -> skip + + +def test_facebook_publishes_by_file_url(monkeypatch): + cfg = Config() + cfg.publish.platforms = ["facebook"] + cfg.publish.meta_page_access_token = "tok" + cfg.publish.facebook_page_id = "page123" + seen = {} + + def fake_post(url, data=None, timeout=None, **kw): + seen["url"] = url + seen["data"] = data + return _Resp({"id": "vid1"}) + + monkeypatch.setattr("flow.publisher.httpx.post", fake_post) + + Publisher(cfg).upload(Path("clip.mp4"), _shotlist(), video_url="https://cdn/x.mp4") + assert seen["url"].endswith("/page123/videos") + assert seen["data"]["file_url"] == "https://cdn/x.mp4" + + +def test_facebook_skips_when_unconfigured(monkeypatch): + cfg = Config() + cfg.publish.platforms = ["facebook"] + called = [] + monkeypatch.setattr("flow.publisher.httpx.post", lambda *a, **k: called.append(1)) + + Publisher(cfg).upload(Path("clip.mp4"), _shotlist(), video_url="https://cdn/x.mp4") + assert called == []