From 268b1bf71d16da99b8275728d09559f5b01b46be Mon Sep 17 00:00:00 2001 From: akashyadav758 Date: Mon, 27 Jul 2026 17:03:41 +0530 Subject: [PATCH 1/4] Stop the extension polling the backend while idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credits refresh fed itself: a credits fetch became an api_request, which flipped extension state and pushed a status update, which fetched credits again. Credits now refresh only on panel open, reconnect, and generation. Also drop work the extension was doing for nobody — a 45-min token refresh that duplicated the existing 401 self-heal, a request sniffer with no receiving handler, and phantom log writes on every tRPC call. Alarms now declare Chrome's real 30s minimum instead of values it silently clamped, the idle Flow tab no longer closes mid-generation, and media URLs are scheme-checked before landing in an href. Adds a Delete all button to the media tab. Co-Authored-By: Claude Sonnet 5 --- flow-extension/background.js | 28 +++++--------- flow-extension/content.js | 14 ------- flow-extension/injected.js | 45 ---------------------- flow-extension/popup.html | 6 ++- flow-extension/popup.js | 74 +++++++++++++++++++++++++++++------- 5 files changed, 74 insertions(+), 93 deletions(-) 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..a676066 100644 --- a/flow-extension/popup.html +++ b/flow-extension/popup.html @@ -445,6 +445,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; } @@ -508,7 +512,7 @@

Connection settings

Connect this extension to your Flow Agent backend

-
Media libraryBackend and extension generations
+
Media libraryBackend and extension generations
Loading media…
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); From fa87335d6483a80323899b2415a7f2b3a9bdbc95 Mon Sep 17 00:00:00 2001 From: akashyadav758 Date: Mon, 27 Jul 2026 17:17:01 +0530 Subject: [PATCH 2/4] Anchor the side panel chrome so only content scrolls The panel scrolled as one page, so the header, tabs and action bar drifted away as soon as a list grew. The log list also carried its own max-height and overflow, producing a second scrollbar nested inside the page one. Body is now a fixed-height flex column with the panels in a scrollable main; the surrounding chrome stays put. Drops the padding-bottom hack that compensated for the previously fixed action bar, and a .footer rule with no matching element. Co-Authored-By: Claude Sonnet 5 --- flow-extension/popup.html | 60 +++++++++++++++------------------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/flow-extension/popup.html b/flow-extension/popup.html index a676066..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; } @@ -473,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; } @@ -502,6 +486,7 @@ +

Connection settings

Connect this extension to your Flow Agent backend.

@@ -552,6 +537,7 @@

Connection settings

Connect this extension to your Flow Agent backend

+
From eda0e11df9c2bd1a66cb6affbf06473c1dabae89 Mon Sep 17 00:00:00 2001 From: santoshray02 Date: Mon, 27 Jul 2026 18:01:05 +0530 Subject: [PATCH 3/4] 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 4/4] 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,