From eda0e11df9c2bd1a66cb6affbf06473c1dabae89 Mon Sep 17 00:00:00 2001 From: santoshray02 Date: Mon, 27 Jul 2026 18:01:05 +0530 Subject: [PATCH 1/2] Restore repository gitignore Commit 61014b8 removed the gitignore, but the README still documents that outputs, logs, build artifacts and local credentials are ignored. Without it, `git add -A` sweeps in generated media (16 files / ~15 MB in output/), __pycache__, build/ and egg-info, and a populated SERVER_API_KEY in a local config.env would be one command away from being committed. --- .gitignore | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3218b61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ + +# Generated and uploaded media +output/ +input/ +*.mp4 +*.png +*.jpg +*.jpeg +!flow-extension/icon*.png +media-id.js + +# Secrets and local credentials +config.env +github-token +cloudflared/ +*.pem + +# Logs +*.log +logs/ + +# Chrome extension build artifacts +_metadata/ + +# OS +.DS_Store +Thumbs.db From 5bfaac4227dbc63957ed42dbd09d7a8388aa0f54 Mon Sep 17 00:00:00 2001 From: santoshray02 Date: Mon, 27 Jul 2026 18:01:19 +0530 Subject: [PATCH 2/2] Expose seed, first-last frame and model selection; add sequence tooling The Flow API already accepts these fields, but the wrapper discarded them. Generation parameters: - seed: was hardcoded to random.randint(1, 9999) in every generator and unreachable from the HTTP API, CLI or MCP. Now plumbed through t2v, i2v, fl, r2v, v2v and t2i, accepting the full uint32 range. Multi-item batches offset from the base seed so a pinned value still yields distinct takes. Omitting it preserves the previous random behaviour exactly. - end_media_id / end_image_base64: generate_video_fl and the batchAsyncGenerateVideoStartAndEndImage endpoint already existed, but no caller could reach them. Supplying an end frame alongside a start frame now routes to first-last mode. - video_model: overrides the videoModelKey, which was hardcoded to abra_t2v_s. Measured: an explicit seed is accepted by the server (including values well above the old 9999 cap) but does NOT make generation deterministic -- identical prompt+seed pairs differ (image SSIM 0.36, video SSIM 0.77). The parameter is plumbed correctly; the backend simply does not honour it. Shot-to-shot continuity therefore comes from pinned frames, not seeds. New MCP tools (9 -> 12), all requiring FFmpeg and failing with a clear message when it is absent: - generate_flow_sequence: generates an ordered shot list, carrying each clip's final frame into the next shot as its start image. Measured seam continuity SSIM 0.967. Stops at the first failure and reports the index rather than burning credits on a derailed run. - extract_video_frame: first / last / indexed frame to PNG. - concat_flow_videos: ordered concat with optional single narration track, re-encoding so clips with differing SAR or timebase join cleanly. Also fixes a latent NameError: uuid was used but never imported in flow_mcp_server.py. --- flow-agent/cli/api.py | 59 +++- flow-agent/flow_mcp_server.py | 368 +++++++++++++++++++++- flow-agent/omniflash/generators/common.py | 14 + flow-agent/omniflash/generators/i2v.py | 27 +- flow-agent/omniflash/generators/t2i.py | 5 +- flow-agent/omniflash/generators/t2v.py | 11 +- flow-agent/omniflash/generators/v2v.py | 7 +- 7 files changed, 460 insertions(+), 31 deletions(-) diff --git a/flow-agent/cli/api.py b/flow-agent/cli/api.py index 2cb6bed..f15eef1 100644 --- a/flow-agent/cli/api.py +++ b/flow-agent/cli/api.py @@ -196,6 +196,7 @@ class ImageGenerationRequest(BaseModel): user: Optional[str] = None image_base64: Optional[str] = Field(None, description="Optional base64 reference image for image-to-image") ref_media_ids: Optional[List[str]] = Field(None, description="Optional reference image media IDs (up to 10)") + seed: Optional[int] = Field(None, ge=0, le=4294967295, description="Explicit seed for reproducible generation; timestamp-derived when omitted") class VideoGenerationRequest(BaseModel): @@ -207,6 +208,10 @@ class VideoGenerationRequest(BaseModel): ref_media_ids: Optional[List[str]] = Field(None, description="Optional reference image media IDs (up to 10)") start_media_id: Optional[str] = Field(None, description="Optional pre-uploaded start image or video media ID") is_video: Optional[bool] = Field(False, description="True if the pre-uploaded reference is a video") + seed: Optional[int] = Field(None, ge=0, le=4294967295, description="Explicit seed for reproducible generation; random when omitted") + end_media_id: Optional[str] = Field(None, description="Optional pre-uploaded end-frame media ID; enables first-last frame mode") + end_image_base64: Optional[str] = Field(None, description="Optional base64 end frame; uploaded automatically for first-last frame mode") + video_model: Optional[str] = Field(None, description="Override the Flow videoModelKey (defaults to abra_t2v_s)") # Extension WebSocket and Callback Endpoints @@ -291,6 +296,7 @@ async def openai_generate_image(req: ImageGenerationRequest, x_client_id: Option try: tasks = [] + seed_offset = 0 for chunk_size in chunks: tasks.append( generate_image( @@ -300,9 +306,13 @@ async def openai_generate_image(req: ImageGenerationRequest, x_client_id: Option project_id=project_id, count=chunk_size, ref_media_ids=ref_media_ids, - model=req.model + model=req.model, + # Offset per chunk so a pinned seed still yields n distinct + # images instead of repeating the first four. + seed=None if req.seed is None else req.seed + seed_offset, ) ) + seed_offset += chunk_size # Run requests concurrently using asyncio.gather results_lists = await asyncio.gather(*tasks, return_exceptions=True) @@ -467,20 +477,53 @@ async def openai_generate_video(req: VideoGenerationRequest, x_client_id: Option os.remove(temp_img_path) raise HTTPException(status_code=500, detail=f"Asset upload error: {str(e)}") + # Optional end frame. Uploading it here means the dispatch below can pick + # first-last mode purely on whether we ended up with an end media ID. + end_media_id = req.end_media_id + temp_end_path = None + if req.end_image_base64 and not end_media_id: + b64_end = req.end_image_base64 + if "," in b64_end: + b64_end = b64_end.split(",")[1] + temp_end_path = os.path.join( + OUTPUT_DIR, f"fl_end_{int(time.time())}_{uuid.uuid4().hex[:6]}.png" + ) + try: + with open(temp_end_path, "wb") as f: + f.write(base64.b64decode(b64_end)) + from omniflash.generators.i2v import upload_image + end_media_id = await upload_image(active_bridge, temp_end_path, project_id) + if not end_media_id: + raise HTTPException(status_code=500, detail="Failed to upload end image to Google Flow.") + except HTTPException: + raise + except Exception as e: + log.exception("Error uploading end frame") + raise HTTPException(status_code=500, detail=f"End frame upload error: {str(e)}") + + if end_media_id and not image_media_id: + raise HTTPException( + status_code=400, + detail="First-last frame mode needs a start image too: pass start_media_id or image_base64 alongside the end frame.", + ) + try: # Submit generation if is_video_input and image_media_id: from omniflash.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=req.ref_media_ids) + media_ids = await edit_video(active_bridge, req.prompt, aspect_key, project_id, image_media_id, duration=req.duration, ref_media_ids=req.ref_media_ids, seed=req.seed) + elif end_media_id and image_media_id: + from omniflash.generators.i2v import generate_video_fl + media_ids = await generate_video_fl(active_bridge, req.prompt, aspect_key, project_id, image_media_id, end_media_id, duration=req.duration, seed=req.seed, video_model=req.video_model) elif req.ref_media_ids: from omniflash.generators.i2v import generate_video_r2v - media_ids = await generate_video_r2v(active_bridge, req.prompt, aspect_key, project_id, req.ref_media_ids, duration=req.duration, count=req.n) + media_ids = await generate_video_r2v(active_bridge, req.prompt, aspect_key, project_id, req.ref_media_ids, duration=req.duration, count=req.n, seed=req.seed, video_model=req.video_model) elif image_media_id: from omniflash.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=req.n) + media_ids = await generate_video_i2v(active_bridge, req.prompt, aspect_key, project_id, image_media_id, duration=req.duration, count=req.n, seed=req.seed, video_model=req.video_model) else: from omniflash.generators.t2v import generate_video - media_ids = await generate_video(active_bridge, req.prompt, aspect_key, project_id, duration=req.duration, count=req.n) + media_ids = await generate_video(active_bridge, req.prompt, aspect_key, project_id, duration=req.duration, count=req.n, seed=req.seed, video_model=req.video_model) except Exception as e: if temp_img_path and os.path.exists(temp_img_path): try: @@ -488,6 +531,12 @@ async def openai_generate_video(req: VideoGenerationRequest, x_client_id: Option except Exception: pass raise HTTPException(status_code=400, detail=str(e)) + finally: + if temp_end_path and os.path.exists(temp_end_path): + try: + os.remove(temp_end_path) + except Exception: + pass # Clean up temp upload image immediately since it's uploaded to Google Flow if temp_img_path and os.path.exists(temp_img_path): diff --git a/flow-agent/flow_mcp_server.py b/flow-agent/flow_mcp_server.py index 2c2c0db..f72cfd9 100755 --- a/flow-agent/flow_mcp_server.py +++ b/flow-agent/flow_mcp_server.py @@ -8,6 +8,7 @@ import mimetypes import tempfile import shutil +import uuid from urllib.parse import urlparse, unquote # Load .env so MCP sees the same user settings as the backend. Legacy @@ -241,6 +242,12 @@ def handle_tools_list(request_id): "type": "string", "description": "Image model to use (harbor_seal/lite, narwhal/standard, gem_pix_2/pro)", "default": "gem_pix_2" + }, + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "Explicit seed. Reuse it to reproduce a look; omit for a fresh random image. Multi-image requests offset from this value." } }, "required": ["prompt"] @@ -248,7 +255,7 @@ def handle_tools_list(request_id): }, { "name": "generate_flow_video", - "description": "Generate 1-20 Flow videos with duration, aspect, start asset, and reference-media control.", + "description": "Generate 1-20 Flow videos with duration, aspect, start asset, seed, first-last frame, and reference-media control.", "inputSchema": { "type": "object", "properties": { @@ -274,11 +281,96 @@ def handle_tools_list(request_id): "items": {"type": "string"}, "maxItems": 10, "description": "Optional Flow reference-media IDs for reference-to-video" + }, + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "Explicit seed. Reuse it to re-roll a shot while holding its look; omit for a fresh random take. Multi-take requests offset from this value." + }, + "end_image_path": { + "type": "string", + "description": "Optional local end-frame image. With a start image this switches to first-last frame mode: the clip morphs from start to end." + }, + "end_media_id": { + "type": "string", + "description": "Optional pre-uploaded end-frame media ID; same first-last frame mode as end_image_path" + }, + "video_model": { + "type": "string", + "description": "Override the Flow videoModelKey (defaults to abra_t2v_s)" } }, "required": ["prompt"] } }, + { + "name": "generate_flow_sequence", + "description": "Generate a continuity-chained run of shots: each shot starts on the previous shot's final frame, so cuts land on matching pixels. Returns clip paths in order. Requires FFmpeg.", + "inputSchema": { + "type": "object", + "properties": { + "shots": { + "type": "array", + "minItems": 1, + "maxItems": 60, + "description": "Ordered shots. Each item is either a prompt string or {prompt, duration, end_image_path}.", + "items": { + "anyOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "prompt": {"type": "string"}, + "duration": {"type": "integer", "enum": [4, 6, 8, 10]}, + "end_image_path": {"type": "string"} + }, + "required": ["prompt"] + } + ] + } + }, + "aspect": {"type": "string", "enum": ["landscape", "portrait"], "default": "landscape"}, + "duration": {"type": "integer", "enum": [4, 6, 8, 10], "default": 8, "description": "Default duration for shots that don't set their own"}, + "start_image_path": {"type": "string", "description": "Optional opening frame for shot 1; later shots chain automatically"}, + "output_dir": {"type": "string", "description": "Where clips are written; defaults to ~/Downloads/Flow-Agent"}, + "seed": {"type": "integer", "minimum": 0, "maximum": 4294967295, "description": "Base seed; each shot offsets from it"}, + "video_model": {"type": "string", "description": "Override the Flow videoModelKey"}, + "ref_media_ids": { + "type": "array", "items": {"type": "string"}, "maxItems": 10, + "description": "Reference media applied to every shot, for style or character carry-through" + } + }, + "required": ["shots"] + } + }, + { + "name": "extract_video_frame", + "description": "Extract one frame from a local video as a PNG. Use the last frame of a clip as the start image of the next to chain shots manually. Requires FFmpeg.", + "inputSchema": { + "type": "object", + "properties": { + "video_path": {"type": "string", "description": "Local video file"}, + "position": {"type": "string", "default": "last", "description": "'first', 'last', or a frame index like '48'"}, + "output_path": {"type": "string", "description": "Optional PNG destination"} + }, + "required": ["video_path"] + } + }, + { + "name": "concat_flow_videos", + "description": "Concatenate local clips in order into one MP4, optionally replacing audio with a single narration track. Requires FFmpeg.", + "inputSchema": { + "type": "object", + "properties": { + "video_paths": {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Clips in playback order"}, + "output_path": {"type": "string", "description": "Destination MP4"}, + "audio_path": {"type": "string", "description": "Optional audio track replacing per-clip audio (e.g. one narration voice)"}, + "mute_source": {"type": "boolean", "default": False, "description": "Drop source audio when no audio_path is given"} + }, + "required": ["video_paths", "output_path"] + } + }, { "name": "upload_flow_media", "description": "Upload a local image or video to Google Flow and return its media ID.", @@ -393,7 +485,8 @@ def call_upload_flow_media(file_path): return {"error": f"Upload failed: {str(e)}"} def call_generate_flow_image(prompt, size="1280x720", count=1, ref_image_path=None, - ref_image_paths=None, ref_media_ids=None, model=None): + ref_image_paths=None, ref_media_ids=None, model=None, + seed=None): if not prompt or not str(prompt).strip(): return "Error: 'prompt' is required and cannot be empty.", None prompt = str(prompt).strip() @@ -407,6 +500,8 @@ def call_generate_flow_image(prompt, size="1280x720", count=1, ref_image_path=No "response_format": "b64_json" } payload["model"] = _normalise_model(model) + if seed is not None: + payload["seed"] = int(seed) media_ids = list(ref_media_ids or []) local_refs = list(ref_image_paths or []) @@ -465,7 +560,8 @@ def call_generate_flow_image(prompt, size="1280x720", count=1, ref_image_path=No return f"Failed to communicate with Flow Agent server: {str(e)}", [] def call_generate_flow_video(prompt, aspect="landscape", start_image_path=None, duration=8, - count=1, start_media_id=None, ref_media_ids=None, is_video=False): + count=1, start_media_id=None, ref_media_ids=None, is_video=False, + seed=None, end_image_path=None, end_media_id=None, video_model=None): if not prompt or not str(prompt).strip(): return "Error: 'prompt' is required and cannot be empty." prompt = str(prompt).strip() @@ -481,6 +577,13 @@ def call_generate_flow_video(prompt, aspect="landscape", start_image_path=None, if ref_media_ids: payload["ref_media_ids"] = list(ref_media_ids)[:10] + if seed is not None: + payload["seed"] = int(seed) + if video_model: + payload["video_model"] = video_model + if end_media_id: + payload["end_media_id"] = end_media_id + if start_image_path: if not os.path.exists(start_image_path): return f"Error: Starting image path does not exist: {start_image_path}" @@ -489,6 +592,14 @@ def call_generate_flow_video(prompt, aspect="landscape", start_image_path=None, except Exception as e: return f"Error reading starting image: {str(e)}" + if end_image_path: + if not os.path.exists(end_image_path): + return f"Error: End image path does not exist: {end_image_path}" + try: + payload["end_image_base64"] = _file_data_uri(end_image_path) + except Exception as e: + return f"Error reading end image: {str(e)}" + try: log_debug(f"Sending video generation request for prompt: {prompt}") url = f"{FLOW_API_URL}/v1/videos/generations" @@ -603,6 +714,222 @@ def call_download_media_from_url(url, output_dir=None, filename=None, except OSError: pass +# ─── Local media tooling ───────────────────────────────────── +# Shot-to-shot continuity is built by carrying the last frame of one clip into +# the next as its start image, so these helpers need frame-accurate local +# access to the generated files. FFmpeg is optional: only the tools below +# require it, and they fail with a clear message rather than at import time. + +def _require_ffmpeg(): + missing = [n for n in ("ffmpeg", "ffprobe") if not shutil.which(n)] + if missing: + return ("Error: %s not found on PATH. Install FFmpeg to use this tool." + % " and ".join(missing)) + return None + + +def _media_dir(output_dir=None): + target = output_dir or os.path.join(os.path.expanduser("~"), "Downloads", "Flow-Agent") + os.makedirs(target, exist_ok=True) + return target + + +def _run(cmd, timeout=180): + import subprocess + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + return proc.returncode, proc.stdout.decode("utf-8", "replace"), proc.stderr.decode("utf-8", "replace") + + +def call_extract_video_frame(video_path, position="last", output_path=None): + """Pull one frame out of a local video. 'position' is first, last, or a frame index.""" + err = _require_ffmpeg() + if err: + return {"error": err} + if not video_path or not os.path.exists(video_path): + return {"error": f"Video not found: {video_path}"} + + if not output_path: + base = os.path.splitext(os.path.basename(video_path))[0] + output_path = os.path.join(os.path.dirname(video_path), f"{base}_{position}.png") + + pos = str(position).lower() + if pos == "last": + # Seeking from the end is far cheaper than decoding the whole file, and + # -update 1 keeps overwriting so the final decoded frame is what lands. + cmd = ["ffmpeg", "-y", "-v", "error", "-sseof", "-0.2", "-i", video_path, + "-update", "1", output_path] + elif pos == "first": + cmd = ["ffmpeg", "-y", "-v", "error", "-i", video_path, + "-vf", "select=eq(n\\,0)", "-frames:v", "1", output_path] + else: + try: + idx = int(position) + except (TypeError, ValueError): + return {"error": "position must be 'first', 'last', or a frame index"} + cmd = ["ffmpeg", "-y", "-v", "error", "-i", video_path, + "-vf", f"select=eq(n\\,{idx})", "-frames:v", "1", output_path] + + try: + code, _, stderr = _run(cmd) + except Exception as e: + return {"error": f"Frame extraction failed: {e}"} + if code != 0 or not os.path.exists(output_path): + return {"error": f"Frame extraction failed: {stderr.strip()[:400]}"} + return {"frame_path": output_path} + + +def _post_video_json(payload, timeout=900): + """Submit one video generation and return (items, error).""" + try: + req = urllib.request.Request( + f"{FLOW_API_URL}/v1/videos/generations", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as response: + if response.status != 200: + return None, f"HTTP {response.status}" + return json.loads(response.read().decode("utf-8")).get("data", []), None + except urllib.error.HTTPError as e: + try: + detail = e.read().decode("utf-8") + except Exception: + detail = str(e) + return None, f"HTTP {e.code}: {detail[:400]}" + except Exception as e: + return None, str(e) + + +def call_generate_flow_sequence(shots, aspect="landscape", duration=8, output_dir=None, + start_image_path=None, seed=None, video_model=None, + ref_media_ids=None): + """Generate a continuity-chained run of shots. + + Each shot after the first starts on the previous shot's final frame, so cuts + land on matching pixels instead of relying on the model to re-imagine the + scene. Returns the clip paths in order, ready to concatenate. + """ + err = _require_ffmpeg() + if err: + return {"error": err} + if not shots or not isinstance(shots, list): + return {"error": "'shots' must be a non-empty list of prompts or shot objects"} + + target_dir = _media_dir(output_dir) + carry_frame = start_image_path + if carry_frame and not os.path.exists(carry_frame): + return {"error": f"start_image_path does not exist: {carry_frame}"} + + clips, failures = [], [] + for i, shot in enumerate(shots): + if isinstance(shot, dict): + prompt = shot.get("prompt") + shot_duration = int(shot.get("duration") or duration) + end_path = shot.get("end_image_path") + else: + prompt, shot_duration, end_path = str(shot), int(duration), None + if not prompt or not str(prompt).strip(): + failures.append({"index": i, "error": "empty prompt"}) + break + + payload = {"prompt": str(prompt).strip(), "aspect": aspect, + "n": 1, "duration": shot_duration} + if seed is not None: + payload["seed"] = int(seed) + i + if video_model: + payload["video_model"] = video_model + if ref_media_ids: + payload["ref_media_ids"] = list(ref_media_ids)[:10] + if carry_frame: + try: + payload["image_base64"] = _file_data_uri(carry_frame) + except Exception as e: + failures.append({"index": i, "error": f"could not read carry frame: {e}"}) + break + if end_path: + if not os.path.exists(end_path): + failures.append({"index": i, "error": f"end_image_path missing: {end_path}"}) + break + payload["end_image_base64"] = _file_data_uri(end_path) + + items, gen_err = _post_video_json(payload) + if gen_err or not items: + failures.append({"index": i, "error": gen_err or "no media returned"}) + break + + url = items[0].get("url") + body = _download_bytes(url, timeout=180) if url else None + if not body: + failures.append({"index": i, "error": f"download failed for {url}"}) + break + clip_path = os.path.join(target_dir, f"seq_{i + 1:02d}.mp4") + with open(clip_path, "wb") as f: + f.write(body) + clips.append({"index": i, "path": clip_path, + "media_id": items[0].get("media_id"), "prompt": prompt}) + + # Carry this clip's final frame into the next shot. + if i < len(shots) - 1: + extracted = call_extract_video_frame(clip_path, "last", + os.path.join(target_dir, f"seq_{i + 1:02d}_last.png")) + if extracted.get("error"): + failures.append({"index": i, "error": extracted["error"]}) + break + carry_frame = extracted["frame_path"] + + return {"clips": clips, "generated": len(clips), "requested": len(shots), + "failures": failures, "output_dir": target_dir} + + +def call_concat_flow_videos(video_paths, output_path, audio_path=None, mute_source=False): + """Concatenate clips in order, optionally replacing audio with one track.""" + err = _require_ffmpeg() + if err: + return {"error": err} + if not video_paths or not isinstance(video_paths, list) or len(video_paths) < 1: + return {"error": "'video_paths' must be a non-empty list"} + missing = [p for p in video_paths if not os.path.exists(p)] + if missing: + return {"error": f"Missing input files: {missing[:5]}"} + if not output_path: + return {"error": "'output_path' is required"} + + os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True) + list_file = os.path.join(_media_dir(), f"concat_{uuid.uuid4().hex[:8]}.txt") + try: + with open(list_file, "w", encoding="utf-8") as f: + for p in video_paths: + f.write("file '%s'\n" % os.path.abspath(p).replace("\\", "/").replace("'", "'\\''")) + + # Re-encode rather than stream-copy: clips can differ in SAR/timebase and + # a copy-concat would silently desync or refuse. + cmd = ["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", list_file] + if audio_path: + if not os.path.exists(audio_path): + return {"error": f"audio_path does not exist: {audio_path}"} + cmd += ["-i", audio_path, "-map", "0:v:0", "-map", "1:a:0", "-shortest"] + elif mute_source: + cmd += ["-an"] + cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium"] + if audio_path: + cmd += ["-c:a", "aac", "-b:a", "192k"] + cmd += [output_path] + + code, _, stderr = _run(cmd, timeout=1800) + if code != 0 or not os.path.exists(output_path): + return {"error": f"Concat failed: {stderr.strip()[:400]}"} + size_mb = round(os.path.getsize(output_path) / (1024 * 1024), 2) + return {"output_path": output_path, "clips": len(video_paths), "size_mb": size_mb} + except Exception as e: + return {"error": f"Concat failed: {e}"} + finally: + try: + os.remove(list_file) + except Exception: + pass + + def handle_tool_call(request_id, tool_name, arguments): log_debug(f"Calling tool: {tool_name} with args: {arguments}") @@ -624,7 +951,8 @@ def handle_tool_call(request_id, tool_name, arguments): ref_media_ids = arguments.get("ref_media_ids") model = arguments.get("model") text, images_b64 = call_generate_flow_image( - prompt, size, count, ref_image_path, ref_image_paths, ref_media_ids, model + prompt, size, count, ref_image_path, ref_image_paths, ref_media_ids, model, + arguments.get("seed"), ) content = [{"type": "text", "text": text}] for image_data_b64 in images_b64: @@ -645,8 +973,40 @@ def handle_tool_call(request_id, tool_name, arguments): arguments.get("count", 1), arguments.get("start_media_id"), arguments.get("ref_media_ids"), + arguments.get("is_video", False), + arguments.get("seed"), + arguments.get("end_image_path"), + arguments.get("end_media_id"), + arguments.get("video_model"), ) content = [{"type": "text", "text": text}] + elif tool_name == "generate_flow_sequence": + result = call_generate_flow_sequence( + arguments.get("shots"), + arguments.get("aspect", "landscape"), + arguments.get("duration", 8), + arguments.get("output_dir"), + arguments.get("start_image_path"), + arguments.get("seed"), + arguments.get("video_model"), + arguments.get("ref_media_ids"), + ) + content = [{"type": "text", "text": json.dumps(result, indent=2)}] + elif tool_name == "extract_video_frame": + result = call_extract_video_frame( + arguments.get("video_path"), + arguments.get("position", "last"), + arguments.get("output_path"), + ) + content = [{"type": "text", "text": json.dumps(result, indent=2)}] + elif tool_name == "concat_flow_videos": + result = call_concat_flow_videos( + arguments.get("video_paths"), + arguments.get("output_path"), + arguments.get("audio_path"), + arguments.get("mute_source", False), + ) + content = [{"type": "text", "text": json.dumps(result, indent=2)}] elif tool_name == "upload_flow_media": result = call_upload_flow_media(arguments.get("file_path")) content = [{"type": "text", "text": json.dumps(result, indent=2)}] diff --git a/flow-agent/omniflash/generators/common.py b/flow-agent/omniflash/generators/common.py index 63cd332..2c22b55 100644 --- a/flow-agent/omniflash/generators/common.py +++ b/flow-agent/omniflash/generators/common.py @@ -18,6 +18,20 @@ log = logging.getLogger("omniflash.generators") +def resolve_seed(seed: int | None, index: int = 0) -> int: + """Resolve the seed for one request item. + + An explicit seed makes a generation reproducible, which is what lets a shot + be re-rolled while holding its look. Items in a multi-variation batch are + offset by their index so the takes still differ from each other but stay + reproducible as a set. None keeps the original behaviour: a fresh random + seed per request. + """ + if seed is None: + return random.randint(1, 9999) + return (int(seed) + index) % 4294967296 + + def build_client_context(project_id: str) -> dict: """Build the clientContext dict used by all API requests.""" return { diff --git a/flow-agent/omniflash/generators/i2v.py b/flow-agent/omniflash/generators/i2v.py index 4ab622b..9284e07 100644 --- a/flow-agent/omniflash/generators/i2v.py +++ b/flow-agent/omniflash/generators/i2v.py @@ -7,7 +7,7 @@ from ..config import CLIENT_CTX, ENDPOINTS from .. import media_store -from .common import build_client_context, build_generation_context +from .common import build_client_context, build_generation_context, resolve_seed log = logging.getLogger("omniflash.generators.i2v") @@ -51,17 +51,18 @@ async def upload_image(bridge, image_path: str, project_id: str = None) -> str | async def generate_video_i2v(bridge, prompt: str, aspect: str, project_id: str, - image_media_id: str, duration: int = 8, count: int = 1) -> list[str] | None: + image_media_id: str, duration: int = 8, count: int = 1, + seed: int = None, video_model: str = None) -> list[str] | None: """Generate video from a start image. Returns list of media_ids.""" - model_key = f"abra_t2v_{duration}s" + model_key = video_model or f"abra_t2v_{duration}s" requests = [] - for _ in range(count): + for i in range(count): requests.append({ "aspectRatio": aspect, "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, "videoModelKey": model_key, - "seed": random.randint(1, 9999), + "seed": resolve_seed(seed, i), "metadata": {}, "startImage": {"mediaId": image_media_id}, }) @@ -104,18 +105,19 @@ async def generate_video_i2v(bridge, prompt: str, aspect: str, project_id: str, async def generate_video_fl(bridge, prompt: str, aspect: str, project_id: str, start_image_id: str, end_image_id: str, - duration: int = 8) -> list[str] | None: + duration: int = 8, seed: int = None, + video_model: str = None) -> list[str] | None: """Generate video with First+Last frame control. Video transitions smoothly from start_image to end_image. """ - model_key = f"abra_t2v_{duration}s" + model_key = video_model or f"abra_t2v_{duration}s" request = { "aspectRatio": aspect, "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, "videoModelKey": model_key, - "seed": random.randint(1, 9999), + "seed": resolve_seed(seed), "metadata": {}, "startImage": {"mediaId": start_image_id}, "endImage": {"mediaId": end_image_id}, @@ -160,9 +162,10 @@ async def generate_video_fl(bridge, prompt: str, aspect: str, project_id: str, async def generate_video_r2v(bridge, prompt: str, aspect: str, project_id: str, ref_media_ids: list[str], - duration: int = 8, count: int = 1) -> list[str] | None: + duration: int = 8, count: int = 1, + seed: int = None, video_model: str = None) -> list[str] | None: """Generate video from reference images (character/style consistency).""" - model_key = f"abra_t2v_{duration}s" + model_key = video_model or f"abra_t2v_{duration}s" ref_images = [ {"mediaId": mid, "imageUsageType": "IMAGE_USAGE_TYPE_ASSET"} @@ -170,12 +173,12 @@ async def generate_video_r2v(bridge, prompt: str, aspect: str, project_id: str, ] requests = [] - for _ in range(count): + for i in range(count): requests.append({ "aspectRatio": aspect, "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, "videoModelKey": model_key, - "seed": random.randint(1, 9999), + "seed": resolve_seed(seed, i), "metadata": {}, "referenceImages": ref_images, }) diff --git a/flow-agent/omniflash/generators/t2i.py b/flow-agent/omniflash/generators/t2i.py index 69b1e22..8918f08 100644 --- a/flow-agent/omniflash/generators/t2i.py +++ b/flow-agent/omniflash/generators/t2i.py @@ -61,7 +61,7 @@ def _parse_image_results(data: dict) -> list[dict]: async def generate_image(bridge, prompt: str, aspect: str, project_id: str, count: int = 1, ref_media_ids: list[str] = None, - model: str = None) -> list[dict] | None: + model: str = None, seed: int = None) -> list[dict] | None: """Generate images from text prompt. Args: @@ -72,6 +72,7 @@ async def generate_image(bridge, prompt: str, aspect: str, project_id: str, count: Number of variations (1-4) ref_media_ids: Optional reference image media IDs model: Optional image model name (harbor_seal, narwhal, gem_pix_2, etc.) + seed: Optional explicit seed; reproducible when set, timestamp-derived when not Returns: List of {"media_id": str, "image_url": str} or None on error @@ -87,7 +88,7 @@ async def generate_image(bridge, prompt: str, aspect: str, project_id: str, for i in range(count): req_item = { "clientContext": build_client_context(project_id), - "seed": (ts + i * 1000) % 1000000, + "seed": ((int(seed) + i) % 4294967296) if seed is not None else (ts + i * 1000) % 1000000, "structuredPrompt": {"parts": [{"text": prompt}]}, "imageAspectRatio": aspect_ratio, "imageModelName": target_model, diff --git a/flow-agent/omniflash/generators/t2v.py b/flow-agent/omniflash/generators/t2v.py index 91b167e..cc37c89 100644 --- a/flow-agent/omniflash/generators/t2v.py +++ b/flow-agent/omniflash/generators/t2v.py @@ -5,23 +5,24 @@ import uuid from ..config import ENDPOINTS -from .common import build_client_context, build_generation_context +from .common import build_client_context, build_generation_context, resolve_seed log = logging.getLogger("omniflash.generators.t2v") async def generate_video(bridge, prompt: str, aspect: str, project_id: str, - duration: int = 10, count: int = 1) -> list[str] | None: + duration: int = 10, count: int = 1, + seed: int = None, video_model: str = None) -> list[str] | None: """Submit T2V generation request. Returns list of media_ids.""" - model_key = f"abra_t2v_{duration}s" + model_key = video_model or f"abra_t2v_{duration}s" requests = [] - for _ in range(count): + for i in range(count): requests.append({ "aspectRatio": aspect, "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, "videoModelKey": model_key, - "seed": random.randint(1, 9999), + "seed": resolve_seed(seed, i), "metadata": {}, }) diff --git a/flow-agent/omniflash/generators/v2v.py b/flow-agent/omniflash/generators/v2v.py index ab909ce..72eea05 100644 --- a/flow-agent/omniflash/generators/v2v.py +++ b/flow-agent/omniflash/generators/v2v.py @@ -4,7 +4,7 @@ import random from ..config import ENDPOINTS -from .common import build_client_context, build_generation_context +from .common import build_client_context, build_generation_context, resolve_seed log = logging.getLogger("omniflash.generators.v2v") @@ -12,7 +12,8 @@ async def edit_video(bridge, prompt: str, aspect: str, project_id: str, video_media_id: str, fps: int = 24, duration: int = 10, start_frame: int = 0, end_frame: int = None, - ref_media_ids: list[str] = None) -> list[str] | None: + ref_media_ids: list[str] = None, + seed: int = None) -> list[str] | None: """Submit V2V edit request. Returns list of media_ids.""" if end_frame is None: end_frame = fps * duration @@ -26,7 +27,7 @@ async def edit_video(bridge, prompt: str, aspect: str, project_id: str, "aspectRatio": aspect, "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, "videoModelKey": "abra_edit", - "seed": random.randint(1, 9999), + "seed": resolve_seed(seed), "metadata": {}, "videoInput": { "mediaId": video_media_id,