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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,13 @@ You can also pass `flow_project_id` per project on `POST /api/projects`.

### What does not work on the new API yet

Three capabilities have no captured payload, so they fail with
`UNSUPPORTED_ON_BATCH_API` rather than quietly producing the wrong thing:
1080p export is ported: FlowKit mirrors the Flow UI's `p0UkFb` high-resolution
Download request and polls the resulting media through `as29s`. The remaining
capabilities below still fail loudly rather than quietly producing the wrong thing:

| Capability | Status | Workaround |
|---|---|---|
| 4K/1080p upscale (`/fk-pipeline` last step) | unported | none — keep the 1080p render |
| 4K export | plan-gated / not live-verified | use 1080p; Google Flow exposes Full HD as the standard high-resolution export |
| Reference-to-video (r2v) | unported | `FLOW_ALLOW_DEGRADED=1` → i2v off the first reference |
| Start+end-frame chaining (`/fk-gen-chain-videos`) | unported | `FLOW_ALLOW_DEGRADED=1` → i2v off the start frame |
| Omni Flash (`model_family=omni_flash`) | unported | use `model_family=veo` |
Expand Down
1 change: 1 addition & 0 deletions agent/api/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class UpscaleVideoRequest(BaseModel):
scene_id: str
aspect_ratio: str = "VIDEO_ASPECT_RATIO_PORTRAIT"
resolution: str = "VIDEO_RESOLUTION_4K"
project_id: Optional[str] = None


class UploadImageRequest(BaseModel):
Expand Down
87 changes: 87 additions & 0 deletions agent/api/upscale_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Explicit Full HD / 4K export endpoints for Google Flow videos."""

from typing import Literal

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from agent.services.flow_client import get_flow_client
from agent.services.upscale_polling import annotate_upscale_polling, check_upscale_status

router = APIRouter(prefix="/flow", tags=["flow"])


class ExportVideoRequest(BaseModel):
media_id: str
scene_id: str = "export"
quality: Literal["1080p", "4k"] = "1080p"
aspect_ratio: str = "VIDEO_ASPECT_RATIO_LANDSCAPE"
project_id: str | None = None


class CheckExportStatusRequest(BaseModel):
workflows: list[dict]


@router.post("/export-video")
async def export_video(body: ExportVideoRequest):
"""Start Google's native Full HD/4K export.

This is the same Flow upsample operation exposed by the UI, presented as an
export/download-quality choice. 1080p is the default because Omni Flash's
generated file is normally 720p and Full HD is the expected downloadable
master.
"""
client = get_flow_client()
if not client.connected:
raise HTTPException(503, "Extension not connected")

resolution = (
"VIDEO_RESOLUTION_1080P"
if body.quality == "1080p"
else "VIDEO_RESOLUTION_4K"
)
result = await client.upscale_video(
media_id=body.media_id,
scene_id=body.scene_id,
aspect_ratio=body.aspect_ratio,
resolution=resolution,
project_id=body.project_id,
)
if result.get("error") or (
isinstance(result.get("status"), int) and result["status"] >= 400
):
raise HTTPException(
result.get("status", 502),
result.get("error", result.get("data")),
)

annotated = annotate_upscale_polling(result)
data = annotated.get("data", annotated)
if isinstance(data, dict):
data["export"] = {
"quality": body.quality,
"resolution": resolution,
"native_flow_export": True,
"next": "/api/flow/check-export-status",
}
return data


@router.post("/check-export-status")
@router.post("/check-upscale-status", include_in_schema=False)
async def check_export_status(body: CheckExportStatusRequest):
"""Return a signed downloadable URL when the native Flow export is ready."""
client = get_flow_client()
if not client.connected:
raise HTTPException(503, "Extension not connected")
try:
result = await check_upscale_status(body.workflows)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc

if result.get("status") == "COMPLETED":
result["download_ready"] = True
else:
result["download_ready"] = False
return result
2 changes: 2 additions & 0 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from agent.api.scenes import router as scenes_router
from agent.api.requests import router as requests_router
from agent.api.flow import router as flow_router
from agent.api.upscale_status import router as upscale_status_router
from agent.api.reviews import router as reviews_router
from agent.api.tts import router as tts_router
from agent.api.materials import router as materials_router
Expand Down Expand Up @@ -128,6 +129,7 @@ async def lifespan(app: FastAPI):
app.include_router(scenes_router, prefix="/api")
app.include_router(requests_router, prefix="/api")
app.include_router(flow_router, prefix="/api")
app.include_router(upscale_status_router, prefix="/api")
app.include_router(reviews_router, prefix="/api")
app.include_router(tts_router, prefix="/api")
app.include_router(materials_router, prefix="/api")
Expand Down
26 changes: 26 additions & 0 deletions agent/services/flow_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
RPC_PROJECT_MEDIA = "Zzl0ze"
RPC_MEDIA = "as29s"
RPC_UPLOAD_IMAGE = "maseQ"
RPC_UPSCALE = "p0UkFb"

CAPTCHA_IMAGE = "IMAGE_GENERATION"
CAPTCHA_VIDEO = "VIDEO_GENERATION"
Expand Down Expand Up @@ -357,6 +358,23 @@ def video_request(prompt: str, project_id: str, source_media_id: str,
return build_envelope(RPC_GEN_VIDEO, inner)


def upscale_request(media_id: str, project_id: str,
aspect: Any = VIDEO_ASPECT_LANDSCAPE,
model: str = "veo_3_1_upsampler_1080p") -> str:
"""Build Flow's migrated high-resolution download request (RPC p0UkFb)."""
item = [None] * 32
item[0] = [None, media_id]
item[2] = 1
item[4] = [None, str(uuid.uuid4()), None, None, _client_uuid()]
item[6] = resolve_video_aspect(aspect)
item[31] = model
return build_envelope(RPC_UPSCALE, [
[item],
_context(project_id),
[_client_uuid()],
])


