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 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,