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
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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
- 4, 6, 8, and 10-second video generation
- 1080p and 4K video delivery through Flow's upsampler, the same pass behind the Flow UI's HD download
- Reusable generated and uploaded media IDs, including after backend restarts
- Exact `--output` paths with real PNG, JPEG, and WebP conversion
- Signature-based MIME and extension detection
Expand Down Expand Up @@ -125,6 +126,31 @@ flow video "transition between scenes" --start first.png --end last.png
flow video "keep this character consistent" --ref character.png
```

#### Resolution

Google Flow generates video at 720p. 1080p and 4K are a second *upsampler* pass
over the finished clip — exactly what the Flow UI does behind its
high-resolution download. Ask for it during generation:

```bash
flow video "a dragon flying over mountains" --resolution 1080p
flow video "a dragon flying over mountains" --resolution 4k -o /absolute/path/dragon.mp4
```

The upsampled file is written to `--output`; the 720p original stays in the
output directory and in `history.json`. If the upsample pass fails, the 720p
video is still delivered and the CLI prints a note.

Upsample a clip you already generated:

```bash
flow upsample GENERATED_VIDEO_MEDIA_ID --resolution 1080p
flow upsample previous_take.mp4 --resolution 4k -o /absolute/path/take_4k.mp4
```

`1080p` upsampling is free. `4k` costs credits and needs a higher Flow tier;
the backend refuses it with HTTP 402 when the balance cannot cover it.

`--start`, `--end`, and `--ref` accept either local image paths or exact media
IDs stored in `history.json`:

Expand Down Expand Up @@ -282,7 +308,8 @@ JSON-RPC messages are sent to `http://127.0.0.1:8001/messages`.
- `list_flow_models` — available models and the active default
- `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_video` — text, start-image, and reference video generation, with `resolution` for 720p/1080p/4K delivery
- `upsample_flow_video` — upsample an existing video to 1080p or 4K
- `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 @@ -299,12 +326,19 @@ Default base URL: `http://127.0.0.1:8001`
| `GET /v1/history` | Persistent generated/uploaded media history |
| `POST /v1/images/generations` | Generate images |
| `POST /v1/videos/generations` | Submit video generation |
| `GET /v1/videos/generations/{job_id}` | Poll a video job |
| `GET /v1/videos/generations/{job_id}` | Poll a video or upsample job |
| `POST /v1/videos/upsample` | Upsample an existing video to 1080p or 4K |
| `POST /v1/upload` | Upload an image or video reference |
| `GET /download/{filename}` | Download a managed media file |
| `GET /sse` | MCP over SSE |
| `POST /messages` | MCP over SSE JSON-RPC messages |

`POST /v1/videos/generations` accepts `"resolution": "720p" | "1080p" | "4k"`.
Above 720p the response leads with the upsampled media and still includes the
720p original, each entry carrying `resolution` and, for upsampled files,
`source_media_id`. `POST /v1/videos/upsample` returns the same pollable job
shape and is polled through `GET /v1/videos/generations/{job_id}`.

Send an `Idempotency-Key` header when an HTTP generation request may be
retried. Video submission returns a structured result containing a `job_id`,
status, creation timestamp, and media data. Poll the job endpoint until it is
Expand Down Expand Up @@ -334,6 +368,15 @@ Environment variables take precedence over values in `.env`.
| `FLOW_VIDEO_POLL_TIMEOUT` | `900` | CLI video-job polling timeout |
| `MAX_CONCURRENT_REQUESTS` | `5` | Maximum concurrent Flow requests |
| `REQUEST_MIN_INTERVAL` | `3` | Minimum seconds between request starts |
| `VIDEO_UPSAMPLER_1080P_MODEL` | `veo_3_1_upsampler_1080p` | Flow 1080p upsampler model key |
| `VIDEO_UPSAMPLER_4K_MODEL` | `veo_3_1_upsampler_4k` | Flow 4K upsampler model key |
| `VIDEO_UPSAMPLE_ENUM_1080P` | `VIDEO_RESOLUTION_1080P` | 1080p resolution enum sent to Flow |
| `VIDEO_UPSAMPLE_ENUM_4K` | `VIDEO_RESOLUTION_4K` | 4K resolution enum sent to Flow |

The four upsampler variables exist because Flow's upsample API is undocumented.
If Google renames a model key or a resolution enum, override it here instead of
patching code. A rejected enum is retried with the known spelling variants and
finally without the field at all, so the model key alone can carry the target.

Image model aliases:

Expand Down Expand Up @@ -389,6 +432,11 @@ The generated binary is written to `dist/flow` (`dist/flow.exe` on Windows).
record nor local managed file still exists.
- **A retry might have reached Flow** — repeat it with the same idempotency key;
do not create a new key for the same paid request.
- **1080p or 4K came back as 720p** — read the `note` in the response. Flow
rejected or failed the upsample pass; the 720p original is still delivered.
If Flow changed the wire format, override `VIDEO_UPSAMPLER_*_MODEL` or
`VIDEO_UPSAMPLE_ENUM_*` and retry. `python -m flow_server.sniff` captures the
Flow UI's own upsample request for comparison.
- **MCP tools appear but fail** — verify `flow status`, then restart the MCP
client after correcting its command/PATH configuration.