def upload_request(image_b64: str, project_id: str, mime_type: str = "image/jpeg",
file_name: str = "upload.jpg") -> str:
"""Put a local image into the project so it can be used as a reference.
Expand Down Expand Up @@ -429,6 +447,14 @@ def read_uploaded_media_id(payload: Any) -> str:
return media_id


def read_upscaled_media_id(payload: Any) -> str:
"""Return the media id created by the p0UkFb upscale submit."""
for text in _walk_strings(payload):
if isinstance(text, str) and text.endswith("_upsampled"):
return text
raise FlowBatchError("upscale response carried no upsampled media id")


def read_operation(payload: Any) -> Operation:
"""`[null, 50, [[opId, projectId, sceneId, status, …]]]`.

Expand Down
31 changes: 25 additions & 6 deletions agent/services/flow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,14 +694,33 @@ async def generate_video_from_references(self, reference_media_ids: list[str],

async def upscale_video(self, media_id: str, scene_id: str,
aspect_ratio: str = "VIDEO_ASPECT_RATIO_PORTRAIT",
resolution: str = "VIDEO_RESOLUTION_4K") -> dict:
"""Upscale a video."""
resolution: str = "VIDEO_RESOLUTION_4K",
project_id: str | None = None) -> dict:
"""Upscale/export a video using Flow's migrated p0UkFb RPC."""
if not USE_BATCH_RPC:
return await self._legacy_upscale_video(media_id, scene_id, aspect_ratio, resolution)
return {"error": _unsupported(
"video upscale",
"no upsampler rpc appears in the new frontend's captures",
)}

model = UPSCALE_MODELS.get(resolution)
if not model:
return {"status": 400, "error": f"Unsupported upscale resolution: {resolution}"}
try:
pid = self._batch_project_id(project_id or "")
freq = fb.upscale_request(media_id, pid, aspect=aspect_ratio, model=model)
payload = await self._batch_payload(
fb.RPC_UPSCALE, freq, fb.CAPTCHA_VIDEO, timeout=120)
upscaled_media_id = fb.read_upscaled_media_id(payload)
except Exception as e:
return _batch_error(e)

workflow = {
"name": upscaled_media_id,
"primary_media_id": upscaled_media_id,
"project_id": pid,
}
return {"status": 200, "data": {
"media": [{"name": upscaled_media_id}],
"workflows": [workflow],
}}

async def check_video_status(self, operations: list[dict]) -> dict:
"""One poll round for each submitted operation.
Expand Down
Loading