Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/flow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
120 changes: 112 additions & 8 deletions src/flow/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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")
98 changes: 98 additions & 0 deletions tests/test_publisher.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading