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, diff --git a/flow-extension/background.js b/flow-extension/background.js index 7ac5d74..ba355a6 100644 --- a/flow-extension/background.js +++ b/flow-extension/background.js @@ -99,9 +99,6 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === 'keepAlive') keepAlive(); if (alarm.name === 'flushOutbox') flushOutbox(); if (alarm.name === 'closeIdleFlowTab') await closeIdleFlowTab(); - if (alarm.name === 'token-refresh') { - await captureTokenFromFlowTab(); - } }); async function init() { @@ -121,9 +118,10 @@ async function init() { if (Array.isArray(data.requestLog)) requestLog = data.requestLog.slice(0, 100); await loadOutbox(); connectToAgent(); - chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 }); + // 0.5 min is Chrome's minimum alarm period — anything lower is silently clamped. + chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 }); // Retry any responses left undelivered by a previous worker lifetime. - chrome.alarms.create('flushOutbox', { periodInMinutes: 0.25 }); + chrome.alarms.create('flushOutbox', { periodInMinutes: 0.5 }); flushOutbox(); } @@ -177,6 +175,10 @@ function scheduleFlowTabClose() { async function closeIdleFlowTab() { if (!workTabId || !workTabCreatedByExtension) return; + if (state === 'running') { + scheduleFlowTabClose(); + return; + } const tabId = workTabId; workTabId = null; workTabCreatedByExtension = false; @@ -305,9 +307,6 @@ async function connectToAgent() { chrome.alarms.clear('reconnect'); setState('idle'); - // Token refresh alarm — 45 min gives buffer before ~60 min expiry - chrome.alarms.create('token-refresh', { periodInMinutes: 45 }); - const storage = await chrome.storage.local.get(['clientId']); let clientId = storage.clientId; if (!clientId) { @@ -447,7 +446,6 @@ async function connectToAgent() { ws.onclose = () => { setState('off'); - chrome.alarms.clear('token-refresh'); if (!manualDisconnect) scheduleReconnect(); }; @@ -459,7 +457,7 @@ async function connectToAgent() { } function scheduleReconnect() { - chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s + chrome.alarms.create('reconnect', { delayInMinutes: 0.5 }); } function keepAlive() { @@ -641,11 +639,7 @@ async function handleTrpcRequest(msg) { } setState('running'); - // TRPC calls don't consume captcha — don't count in metrics - - const logId = id; - const logType = url.includes('createProject') ? 'CREATE_PROJECT' : 'TRPC'; - // TRPC calls are silent — don't show in request log + // TRPC calls don't consume captcha and are silent — no metrics, no request log. const fetchHeaders = { 'Content-Type': 'application/json', ...headers }; if (flowKey) { @@ -660,13 +654,9 @@ async function handleTrpcRequest(msg) { credentials: 'include', }); const data = await resp.json(); - chrome.storage.local.set({ metrics }); - updateRequestLog(logId, { status: 'success' }); sendToAgent({ id, status: resp.status, data }); } catch (e) { console.error('[Flow Agent] tRPC request failed:', e); - chrome.storage.local.set({ metrics }); - updateRequestLog(logId, { status: 'failed', error: e.message || 'TRPC_FETCH_FAILED' }); sendToAgent({ id, error: e.message || 'TRPC_FETCH_FAILED' }); } finally { setState('idle'); diff --git a/flow-extension/content.js b/flow-extension/content.js index 3e62158..256fba9 100644 --- a/flow-extension/content.js +++ b/flow-extension/content.js @@ -51,20 +51,6 @@ window.addEventListener('TRPC_MEDIA_URLS', (e) => { }).catch(() => {}); }); -// ─── Aisandbox Request Sniffer (via postMessage from MAIN world) ── -window.addEventListener('message', (e) => { - if (e.data?.type !== '__FLOWKIT_SNIFF__') return; - const { url, body, method } = e.data; - if (!url) return; - chrome.runtime.sendMessage({ - type: 'SNIFFED_AISANDBOX_REQUEST', - url, - method, - payload: body, - timestamp: Date.now(), - }).catch(() => {}); -}); - // ─── Video Upload Relay ───────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _, reply) => { if (msg.type !== 'UPLOAD_VIDEO') return; diff --git a/flow-extension/injected.js b/flow-extension/injected.js index 6932e02..2d7161a 100644 --- a/flow-extension/injected.js +++ b/flow-extension/injected.js @@ -8,57 +8,12 @@ window.__FLOW_AGENT_MAIN_INJECTED__ = true; const SITE_KEY = '6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV'; -// ─── XHR Interceptor (for file uploads) ───────────────────── -const _xhrOpen = XMLHttpRequest.prototype.open; -const _xhrSend = XMLHttpRequest.prototype.send; -XMLHttpRequest.prototype.open = function (method, url, ...rest) { - this.__sniffUrl = url; - this.__sniffMethod = method; - return _xhrOpen.call(this, method, url, ...rest); -}; -XMLHttpRequest.prototype.send = function (body) { - try { - const url = this.__sniffUrl || ''; - if (url.includes('googleapis.com') || url.includes('labs.google') || url.includes('storage.google')) { - window.postMessage({ - type: '__FLOWKIT_SNIFF__', - url, - body: typeof body === 'string' ? body : `(binary ${body?.size || body?.byteLength || '?'} bytes)`, - method: this.__sniffMethod || 'POST', - }, '*'); - } - } catch {} - return _xhrSend.call(this, body); -}; - // ─── TRPC Response Monitor ───────────────────────────────── // Monkey-patch fetch to intercept TRPC responses containing media URLs. // Fresh signed GCS URLs are extracted and forwarded to the agent. const _originalFetch = window.fetch; window.fetch = async function (...args) { - try { - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; - - // ─── SNIFF ALL outgoing requests (catch upload) ───────── - { - let bodyText = ''; - if (args[1]?.body) { - const b = args[1].body; - if (typeof b === 'string') bodyText = b.length > 5000 ? b.slice(0, 200) + `...(${b.length} chars)` : b; - else if (b instanceof FormData) bodyText = `(FormData: ${[...b.keys()].join(', ')})`; - else if (b instanceof Blob) bodyText = `(Blob ${b.size} bytes, type=${b.type})`; - else if (b instanceof ArrayBuffer) bodyText = `(ArrayBuffer ${b.byteLength} bytes)`; - else if (b instanceof ReadableStream) bodyText = '(ReadableStream)'; - else bodyText = JSON.stringify(b)?.slice(0, 2000) || '(unknown)'; - } - window.postMessage({ - type: '__FLOWKIT_SNIFF__', - url, body: bodyText, method: args[1]?.method || 'GET', - }, '*'); - } - } catch {} - const response = await _originalFetch.apply(this, args); try { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; diff --git a/flow-extension/popup.html b/flow-extension/popup.html index 5c69052..8062a68 100644 --- a/flow-extension/popup.html +++ b/flow-extension/popup.html @@ -36,21 +36,35 @@ --radius: 12px; } - html, + html { + height: 100%; + } + + /* Header, tabs and the action bar stay put; only #content scrolls. */ body { + display: flex; + flex-direction: column; width: 100%; min-width: 320px; - min-height: 200px; + height: 100%; + overflow: hidden; background: var(--bg); color: var(--text); font-family: var(--font); font-size: 12px; line-height: 1.5; -webkit-font-smoothing: antialiased; - padding-bottom:64px; + } + + #content { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; } header { + flex-shrink: 0; display: flex; align-items: center; gap: 10px; @@ -146,26 +160,6 @@ color: var(--accent-soft); } - #log-list { - max-height: 440px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--border) transparent; - } - - #log-list::-webkit-scrollbar { - width: 4px; - } - - #log-list::-webkit-scrollbar-track { - background: transparent; - } - - #log-list::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 2px; - } - .log-empty { padding: 28px 14px; text-align: center; @@ -312,15 +306,6 @@ color: var(--red); } - .footer { - padding: 10px 14px; - border-top: 1px solid var(--border); - background: var(--surface); - font-size: 10px; - color: var(--muted); - text-align: center; - } - .quick-panel { padding: 14px; background: linear-gradient(180deg, #fff 0%, #fafbff 100%); @@ -426,15 +411,14 @@ .result-meta { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 11px; } .result-meta strong { color:var(--text); font-size:11px; } .result-open { flex-shrink:0; padding:7px 11px; border-radius:8px; background:var(--accent-bg); color:var(--accent); font-weight:800; text-decoration:none; } - .tabs { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:8px 10px; position:sticky; top:0; z-index:5; background:rgba(255,255,255,.94); backdrop-filter:blur(12px); border-bottom:1px solid var(--border); } + .tabs { flex-shrink:0; display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:8px 10px; background:#fff; border-bottom:1px solid var(--border); } .tab { border:0; outline:0; border-radius:9px; padding:8px 6px; background:transparent; color:var(--muted); font:700 10px var(--font); cursor:pointer; } .tab:focus-visible { box-shadow:0 0 0 2px rgba(255,55,95,.2); } .tab.active { color:var(--accent); background:var(--accent-bg); box-shadow:inset 0 0 0 1px rgba(255,55,95,.12); } .tab-panel { display:none; } .tab-panel.active { display:block; } - #panel-history.active { min-height:calc(100vh - 106px); display:flex; flex-direction:column; background:#fff; } - #panel-history #log-list { flex:1; } - #panel-history .footer { margin-top:auto; position:sticky; bottom:0; } + #panel-history.active { background:#fff; } + .log-header { position:sticky; top:0; z-index:2; } .settings-card { margin:14px; padding:14px; border:1px solid var(--border); border-radius:14px; background:#fff; box-shadow:0 8px 30px rgba(15,23,42,.06); } .settings-card h2 { font-size:14px; margin-bottom:3px; } .settings-card > p { color:var(--muted); font-size:10px; margin-bottom:14px; } @@ -445,6 +429,10 @@ .media-head strong { display:block; font-size:14px; } .media-head small { color:var(--muted); } #refresh-media { border:1px solid var(--border); border-radius:8px; padding:6px 9px; background:#fff; color:var(--text-dim); font:700 9px var(--font); cursor:pointer; } + .media-actions { display:flex; gap:6px; } + #delete-media { border:1px solid rgba(255,59,48,.25); border-radius:8px; padding:6px 9px; background:#fff; color:var(--red); font:700 9px var(--font); cursor:pointer; } + #delete-media:hover { background:rgba(255,59,48,.06); } + #delete-media:disabled { opacity:.5; cursor:wait; } .media-grid { display:grid; grid-template-columns:1fr; gap:12px; padding:0 14px 14px; } .media-item { overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#fff; box-shadow:0 5px 18px rgba(15,23,42,.06); } .media-item img,.media-item video { display:block; width:100%; max-height:280px; aspect-ratio:16/9; object-fit:cover; background:#eef1f5; } @@ -469,7 +457,7 @@ .metric-mini span { color:var(--muted); font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.06em; } .token-card { display:flex; align-items:center; justify-content:space-between; padding:10px 11px; margin-bottom:10px; border-radius:10px; background:#f5f7fa; } #monitor-token { font-size:10px; font-weight:700; color:var(--text-dim); } - .monitor-actions { position:fixed; left:0; right:0; bottom:0; z-index:20; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 12px; background:rgba(255,255,255,.96); backdrop-filter:blur(14px); border-top:1px solid var(--border); box-shadow:0 -8px 24px rgba(15,23,42,.06); } + .monitor-actions { flex-shrink:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 12px; background:#fff; border-top:1px solid var(--border); box-shadow:0 -8px 24px rgba(15,23,42,.06); } .monitor-actions button { border:1px solid var(--border); border-radius:9px; padding:9px; background:#fff; color:var(--text); font:700 10px var(--font); cursor:pointer; } .monitor-actions button.primary { border:0; color:#fff; background:linear-gradient(135deg,var(--accent),var(--accent-soft)); } #toast { position:fixed; left:50%; bottom:72px; z-index:40; max-width:calc(100% - 28px); padding:9px 13px; border-radius:10px; background:#111827; color:#fff; font:700 10px var(--font); box-shadow:0 10px 30px rgba(15,23,42,.22); opacity:0; pointer-events:none; transform:translate(-50%,10px); transition:.2s ease; white-space:nowrap; } @@ -498,6 +486,7 @@ +

Connection settings

Connect this extension to your Flow Agent backend.

@@ -508,7 +497,7 @@

Connection settings

Connect this extension to your Flow Agent backend

-
Media libraryBackend and extension generations
+
Media libraryBackend and extension generations
Loading media…
@@ -548,6 +537,7 @@

Connection settings

Connect this extension to your Flow Agent backend +

diff --git a/flow-extension/popup.js b/flow-extension/popup.js index a8edb39..3eeeafe 100644 --- a/flow-extension/popup.js +++ b/flow-extension/popup.js @@ -42,6 +42,13 @@ function escHtml(str) { .replace(/"/g, '"'); } +// Media URLs come from the backend, so only allow schemes that are safe to +// drop into an href/src — a `javascript:` URL would otherwise be clickable. +function safeUrl(raw) { + const url = String(raw || '').trim(); + return /^(https?|blob|data):/i.test(url) ? url : ''; +} + function badgeHtml(status) { if (status === 'COMPLETED' || status === 'success') { return '✓ done'; @@ -143,7 +150,8 @@ async function loadMedia() { return; } grid.innerHTML = items.map((item) => { - const rawUrl = String(item.url || ''); + const rawUrl = safeUrl(item.url); + if (!rawUrl) return ''; const url = escHtml(rawUrl.replace(/^http:\/\/(localhost|127\.0\.0\.1):8001/i, base)); const prompt = escHtml(item.prompt || 'Generated media'); const preview = item.type === 'video' @@ -161,6 +169,7 @@ async function loadMedia() { } let monitorEnabled = false; +let wasConnected = false; function runtimeMessage(payload) { return new Promise((resolve, reject) => chrome.runtime.sendMessage(payload, (response) => { if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); @@ -194,8 +203,13 @@ async function refreshMonitor() { const age = status.tokenAge == null ? null : Math.round(status.tokenAge / 60000); document.getElementById('monitor-token').textContent = status.flowKeyPresent ? `Token ${age || 0}m` : 'No token'; document.getElementById('monitor-client').textContent = (status.clientId || '—').replace(/^client-/, ''); + // Credits cost a real backend round-trip, so only fetch them when the + // connection actually comes back up — not on every status tick. + if (connected && !wasConnected) refreshQuickStatus(); + wasConnected = connected; } catch { document.getElementById('monitor-state').textContent = 'Extension offline'; + wasConnected = false; } } @@ -227,13 +241,28 @@ document.getElementById('save-settings').addEventListener('click', async () => { await chrome.storage.local.set({ clientId: document.getElementById('setting-client').value.trim() }); chrome.runtime.sendMessage({ type: 'SETTINGS_UPDATED' }); selectTab('create'); - refreshQuickStatus(); + // New client id means a different balance — re-arm so the next tick refetches + // once the reconnect has actually landed. + wasConnected = false; }); document.getElementById('clear-history').addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'CLEAR_REQUEST_LOG' }, () => renderLog([])); }); document.getElementById('refresh-media').addEventListener('click', loadMedia); +document.getElementById('delete-media').addEventListener('click', async () => { + const button = document.getElementById('delete-media'); + button.disabled = true; + try { + await apiJson('/v1/history', { method: 'DELETE' }); + showToast('All media deleted'); + loadMedia(); + } catch (error) { + showToast(`Delete failed: ${error.message}`, 'error'); + } finally { + button.disabled = false; + } +}); chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => { if (chrome.runtime.lastError) return; @@ -242,10 +271,7 @@ chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => { chrome.runtime.onMessage.addListener((message) => { if (message.type === 'REQUEST_LOG_UPDATE' && message.log) renderLog(message.log); - if (message.type === 'STATUS_PUSH') { - refreshMonitor(); - refreshQuickStatus(); - } + if (message.type === 'STATUS_PUSH') refreshMonitor(); }); // ── Quick generation ──────────────────────────────────────── @@ -278,7 +304,23 @@ async function apiJson(path, options = {}) { return data; } +// Credits come back in two shapes: a single client's response +// ({data:{credits}}) or, when no client id is known yet, the pooled +// fan-out across every connected browser ({total_credits}). +function parseCredits(payload) { + if (!payload || typeof payload !== 'object') return null; + const candidates = [payload.data?.credits, payload.credits, payload.total_credits]; + for (const value of candidates) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +let creditsInFlight = false; async function refreshQuickStatus() { + if (creditsInFlight) return; + creditsInFlight = true; const credits = document.getElementById('quick-credits'); try { let creditData; @@ -288,13 +330,17 @@ async function refreshQuickStatus() { console.warn('[Flow Agent] Background credits failed, trying direct API:', backgroundError); creditData = await apiJson('/v1/credits'); } - const balance = creditData.data?.credits ?? creditData.credits ?? '—'; + const balance = parseCredits(creditData); + if (balance == null) throw new Error('Credits missing from response'); credits.textContent = String(balance); credits.title = `Current client credits: ${balance}`; } catch (error) { - credits.textContent = 'ERR'; - credits.title = error.message; + // A transient failure shouldn't wipe a known-good balance off the UI. + if (!/^\d+$/.test(credits.textContent)) credits.textContent = '—'; + credits.title = `Credits unavailable: ${error.message}`; console.error('[Flow Agent] Credits unavailable:', error); + } finally { + creditsInFlight = false; } } @@ -391,11 +437,12 @@ document.getElementById('quick-generate').addEventListener('click', async () => }, }); if (item.url) { - const safeUrl = escHtml(item.url); + const safeHref = safeUrl(item.url); + const safeUrlAttr = escHtml(safeHref); const preview = type === 'image' - ? `Generated image` - : ``; - result.innerHTML = `
${preview}
Generation readyView
`; + ? `Generated image` + : ``; + result.innerHTML = `
${preview}
Generation readyView
`; } else { result.textContent = 'Done. Check Flow history for the result.'; } @@ -410,6 +457,5 @@ document.getElementById('quick-generate').addEventListener('click', async () => }); updateQuickFields(); -refreshQuickStatus(); refreshMonitor(); setInterval(refreshMonitor, 3000);