Expand Down
10 changes: 9 additions & 1 deletion flow-agent/flow_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@
# ─── 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,
NATIVE_VIDEO_RESOLUTION, VIDEO_UPSAMPLE_MODELS, CREDITS_PER_UPSAMPLE,
)
from .generators import (
generate_video,
edit_video,
upload_image,
generate_video_i2v,
upsample_video,
poll_status,
download_video,
build_client_context,
Expand All @@ -51,6 +55,7 @@
"edit_video",
"upload_image",
"generate_video_i2v",
"upsample_video",
"poll_status",
"download_video",
"build_client_context",
Expand All @@ -63,6 +68,9 @@
"CLIENT_CTX",
"API_KEY",
"API_BASE",
"NATIVE_VIDEO_RESOLUTION",
"VIDEO_UPSAMPLE_MODELS",
"CREDITS_PER_UPSAMPLE",
# Media store
"media_store",
]
21 changes: 21 additions & 0 deletions flow-agent/flow_engine/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _flow_binary_dir() -> str:
"generate_r2v": "/v1/video:batchAsyncGenerateVideoReferenceImages",
"generate_edit": "/v1/video:batchAsyncGenerateVideoEditVideo",
"upload_image": "/v1/flow/uploadImage",
"upsample_video": "/v1/video:batchAsyncGenerateVideoUpsampleVideo",
"poll_status": "/v1/video:batchCheckAsyncVideoGenerationStatus",
"get_media": "/v1/media/{media_id}",
"get_credits": "/v1/credits",
Expand All @@ -132,6 +133,26 @@ def _flow_binary_dir() -> str:
"edit": "abra_edit",
}

# ─── Video upsampling (native generation is 720p) ─────────────
# Flow generates video at 720p and reaches 1080p/4K through a second
# "upsampler" pass on the finished media, exactly like the Flow UI's
# high-resolution download. Model keys and the resolution enum are part of an
# undocumented API, so both stay overridable without a code change.
NATIVE_VIDEO_RESOLUTION = "720p"

VIDEO_UPSAMPLE_MODELS = {
"1080p": os.environ.get("VIDEO_UPSAMPLER_1080P_MODEL", "veo_3_1_upsampler_1080p"),
"4k": os.environ.get("VIDEO_UPSAMPLER_4K_MODEL", "veo_3_1_upsampler_4k"),
}

VIDEO_UPSAMPLE_RESOLUTIONS = {
"1080p": os.environ.get("VIDEO_UPSAMPLE_ENUM_1080P", "VIDEO_RESOLUTION_1080P"),
"4k": os.environ.get("VIDEO_UPSAMPLE_ENUM_4K", "VIDEO_RESOLUTION_4K"),
}

# 1080p upsampling is free; 4K is a paid, higher-tier operation.
CREDITS_PER_UPSAMPLE = {"1080p": 0, "4k": 50}

DURATIONS = [4, 6, 8, 10]
DEFAULT_DURATION = 10
MAX_COUNT = 4
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 .upsample import upsample_video, normalise_resolution
from .common import poll_status, download_video, build_client_context

