diff --git a/README.md b/README.md index 71b8a98..7e25304 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,16 @@ flow video "a dragon flying over mountains" --resolution 1080p flow video "a dragon flying over mountains" --resolution 4k -o /absolute/path/dragon.mp4 ``` +Draft quickly, upscale for quality. `--draft` generates at 360p, the faster, +lower-credit mode Google Flow added with Gemini Omni 1.1 Flash (~half the +credits of the 720p default: 4s=4, 6s=5, 8s=6, 10s=7 vs 7/10/12/15): + +```bash +flow video "a dragon flying over mountains" --draft +``` + +Draft a concept, then regenerate the keeper with `--resolution 1080p` or `--resolution 4k`. + 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. diff --git a/flow-agent/flow_server/models.py b/flow-agent/flow_server/models.py index 9eea3cb..6e9f3f0 100644 --- a/flow-agent/flow_server/models.py +++ b/flow-agent/flow_server/models.py @@ -39,6 +39,7 @@ class VideoGenerationRequest(BaseModel): "the same step behind the Flow UI's high-resolution download." ), ) + draft: Optional[bool] = Field(False, description="Generate a faster, lower-credit 360p draft (about half the credits)") class VideoUpsampleRequest(BaseModel): diff --git a/flow-agent/flow_server/routes/generation.py b/flow-agent/flow_server/routes/generation.py index 3d81da5..30fd500 100644 --- a/flow-agent/flow_server/routes/generation.py +++ b/flow-agent/flow_server/routes/generation.py @@ -46,6 +46,11 @@ def _request_payload(req, operation: str, x_client_id: Optional[str]) -> Dict[st return {"operation": operation, "request": request_data, "client_id": x_client_id} +def _video_model_key(duration: int, draft: bool, video_model: Optional[str] = None) -> str: + """Video model key; _360p suffix requests a fast, lower-credit 360p draft.""" + return video_model or f"abra_t2v_{duration}s" + ("_360p" if draft else "") + + def _header_string(value: Any) -> Optional[str]: """FastAPI Header defaults remain Header objects in direct MCP calls.""" if not isinstance(value, str): @@ -789,6 +794,7 @@ async def _generate_video(req: VideoGenerationRequest, x_client_id: Optional[str try: # Submit generation + video_model = _video_model_key(req.duration, req.draft, req.video_model) if is_video_input and image_media_id: from flow_engine.generators.v2v import edit_video media_ids = await edit_video(active_bridge, req.prompt, aspect_key, project_id, image_media_id, duration=req.duration, ref_media_ids=ref_media_ids or None) @@ -804,17 +810,17 @@ async def _generate_video(req: VideoGenerationRequest, x_client_id: Optional[str duration=req.duration, count=generation_count, seed=req.seed, - video_model=req.video_model, + video_model=video_model, ) elif ref_media_ids: from flow_engine.generators.i2v import generate_video_r2v - media_ids = await generate_video_r2v(active_bridge, req.prompt, aspect_key, project_id, ref_media_ids, duration=req.duration, count=generation_count, seed=req.seed, video_model=req.video_model) + media_ids = await generate_video_r2v(active_bridge, req.prompt, aspect_key, project_id, ref_media_ids, duration=req.duration, count=generation_count, seed=req.seed, video_model=video_model) elif image_media_id: from flow_engine.generators.i2v import generate_video_i2v - media_ids = await generate_video_i2v(active_bridge, req.prompt, aspect_key, project_id, image_media_id, duration=req.duration, count=generation_count, seed=req.seed, video_model=req.video_model) + media_ids = await generate_video_i2v(active_bridge, req.prompt, aspect_key, project_id, image_media_id, duration=req.duration, count=generation_count, seed=req.seed, video_model=video_model) else: from flow_engine.generators.t2v import generate_video - media_ids = await generate_video(active_bridge, req.prompt, aspect_key, project_id, duration=req.duration, count=generation_count, seed=req.seed, video_model=req.video_model) + media_ids = await generate_video(active_bridge, req.prompt, aspect_key, project_id, duration=req.duration, count=generation_count, seed=req.seed, video_model=video_model) except Exception as e: if temp_img_path and os.path.exists(temp_img_path): try: diff --git a/flow-agent/main.py b/flow-agent/main.py index f0d1a7d..514b8a8 100644 --- a/flow-agent/main.py +++ b/flow-agent/main.py @@ -558,6 +558,7 @@ def cmd_video(argv): parser.add_argument("--end", metavar="IMAGE", help="End image path or media ID (use with --start)") parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE", help="Reference image paths or media IDs") parser.add_argument("--project-id", "-p", help="Deprecated; configure DEFAULT_PROJECT on the backend") + parser.add_argument("--draft", action="store_true", help="Generate a faster, lower-credit 360p draft (~half the credits)") parser.add_argument( "--resolution", "-R", @@ -586,6 +587,8 @@ def cmd_video(argv): "duration": args.duration, "n": args.count, } + if args.draft: + payload["draft"] = True if args.resolution != "720p": payload["resolution"] = args.resolution if args.edit: diff --git a/flow-agent/tests/test_video_draft.py b/flow-agent/tests/test_video_draft.py new file mode 100644 index 0000000..5fb930b --- /dev/null +++ b/flow-agent/tests/test_video_draft.py @@ -0,0 +1,57 @@ +"""Draft-at-360p generation: --draft / draft:true request the _360p model key.""" + +import io + +import main +from flow_server.routes.generation import _video_model_key + + +def test_draft_appends_360p_model_key(): + assert _video_model_key(4, True) == "abra_t2v_4s_360p" + assert _video_model_key(6, False) == "abra_t2v_6s" + assert _video_model_key(10, True, "custom_model") == "custom_model" + + +READY = {"status": "healthy", "extension_connected": True, "has_flow_key": True} + + +class DownloadResponse(io.BytesIO): + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + self.close() + + +def test_cli_draft_flag_sends_payload(monkeypatch, tmp_path): + requested = tmp_path / "draft.mp4" + captured = {} + media_bytes = bytes([0, 0, 0, 24]) + b"ftypisomvideo" + monkeypatch.setattr(main, "_wait_for_generation_ready", lambda: READY) + + def fake_post(path, payload, key, timeout): + captured.update(path=path, payload=payload, key=key) + return {"job_id": "job-draft", "status": "processing", "data": []} + + def fake_request(path, **kwargs): + return { + "job_id": "job-draft", + "status": "succeeded", + "data": [{"url": "https://media.invalid/video", "media_id": "video-1"}], + }, 200 + + monkeypatch.setattr(main, "_post_generation", fake_post) + monkeypatch.setattr(main, "_request_json", fake_request) + monkeypatch.setattr(main.time, "sleep", lambda _seconds: None) + monkeypatch.setattr( + main.urllib.request, + "urlopen", + lambda *args, **kwargs: DownloadResponse(media_bytes), + ) + + main.cmd_video(["draft it", "--draft", "--output", str(requested)]) + + assert captured["payload"]["draft"] is True + assert requested.read_bytes() == media_bytes