Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ input/
*.png
*.jpg
*.jpeg
*.mp3
*.m4a
*.wav
*.ogg
*.flac
*.aac
!flow-extension/icon*.png
media-id.js

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Flow Agent

CLI, OpenAI-compatible API, Chrome extension bridge, and MCP server for Google
Flow image and video generation.
Flow image, video, and music generation.

[![Release](https://img.shields.io/github/v/release/kodelyx/flow-agent)](https://github.com/kodelyx/flow-agent/releases)
[![Build](https://github.com/kodelyx/flow-agent/actions/workflows/build.yml/badge.svg)](https://github.com/kodelyx/flow-agent/actions/workflows/build.yml)
Expand All @@ -13,9 +13,10 @@ HTTP API, and MCP clients all share one backend and one extension bridge.

- Text-to-image and reference-image generation
- Text-to-video, image-to-video, first/last-frame, reference-to-video, and video editing
- Text-to-music and soundtrack generation with custom duration, looping, and variations
- 4, 6, 8, and 10-second video generation
- Reusable generated and uploaded media IDs, including after backend restarts
- Exact `--output` paths with real PNG, JPEG, and WebP conversion
- Exact `--output` paths with real PNG, JPEG, WebP, and audio format conversion
- Signature-based MIME and extension detection
- Persistent idempotency for safe paid-generation retries
- OpenAI-compatible HTTP endpoints and pollable video jobs
Expand Down Expand Up @@ -133,6 +134,14 @@ flow video "animate this generated image" --start GENERATED_IMAGE_MEDIA_ID
flow video "use these existing references" --ref GENERATED_ID UPLOADED_ID
```

### Music

```bash
flow music "a cinematic ambient synth soundtrack" --duration 30
flow music "relaxing lo-fi hip hop beat" --loop --output bgm.mp3
flow music "epic orchestral intro" --duration 50 --count 2 --seed 42 -o ./soundtrack.mp3
```

### Upload and edit

```bash
Expand Down Expand Up @@ -283,6 +292,7 @@ JSON-RPC messages are sent to `http://127.0.0.1:8001/messages`.
- `get_flow_history` — generated and uploaded media history
- `generate_flow_image` — text/reference image generation
- `generate_flow_video` — text, start-image, and reference video generation
- `generate_flow_music` — text-to-music and soundtrack generation with loops and duration controls
- `upload_flow_media` — upload a local path, URL, or base64 media payload
- `download_media_from_url` — download media and optionally upload it to Flow
- `edit_flow_video` — edit a video by media ID or local video path
Expand All @@ -300,6 +310,7 @@ Default base URL: `http://127.0.0.1:8001`
| `POST /v1/images/generations` | Generate images |
| `POST /v1/videos/generations` | Submit video generation |
| `GET /v1/videos/generations/{job_id}` | Poll a video job |
| `POST /v1/audio/generations` | Generate music / audio tracks |
| `POST /v1/upload` | Upload an image or video reference |
| `GET /download/{filename}` | Download a managed media file |
| `GET /sse` | MCP over SSE |
Expand Down
17 changes: 16 additions & 1 deletion flow-agent/flow_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,23 @@
# ─── Public API ──────────────────────────────────────────────

from .bridge import ExtensionBridge
from .config import ASPECTS, DEFAULT_PROJECT, ENDPOINTS, CLIENT_CTX, API_KEY, API_BASE
from .config import (
ASPECTS,
DEFAULT_PROJECT,
ENDPOINTS,
CLIENT_CTX,
API_KEY,
API_BASE,
DEFAULT_MUSIC_DURATION,
MUSIC_DURATIONS,
)
from .generators import (
generate_video,
edit_video,
upload_image,
generate_video_i2v,
generate_music,
download_music,
poll_status,
download_video,
build_client_context,
Expand All @@ -51,6 +62,8 @@
"edit_video",
"upload_image",
"generate_video_i2v",
"generate_music",
"download_music",
"poll_status",
"download_video",
"build_client_context",
Expand All @@ -63,6 +76,8 @@
"CLIENT_CTX",
"API_KEY",
"API_BASE",
"DEFAULT_MUSIC_DURATION",
"MUSIC_DURATIONS",
# Media store
"media_store",
]
69 changes: 60 additions & 9 deletions flow-agent/flow_engine/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,12 +358,13 @@ async def _request_flow_tab_for(self, client_id):
async def _force_refresh_client(self, client_id):
"""Force a client to reload its Flow tab and re-capture a fresh token,
bypassing the extension's 50-min freshness cache. Used to self-heal a
stale token (Google invalidates via inactivity before the cache expires).
stale token (Google invalidates via inactivity before the cache expires)
or a stale reCAPTCHA session.
"""
if client_id not in self._clients:
if client_id not in self._clients and not self.http_registry.is_connected(client_id) and client_id != "__http__":
return
try:
log.info("Force-refreshing Flow tab for client %s (stale token self-heal)...", client_id)
log.info("Force-refreshing Flow tab for client %s (stale token/captcha self-heal)...", client_id)
self._tokens.pop(client_id, None)
await self.send_message_to(client_id, {"method": "force_refresh", "force": True})
await asyncio.sleep(6)
Expand Down Expand Up @@ -583,6 +584,19 @@ async def api_request(self, url_path, body, captcha_action="VIDEO_GENERATION", m
if fallback:
log.warning("Client %s still 401 after refresh — failing over to %s", client_id, fallback)
result = await self._run_api_request(fallback, url_path, body, captcha_action, method, timeout, meta)

# Self-heal on reCAPTCHA / UNUSUAL_ACTIVITY: force-reload the Flow tab so that
# the enterprise reCAPTCHA session re-initialises and issues a fresh valid token.
elif self._is_recaptcha_failure(result):
log.warning(
"Client %s returned reCAPTCHA / UNUSUAL_ACTIVITY — "
"force-reloading Flow tab and retrying once",
client_id,
)
await self._force_refresh_client(client_id)
result = await self._run_api_request(
client_id, url_path, body, captcha_action, method, timeout, meta
)
return result

@staticmethod
Expand All @@ -599,6 +613,30 @@ def _is_unauthenticated(result) -> bool:
return True
return False

@staticmethod
def _is_recaptcha_failure(result) -> bool:
"""True if Google or extension returned a reCAPTCHA / UNUSUAL_ACTIVITY failure."""
if not isinstance(result, dict):
return False
err_msg = str(result.get("error") or "")
if "CAPTCHA" in err_msg.upper() or "UNUSUAL_ACTIVITY" in err_msg:
return True
status = result.get("status")
if status in (400, 403):
data = result.get("data")
text = ""
if isinstance(data, str):
text = data
elif isinstance(data, dict):
err = data.get("error", {})
if isinstance(err, dict):
text = f"{err.get('message', '')} {err.get('status', '')} {err.get('details', '')}"
else:
text = str(err)
if "UNUSUAL_ACTIVITY" in text or "recaptcha" in text.lower():
return True
return False

def _select_client_excluding(self, exclude_id) -> str | None:
"""Pick another connected client with a token, skipping exclude_id."""
candidates = sorted([c for c in self._clients if c != exclude_id and self._tokens.get(c)])
Expand Down Expand Up @@ -649,7 +687,20 @@ async def _do_api_request(self, client_id, url_path, body, captcha_action, metho
**(meta or {}),
})

url = f"{API_BASE}{url_path}?key={API_KEY}"
if url_path.startswith("http://") or url_path.startswith("https://"):
url = url_path
is_flowmusic = "flowmusic.app" in url
origin = "https://www.flowmusic.app" if is_flowmusic else CLIENT_CTX["origin"]
referer = "https://www.flowmusic.app/session" if is_flowmusic else CLIENT_CTX["origin"] + "/"
content_type = "application/json" if is_flowmusic else "text/plain;charset=UTF-8"
site = "same-origin" if is_flowmusic else "cross-site"
else:
url = f"{API_BASE}{url_path}?key={API_KEY}"
origin = CLIENT_CTX["origin"]
referer = CLIENT_CTX["origin"] + "/"
content_type = "text/plain;charset=UTF-8"
site = "cross-site"

ua = random.choice(USER_AGENTS)
platform = '"macOS"' if "Macintosh" in ua else '"Windows"'

Expand All @@ -675,18 +726,18 @@ async def _do_api_request(self, client_id, url_path, body, captcha_action, metho
"method": method,
"headers": {
"accept": "*/*",
"content-type": "text/plain;charset=UTF-8",
"origin": CLIENT_CTX["origin"],
"referer": CLIENT_CTX["origin"] + "/",
"content-type": content_type,
"origin": origin,
"referer": referer,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": platform,
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "cross-site",
"sec-fetch-site": site,
"user-agent": ua,
},
"body": body,
"captchaAction": captcha_action,
"captchaAction": captcha_action if not ("flowmusic.app" in url) else None,
},
}

Expand Down
10 changes: 10 additions & 0 deletions flow-agent/flow_engine/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ def _flow_binary_dir() -> str:
"generate_fl": "/v1/video:batchAsyncGenerateVideoStartAndEndImage",
"generate_r2v": "/v1/video:batchAsyncGenerateVideoReferenceImages",
"generate_edit": "/v1/video:batchAsyncGenerateVideoEditVideo",
"flowmusic_conversation": "https://www.flowmusic.app/__api/conversation",
"flowmusic_stream": "https://www.flowmusic.app/__api/messages/{job_id}/stream?last_id=0",
"flowmusic_clips": "https://www.flowmusic.app/__api/clips",
"generate_music": "/v1:runMusicFx",
"generate_music_demo": "/v1:runSoundDemo",
"generate_music_sound": "/v1/sound:generate",
"generate_music_batch": "/v1/music:batchGenerateMusic",
"upload_image": "/v1/flow/uploadImage",
"poll_status": "/v1/video:batchCheckAsyncVideoGenerationStatus",
"get_media": "/v1/media/{media_id}",
Expand All @@ -136,6 +143,9 @@ def _flow_binary_dir() -> str:
DEFAULT_DURATION = 10
MAX_COUNT = 4

DEFAULT_MUSIC_DURATION = 30
MUSIC_DURATIONS = [10, 30, 50, 70]

CREDITS_PER_VIDEO = {
4: 7,
6: 10,
Expand Down
3 changes: 3 additions & 0 deletions flow-agent/flow_engine/generators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .v2v import edit_video
from .i2v import upload_image, generate_video_i2v
from .t2i import generate_image, download_image, IMAGE_ASPECTS
from .t2m import generate_music, download_music
from .common import poll_status, download_video, build_client_context

__all__ = [
Expand All @@ -13,6 +14,8 @@
"generate_video_i2v",
"generate_image",
"download_image",
"generate_music",
"download_music",
"IMAGE_ASPECTS",
"poll_status",
"download_video",
Expand Down
Loading