__all__ = [
Expand All @@ -12,6 +13,8 @@
"upload_image",
"generate_video_i2v",
"generate_image",
"upsample_video",
"normalise_resolution",
"download_image",
"IMAGE_ASPECTS",
"poll_status",
Expand Down
209 changes: 209 additions & 0 deletions flow-agent/flow_engine/generators/upsample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""Flow Engine — Video upsampler (720p -> 1080p / 4K).

Flow generates video at 720p. The higher-resolution download offered in the
Flow UI is a second pass over the finished media against an upsampler model,
submitted asynchronously and polled with the ordinary video status endpoint.

The request shape below is part of Google's undocumented Flow API. The
resolution enum in particular is not published, so a rejected enum is retried
with the known spelling variants and finally omitted entirely — the upsampler
model key already encodes the target resolution. A rejected request never
starts a generation, so the ladder cannot double-charge.
"""

import logging

from ..config import (
ENDPOINTS,
VIDEO_UPSAMPLE_MODELS,
VIDEO_UPSAMPLE_RESOLUTIONS,
)
from .common import build_client_context, build_generation_context, resolve_seed

log = logging.getLogger("flow_engine.generators.upsample")

# Ordered spelling candidates per tier. The configured value is always tried
# first; ``None`` means "send no resolution field at all".
_RESOLUTION_CANDIDATES = {
"1080p": ("VIDEO_RESOLUTION_1080P", "VIDEO_RESOLUTION_1080p", None),
"4k": ("VIDEO_RESOLUTION_4K", "VIDEO_RESOLUTION_4k", None),
}

# Substrings that identify a request rejected for its shape rather than for a
# real generation failure (bad media ID, no credits, unusual activity...).
_SCHEMA_REJECTION_HINTS = (
"resolution",
"invalid value",
"invalid_argument",
"unknown name",
"cannot find field",
)


def normalise_resolution(resolution) -> str | None:
"""Map a user-facing resolution to an upsample tier key, or None.

Returns None for the native 720p output, which needs no upsample pass.
"""
key = str(resolution or "").strip().lower().replace(" ", "")
if not key or key in {"720p", "720", "native", "source", "original"}:
return None
aliases = {
"1080": "1080p",
"1080p": "1080p",
"fhd": "1080p",
"hd": "1080p",
"full_hd": "1080p",
"fullhd": "1080p",
"4k": "4k",
"2160": "4k",
"2160p": "4k",
"uhd": "4k",
}
tier = aliases.get(key)
if tier is None:
raise ValueError(
f"Unsupported video resolution {resolution!r}; use '720p', '1080p', or '4k'."
)
return tier


def _resolution_candidates(tier: str) -> list[str | None]:
configured = VIDEO_UPSAMPLE_RESOLUTIONS.get(tier)
ordered: list[str | None] = [configured] if configured else []
for candidate in _RESOLUTION_CANDIDATES.get(tier, ()):
if candidate not in ordered:
ordered.append(candidate)
return ordered


def _error_message(result: dict) -> str:
data = result.get("data", {})
reason = ""
message = result.get("error", "Unknown")
if isinstance(data, dict):
error = data.get("error", {})
if isinstance(error, dict):
message = error.get("message", message)
for detail in error.get("details", []) or []:
if isinstance(detail, dict) and "reason" in detail:
reason = f" ({detail['reason']})"
break
elif data:
message = data
return f"{message}{reason}"


def _looks_like_schema_rejection(status: int, message: str) -> bool:
if status not in (400, 404):
return False
lowered = message.lower()
return any(hint in lowered for hint in _SCHEMA_REJECTION_HINTS)


def _parse_media_ids(data: dict) -> list[str]:
"""Collect upsampled media IDs from either response shape."""
if not isinstance(data, dict):
return []

ids: list[str] = []

def _add(value):
if isinstance(value, str) and value and value not in ids:
ids.append(value)

for item in data.get("media", []) or []:
if isinstance(item, dict):
_add(item.get("name") or item.get("mediaId") or item.get("id"))

# Older/legacy responses nest the new media under operations.
for item in data.get("operations", []) or []:
if not isinstance(item, dict):
continue
_add(item.get("name") or item.get("mediaId"))
nested = item.get("media") or item.get("operation")
if isinstance(nested, dict):
_add(nested.get("name") or nested.get("mediaId"))

return ids


async def upsample_video(
bridge,
media_id: str,
aspect: str,
project_id: str,
resolution: str = "1080p",
seed: int = None,
scene_id: str = None,
) -> list[str] | None:
"""Submit an upsample pass for one finished video. Returns new media IDs.

``resolution`` accepts '1080p' or '4k'. Poll the returned IDs with
``flow_engine.generators.common.poll_status`` and download them the same way
as any generated video.
"""
tier = normalise_resolution(resolution)
if tier is None:
raise ValueError(
"Upsampling to 720p is a no-op; Flow already generates at 720p."
)

model_key = VIDEO_UPSAMPLE_MODELS.get(tier)
if not model_key:
raise ValueError(f"No upsampler model configured for {tier}.")

last_error = None
for attempt, resolution_enum in enumerate(_resolution_candidates(tier)):
request_item = {
"aspectRatio": aspect,
"videoModelKey": model_key,
"seed": resolve_seed(seed),
"metadata": {"sceneId": scene_id} if scene_id else {},
"videoInput": {"mediaId": media_id},
}
if resolution_enum is not None:
request_item["resolution"] = resolution_enum

body = {
"mediaGenerationContext": build_generation_context(),
"clientContext": build_client_context(project_id),
"requests": [request_item],
}

log.info(
"Upsampling %s to %s [%s]%s",
media_id[:12],
tier,
model_key,
"" if resolution_enum is None else f" resolution={resolution_enum}",
)
result = await bridge.api_request(
ENDPOINTS["upsample_video"], body, captcha_action="VIDEO_GENERATION"
)

status = result.get("status", 0)
if status == 200:
media_ids = _parse_media_ids(result.get("data", {}))
if not media_ids:
log.error("Upsample accepted but returned no media: %r", result.get("data"))
return None
log.info("Upsample submitted! %s -> %s", media_id[:12], ", ".join(media_ids))
return media_ids

last_error = _error_message(result)
if _looks_like_schema_rejection(status, last_error) and attempt + 1 < len(
_resolution_candidates(tier)
):
log.warning(
"Upsample rejected the request shape (%s): %s — retrying with the "
"next resolution spelling.",
status,
last_error,
)
continue

log.error("Upsample failed (%s): %s", status, last_error)
raise ValueError(last_error)

raise ValueError(last_error or "Upsample request was rejected.")
Loading