vrex-flow-engine 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flow_engine/__init__.py +4 -0
- flow_engine/bridge/__init__.py +6 -0
- flow_engine/bridge/flow_client.py +444 -0
- flow_engine/bridge/flow_sdk.py +1743 -0
- flow_engine/bridge/ws_server.py +84 -0
- flow_engine/catalog.py +332 -0
- flow_engine/cli/__init__.py +1 -0
- flow_engine/cli/app.py +149 -0
- flow_engine/cli/config.py +81 -0
- flow_engine/cli/dashboard.py +125 -0
- flow_engine/cli/health.py +40 -0
- flow_engine/cli/processes.py +109 -0
- flow_engine/cli/supervisor.py +184 -0
- flow_engine/config.py +43 -0
- flow_engine/ingest.py +172 -0
- flow_engine/job_store.py +28 -0
- flow_engine/jobs.py +245 -0
- flow_engine/main.py +187 -0
- flow_engine/media.py +227 -0
- flow_engine/media_store.py +202 -0
- flow_engine/openai/__init__.py +1 -0
- flow_engine/openai/_util.py +59 -0
- flow_engine/openai/images.py +134 -0
- flow_engine/openai/models.py +38 -0
- flow_engine/openai/uploads.py +70 -0
- flow_engine/openai/videos.py +289 -0
- flow_engine/pool.py +138 -0
- flow_engine/posthog_client.py +117 -0
- flow_engine/session.py +89 -0
- flow_engine/video_context.py +77 -0
- flow_engine/video_context_store.py +49 -0
- vrex_flow_engine-0.2.0.dist-info/METADATA +62 -0
- vrex_flow_engine-0.2.0.dist-info/RECORD +36 -0
- vrex_flow_engine-0.2.0.dist-info/WHEEL +5 -0
- vrex_flow_engine-0.2.0.dist-info/entry_points.txt +3 -0
- vrex_flow_engine-0.2.0.dist-info/top_level.txt +1 -0
flow_engine/job_store.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Per-instance job store.
|
|
2
|
+
|
|
3
|
+
Extracted from the module-level ``_jobs`` dict in jobs.py so each pool
|
|
4
|
+
instance owns an isolated namespace — jobs from account A never appear in
|
|
5
|
+
account B's job listing.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Optional
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from flow_engine.jobs import Job
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JobStore:
|
|
16
|
+
"""Simple dict-backed job registry, one per PoolInstance."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._jobs: dict[str, "Job"] = {}
|
|
20
|
+
|
|
21
|
+
def get(self, job_id: str) -> Optional["Job"]:
|
|
22
|
+
return self._jobs.get(job_id)
|
|
23
|
+
|
|
24
|
+
def put(self, job: "Job") -> None:
|
|
25
|
+
self._jobs[job.id] = job
|
|
26
|
+
|
|
27
|
+
def __len__(self) -> int:
|
|
28
|
+
return len(self._jobs)
|
flow_engine/jobs.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Async video job runner.
|
|
2
|
+
|
|
3
|
+
flowboard ran generation through a DB-backed request queue + worker. flow_engine
|
|
4
|
+
replaces that with per-request asyncio tasks and a per-instance JobStore.
|
|
5
|
+
|
|
6
|
+
The poll loop mirrors flowboard/agent worker `_handle_gen_video`: per-op
|
|
7
|
+
resolution with partial-failure aggregation (some variants can be blocked by
|
|
8
|
+
the content filter while others render). State is process-local; swap for a
|
|
9
|
+
shared store if you scale past one process.
|
|
10
|
+
|
|
11
|
+
All public functions accept explicit ``job_store``, ``media_store``, and
|
|
12
|
+
``vc_store`` arguments instead of using module globals — this gives each
|
|
13
|
+
PoolInstance full isolation.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import base64
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
from flow_engine.bridge.flow_sdk import FlowSDK
|
|
27
|
+
from flow_engine.job_store import JobStore
|
|
28
|
+
from flow_engine.media_store import MediaStore
|
|
29
|
+
from flow_engine.video_context_store import VideoContextStore
|
|
30
|
+
from flow_engine.video_context import parse_seconds
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Poll knobs — same budget as flowboard's worker (5 min: 30 cycles × 10s).
|
|
35
|
+
# Poll budget for waiting on Veo results. Default 72 × 10s = 12 min, to tolerate
|
|
36
|
+
# Flow's veo_*_low_priority queue (which can exceed the old 5-min budget and
|
|
37
|
+
# return timeout_waiting_video). Env-tunable without a code change.
|
|
38
|
+
VIDEO_POLL_INTERVAL_S = float(os.getenv("VIDEO_POLL_INTERVAL_S", "10"))
|
|
39
|
+
VIDEO_POLL_MAX_CYCLES = int(os.getenv("VIDEO_POLL_MAX_CYCLES", "72"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Job:
|
|
44
|
+
id: str
|
|
45
|
+
kind: str # "video"
|
|
46
|
+
status: str = "in_progress" # in_progress | completed | failed
|
|
47
|
+
created_at: int = field(default_factory=lambda: int(time.time()))
|
|
48
|
+
# Positional results aligned to dispatch order.
|
|
49
|
+
media_ids: list[Optional[str]] = field(default_factory=list)
|
|
50
|
+
slot_errors: list[Optional[str]] = field(default_factory=list)
|
|
51
|
+
error: Optional[str] = None
|
|
52
|
+
partial_error: Optional[str] = None
|
|
53
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Generation mode → the FlowSDK dispatch coroutine it maps to.
|
|
57
|
+
_DISPATCH = {
|
|
58
|
+
"t2v": "gen_video_t2v",
|
|
59
|
+
"r2v": "gen_video_r2v",
|
|
60
|
+
"i2v": "gen_video",
|
|
61
|
+
"extension": "gen_video_extend",
|
|
62
|
+
"edit": "gen_video_edit",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get(job_id: str, job_store: JobStore) -> Optional[Job]:
|
|
67
|
+
return job_store.get(job_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _new_job(kind: str, job_store: JobStore) -> Job:
|
|
71
|
+
job = Job(id=f"{kind}_{uuid.uuid4().hex}", kind=kind)
|
|
72
|
+
job_store.put(job)
|
|
73
|
+
return job
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def start_video_job(
|
|
77
|
+
sdk: FlowSDK,
|
|
78
|
+
*,
|
|
79
|
+
mode: str,
|
|
80
|
+
dispatch_kwargs: dict[str, Any],
|
|
81
|
+
job_store: JobStore,
|
|
82
|
+
media_store: MediaStore,
|
|
83
|
+
vc_store: VideoContextStore,
|
|
84
|
+
) -> Job:
|
|
85
|
+
"""Create a job and kick off dispatch+poll in the background.
|
|
86
|
+
|
|
87
|
+
``mode`` ∈ {t2v, i2v, r2v, extension, edit} selects the FlowSDK dispatch
|
|
88
|
+
method; ``dispatch_kwargs`` is forwarded to it.
|
|
89
|
+
"""
|
|
90
|
+
job = _new_job("video", job_store)
|
|
91
|
+
asyncio.create_task(
|
|
92
|
+
_run_video_job(job, sdk, mode, dispatch_kwargs, job_store, media_store, vc_store)
|
|
93
|
+
)
|
|
94
|
+
return job
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _run_video_job(
|
|
98
|
+
job: Job,
|
|
99
|
+
sdk: FlowSDK,
|
|
100
|
+
mode: str,
|
|
101
|
+
kwargs: dict[str, Any],
|
|
102
|
+
job_store: JobStore,
|
|
103
|
+
media_store: MediaStore,
|
|
104
|
+
vc_store: VideoContextStore,
|
|
105
|
+
) -> None:
|
|
106
|
+
try:
|
|
107
|
+
dispatch = await getattr(sdk, _DISPATCH[mode])(**kwargs)
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
logger.exception("video dispatch crashed")
|
|
110
|
+
job.status, job.error = "failed", str(exc)[:200]
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if dispatch.get("error"):
|
|
114
|
+
job.status = "failed"
|
|
115
|
+
job.error = str(dispatch["error"])[:200]
|
|
116
|
+
job.raw = {"dispatch": dispatch}
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
op_names: list[str] = dispatch.get("operation_names") or []
|
|
120
|
+
if not op_names:
|
|
121
|
+
job.status, job.error = "failed", "no_operations_returned"
|
|
122
|
+
job.raw = {"dispatch": dispatch}
|
|
123
|
+
return
|
|
124
|
+
workflows = dispatch.get("workflows") or None
|
|
125
|
+
|
|
126
|
+
done_by_name = {name: False for name in op_names}
|
|
127
|
+
entry_by_name: dict[str, dict] = {}
|
|
128
|
+
op_errors: dict[str, str] = {}
|
|
129
|
+
last_poll: dict = {}
|
|
130
|
+
attempts = 0
|
|
131
|
+
|
|
132
|
+
while attempts < VIDEO_POLL_MAX_CYCLES and not all(done_by_name.values()):
|
|
133
|
+
await asyncio.sleep(VIDEO_POLL_INTERVAL_S)
|
|
134
|
+
attempts += 1
|
|
135
|
+
last_poll = await sdk.check_async(
|
|
136
|
+
op_names, workflows=workflows, project_id=kwargs.get("project_id")
|
|
137
|
+
)
|
|
138
|
+
if last_poll.get("error"):
|
|
139
|
+
continue
|
|
140
|
+
for op in last_poll.get("operations") or []:
|
|
141
|
+
if not isinstance(op, dict):
|
|
142
|
+
continue
|
|
143
|
+
name = op.get("name")
|
|
144
|
+
if not isinstance(name, str) or done_by_name.get(name, False):
|
|
145
|
+
continue
|
|
146
|
+
err = op.get("error")
|
|
147
|
+
if isinstance(err, str) and err:
|
|
148
|
+
done_by_name[name] = True
|
|
149
|
+
op_errors[name] = err
|
|
150
|
+
continue
|
|
151
|
+
if op.get("done"):
|
|
152
|
+
done_by_name[name] = True
|
|
153
|
+
for e in op.get("media_entries") or []:
|
|
154
|
+
if isinstance(e, dict) and e.get("media_id"):
|
|
155
|
+
entry_by_name[name] = e
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
# Unresolved after budget → timeout.
|
|
159
|
+
for name in op_names:
|
|
160
|
+
if not done_by_name.get(name) and name not in op_errors:
|
|
161
|
+
op_errors[name] = "timeout_waiting_video"
|
|
162
|
+
|
|
163
|
+
positional_ids: list[Optional[str]] = []
|
|
164
|
+
slot_errors: list[Optional[str]] = []
|
|
165
|
+
succeeded: list[dict] = []
|
|
166
|
+
for name in op_names:
|
|
167
|
+
e = entry_by_name.get(name)
|
|
168
|
+
if isinstance(e, dict) and isinstance(e.get("media_id"), str):
|
|
169
|
+
positional_ids.append(e["media_id"])
|
|
170
|
+
succeeded.append(e)
|
|
171
|
+
slot_errors.append(None)
|
|
172
|
+
else:
|
|
173
|
+
positional_ids.append(None)
|
|
174
|
+
slot_errors.append(op_errors.get(name))
|
|
175
|
+
|
|
176
|
+
if not any(positional_ids):
|
|
177
|
+
job.status = "failed"
|
|
178
|
+
job.error = next(iter(op_errors.values()), "timeout_waiting_video")
|
|
179
|
+
job.slot_errors = slot_errors
|
|
180
|
+
job.raw = {"dispatch": dispatch, "last_poll": last_poll}
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Ingest what rendered into this instance's MediaStore.
|
|
184
|
+
url_entries = [e for e in succeeded if isinstance(e, dict) and e.get("url")]
|
|
185
|
+
if url_entries:
|
|
186
|
+
try:
|
|
187
|
+
media_store.ingest_urls(url_entries)
|
|
188
|
+
except Exception: # noqa: BLE001
|
|
189
|
+
logger.exception("ingest_urls failed for video job %s", job.id)
|
|
190
|
+
for e in succeeded:
|
|
191
|
+
encoded, mid = e.get("encoded_video"), e.get("media_id")
|
|
192
|
+
if isinstance(encoded, str) and isinstance(mid, str):
|
|
193
|
+
try:
|
|
194
|
+
media_store.ingest_inline_bytes(
|
|
195
|
+
mid,
|
|
196
|
+
base64.b64decode(encoded, validate=False),
|
|
197
|
+
kind="video",
|
|
198
|
+
mime="video/mp4",
|
|
199
|
+
)
|
|
200
|
+
except Exception: # noqa: BLE001
|
|
201
|
+
logger.exception("inline ingest failed for %s", mid)
|
|
202
|
+
|
|
203
|
+
# Stash extend/edit context into this instance's VideoContextStore.
|
|
204
|
+
wf_by_media = {
|
|
205
|
+
w.get("primary_media_id"): w.get("name")
|
|
206
|
+
for w in (workflows or [])
|
|
207
|
+
if isinstance(w, dict)
|
|
208
|
+
}
|
|
209
|
+
finished_ids = [
|
|
210
|
+
e["media_id"]
|
|
211
|
+
for e in succeeded
|
|
212
|
+
if isinstance(e, dict) and isinstance(e.get("media_id"), str)
|
|
213
|
+
]
|
|
214
|
+
rich_meta: dict[str, dict] = {}
|
|
215
|
+
if finished_ids:
|
|
216
|
+
project_id = kwargs.get("project_id")
|
|
217
|
+
if isinstance(project_id, str):
|
|
218
|
+
try:
|
|
219
|
+
rich_meta = (
|
|
220
|
+
await sdk.fetch_video_meta(finished_ids, project_id)
|
|
221
|
+
).get("meta") or {}
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
logger.exception("fetch_video_meta failed for job %s", job.id)
|
|
224
|
+
|
|
225
|
+
dispatched_scene = dispatch.get("scene_id")
|
|
226
|
+
for mid in finished_ids:
|
|
227
|
+
rm = rich_meta.get(mid) or {}
|
|
228
|
+
vc_store.remember(
|
|
229
|
+
mid,
|
|
230
|
+
workflow_id=wf_by_media.get(mid) or rm.get("workflow_id"),
|
|
231
|
+
operation_id=rm.get("operation_id"),
|
|
232
|
+
scene_id=dispatched_scene or rm.get("scene_id"),
|
|
233
|
+
duration_sec=parse_seconds(rm.get("length")),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if op_errors:
|
|
237
|
+
unique = sorted({v for v in op_errors.values()})
|
|
238
|
+
job.partial_error = (
|
|
239
|
+
f"{len(op_errors)}/{len(op_names)} variants blocked: {', '.join(unique)}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
job.media_ids = positional_ids
|
|
243
|
+
job.slot_errors = slot_errors
|
|
244
|
+
job.status = "completed"
|
|
245
|
+
job.raw = {"dispatch": dispatch, "last_poll": last_poll, "video_meta": rich_meta}
|
flow_engine/main.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""flow_engine FastAPI app.
|
|
2
|
+
|
|
3
|
+
Hosts the extension bridge (WS :9223) and an OpenAI-compatible HTTP surface
|
|
4
|
+
(:8101) for Google Flow image/video generation.
|
|
5
|
+
|
|
6
|
+
Boot-time checks:
|
|
7
|
+
- FLOW_ENGINE_API_KEY (or legacy FLOWPROXY_API_KEY) must be non-empty.
|
|
8
|
+
- WS_HOST must be loopback (the extension WS is unauthenticated by design).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
|
|
16
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
18
|
+
from fastapi.responses import FileResponse, Response
|
|
19
|
+
|
|
20
|
+
from flow_engine.config import API_KEY, WS_HOST
|
|
21
|
+
from flow_engine.openai import images, models, uploads, videos
|
|
22
|
+
from flow_engine.openai._util import require_api_key
|
|
23
|
+
from flow_engine.pool import pool
|
|
24
|
+
from flow_engine.posthog_client import flush_posthog
|
|
25
|
+
|
|
26
|
+
# The WS server is unauthenticated — refuse to boot on a non-loopback bind.
|
|
27
|
+
if WS_HOST not in ("127.0.0.1", "localhost", "::1"):
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
f"FLOWPROXY_WS_HOST must be loopback (got {WS_HOST!r}); the extension WS "
|
|
30
|
+
"is unauthenticated by design and must not be network-reachable."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
35
|
+
)
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@asynccontextmanager
|
|
40
|
+
async def lifespan(app: FastAPI):
|
|
41
|
+
# Enforce non-empty API key at server startup, not at import time, so that
|
|
42
|
+
# `pip install -e .` and plain imports never crash (only the running server).
|
|
43
|
+
if not API_KEY:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"FLOW_ENGINE_API_KEY must be set (or legacy FLOWPROXY_API_KEY). "
|
|
46
|
+
"Set the environment variable before starting flow-engine."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from flow_engine.bridge.ws_server import run_ws_server
|
|
50
|
+
|
|
51
|
+
ws_task = asyncio.create_task(run_ws_server(), name="ext-ws-server")
|
|
52
|
+
logger.info("flow_engine started (ws bridge + openai surface)")
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
ws_task.cancel()
|
|
57
|
+
await asyncio.gather(ws_task, return_exceptions=True)
|
|
58
|
+
flush_posthog() # drain queued events before process exits
|
|
59
|
+
logger.info("flow_engine stopped")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
app = FastAPI(title="flow_engine", version="0.0.1", lifespan=lifespan)
|
|
63
|
+
|
|
64
|
+
app.add_middleware(
|
|
65
|
+
CORSMiddleware,
|
|
66
|
+
allow_origins=["*"],
|
|
67
|
+
allow_credentials=True,
|
|
68
|
+
allow_methods=["*"],
|
|
69
|
+
allow_headers=["*"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
app.include_router(images.router)
|
|
73
|
+
app.include_router(videos.router)
|
|
74
|
+
app.include_router(uploads.router)
|
|
75
|
+
app.include_router(models.router)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.get("/api/health")
|
|
79
|
+
def health() -> dict:
|
|
80
|
+
instances = pool.status()
|
|
81
|
+
connected = any(i["connected"] for i in instances)
|
|
82
|
+
return {
|
|
83
|
+
"ok": True,
|
|
84
|
+
"extension_connected": connected,
|
|
85
|
+
"instances": len(instances),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.get("/api/pool/status")
|
|
90
|
+
def pool_status(_: str | None = Depends(require_api_key)) -> dict:
|
|
91
|
+
"""Admin view of all registered pool instances (API-key gated)."""
|
|
92
|
+
return {"instances": pool.status()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.post("/api/ext/callback")
|
|
96
|
+
async def ext_callback(
|
|
97
|
+
request: Request,
|
|
98
|
+
x_callback_secret: str | None = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""HTTP callback the extension uses to deliver API responses.
|
|
101
|
+
|
|
102
|
+
Routes to the correct PoolInstance by searching all instances' pending
|
|
103
|
+
futures for a matching request id.
|
|
104
|
+
"""
|
|
105
|
+
# Read the secret from the header manually so the endpoint stays flexible.
|
|
106
|
+
x_callback_secret = request.headers.get("x-callback-secret")
|
|
107
|
+
if not x_callback_secret:
|
|
108
|
+
raise HTTPException(status_code=401, detail="missing X-Callback-Secret")
|
|
109
|
+
|
|
110
|
+
# Validate secret against any registered instance — each has its own.
|
|
111
|
+
matched_instance = None
|
|
112
|
+
for inst in pool.status():
|
|
113
|
+
# pool.status() returns dicts; we need the live instances.
|
|
114
|
+
pass
|
|
115
|
+
# Access live instances directly from the pool registry.
|
|
116
|
+
import hmac
|
|
117
|
+
valid = False
|
|
118
|
+
for inst_obj in pool._instances.values():
|
|
119
|
+
if hmac.compare_digest(x_callback_secret, inst_obj.client.callback_secret):
|
|
120
|
+
valid = True
|
|
121
|
+
break
|
|
122
|
+
if not valid:
|
|
123
|
+
raise HTTPException(status_code=401, detail="invalid callback secret")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
payload = await request.json()
|
|
127
|
+
except Exception:
|
|
128
|
+
raise HTTPException(status_code=400, detail="invalid json body")
|
|
129
|
+
if not isinstance(payload, dict) or "id" not in payload:
|
|
130
|
+
raise HTTPException(status_code=400, detail="missing id")
|
|
131
|
+
|
|
132
|
+
resolved = pool.resolve_callback(payload)
|
|
133
|
+
return {"ok": resolved}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/media/{media_id}")
|
|
137
|
+
async def serve_media(media_id: str, request: Request) -> Response:
|
|
138
|
+
"""Serve cached media bytes. Routes to the correct instance's MediaStore
|
|
139
|
+
by trying each registered instance in pool order."""
|
|
140
|
+
from flow_engine.media import normalize_media_id, is_valid_media_id
|
|
141
|
+
|
|
142
|
+
media_id = normalize_media_id(media_id)
|
|
143
|
+
if not is_valid_media_id(media_id):
|
|
144
|
+
raise HTTPException(status_code=404, detail="invalid media id")
|
|
145
|
+
|
|
146
|
+
# Try per-instance stores first (multi-tenant path).
|
|
147
|
+
for inst_obj in pool._instances.values():
|
|
148
|
+
path = await inst_obj.media.ensure_cached(media_id)
|
|
149
|
+
if path is not None:
|
|
150
|
+
return FileResponse(path, media_type=inst_obj.media.media_mime(media_id))
|
|
151
|
+
|
|
152
|
+
# Fallback: module-level media store (populated by single-tenant code paths
|
|
153
|
+
# that haven't been migrated yet, or for legacy compatibility).
|
|
154
|
+
from flow_engine import media as media_service
|
|
155
|
+
path = await media_service.ensure_cached(media_id)
|
|
156
|
+
if path is None:
|
|
157
|
+
raise HTTPException(status_code=404, detail="media not available")
|
|
158
|
+
return FileResponse(path, media_type=media_service.media_mime(media_id))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.get("/media/{media_id}/status")
|
|
162
|
+
def media_status(media_id: str) -> dict:
|
|
163
|
+
from flow_engine.media import normalize_media_id, is_valid_media_id
|
|
164
|
+
|
|
165
|
+
media_id = normalize_media_id(media_id)
|
|
166
|
+
if not is_valid_media_id(media_id):
|
|
167
|
+
return {"available": False, "has_url": False, "reason": "invalid_id"}
|
|
168
|
+
|
|
169
|
+
for inst_obj in pool._instances.values():
|
|
170
|
+
s = inst_obj.media.status(media_id)
|
|
171
|
+
if s.get("available") or s.get("has_url"):
|
|
172
|
+
return s
|
|
173
|
+
|
|
174
|
+
from flow_engine import media as media_service
|
|
175
|
+
return media_service.status(media_id)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main() -> None:
|
|
179
|
+
import uvicorn
|
|
180
|
+
|
|
181
|
+
from flow_engine.config import HTTP_PORT
|
|
182
|
+
|
|
183
|
+
uvicorn.run("flow_engine.main:app", host="127.0.0.1", port=HTTP_PORT, reload=False)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
main()
|
flow_engine/media.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Media cache — DB-free port of flowboard's media service.
|
|
2
|
+
|
|
3
|
+
flowboard persisted media to a SQLModel ``Asset`` table; flow_engine keeps the
|
|
4
|
+
same behaviour with a process-local registry plus an on-disk byte cache under
|
|
5
|
+
``storage/media``. Flow returns short-TTL signed ``flow-content.google`` URLs
|
|
6
|
+
(and, for workflow-mode video, inline base64 MP4), so we fetch/plant bytes
|
|
7
|
+
locally and serve stable ``/media/<id>`` URLs to OpenAI clients.
|
|
8
|
+
|
|
9
|
+
Registry is in-memory and rebuilt from disk on startup — good enough for a
|
|
10
|
+
single-tenant proxy. Swap for Redis/SQLite if you need persistence across
|
|
11
|
+
restarts or a horizontal pool.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from flow_engine.config import STORAGE_DIR
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
MEDIA_CACHE_DIR = STORAGE_DIR / "media"
|
|
29
|
+
MEDIA_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# Media id is a hex-with-dashes UUID in the GCS path.
|
|
32
|
+
_MEDIA_ID_RE = re.compile(r"^[0-9a-fA-F-]{1,64}$")
|
|
33
|
+
|
|
34
|
+
# Flow serves user-generated media from its `flow-content.google` CDN (signed
|
|
35
|
+
# with short-TTL query params); the response includes the signed URL at
|
|
36
|
+
# `data.media[].image.generatedImage.fifeUrl`.
|
|
37
|
+
_ALLOWED_URL_PREFIXES: tuple[str, ...] = ("https://flow-content.google/",)
|
|
38
|
+
|
|
39
|
+
_EXT_BY_MIME = {
|
|
40
|
+
"image/jpeg": ".jpg",
|
|
41
|
+
"image/png": ".png",
|
|
42
|
+
"image/webp": ".webp",
|
|
43
|
+
"image/gif": ".gif",
|
|
44
|
+
"video/mp4": ".mp4",
|
|
45
|
+
"video/webm": ".webm",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Asset:
|
|
51
|
+
media_id: str
|
|
52
|
+
url: Optional[str] = None
|
|
53
|
+
kind: str = "image"
|
|
54
|
+
local_path: Optional[str] = None
|
|
55
|
+
mime: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_registry: dict[str, Asset] = {}
|
|
59
|
+
_lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _url_allowed(url: Any) -> bool:
|
|
63
|
+
return isinstance(url, str) and any(url.startswith(p) for p in _ALLOWED_URL_PREFIXES)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_valid_media_id(media_id: str) -> bool:
|
|
67
|
+
return bool(_MEDIA_ID_RE.fullmatch(media_id or ""))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_media_id(raw: str) -> str:
|
|
71
|
+
"""Accept either ``media/<uuid>`` or the bare uuid."""
|
|
72
|
+
if raw.startswith("media/"):
|
|
73
|
+
return raw.split("/", 1)[1]
|
|
74
|
+
return raw
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cache_glob(media_id: str) -> Optional[Path]:
|
|
78
|
+
if not is_valid_media_id(media_id):
|
|
79
|
+
return None
|
|
80
|
+
for p in MEDIA_CACHE_DIR.glob(f"{media_id}.*"):
|
|
81
|
+
if p.is_file():
|
|
82
|
+
return p
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cached_path(media_id: str) -> Optional[Path]:
|
|
87
|
+
return _cache_glob(media_id)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get(media_id: str) -> Optional[Asset]:
|
|
91
|
+
with _lock:
|
|
92
|
+
return _registry.get(media_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ingest_urls(urls: list[dict[str, Any]]) -> int:
|
|
96
|
+
"""Register Asset rows keyed by uuid media_id. Returns count touched."""
|
|
97
|
+
touched = 0
|
|
98
|
+
with _lock:
|
|
99
|
+
for entry in urls or []:
|
|
100
|
+
if not isinstance(entry, dict):
|
|
101
|
+
continue
|
|
102
|
+
media_id = entry.get("media_id") or entry.get("mediaId")
|
|
103
|
+
url = entry.get("url")
|
|
104
|
+
kind = entry.get("mediaType") or entry.get("kind") or "image"
|
|
105
|
+
if not isinstance(media_id, str) or not is_valid_media_id(media_id):
|
|
106
|
+
continue
|
|
107
|
+
if not _url_allowed(url):
|
|
108
|
+
logger.warning("media: skip non-allowed url for %s: %r", media_id, (url or "")[:80])
|
|
109
|
+
continue
|
|
110
|
+
row = _registry.get(media_id)
|
|
111
|
+
if row is None:
|
|
112
|
+
_registry[media_id] = Asset(media_id=media_id, url=url, kind=kind)
|
|
113
|
+
else:
|
|
114
|
+
row.url = url
|
|
115
|
+
if not row.kind:
|
|
116
|
+
row.kind = kind
|
|
117
|
+
touched += 1
|
|
118
|
+
if touched:
|
|
119
|
+
logger.info("media: ingested %d url(s)", touched)
|
|
120
|
+
return touched
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ingest_inline_bytes(
|
|
124
|
+
media_id: str, data: bytes, *, kind: str = "video", mime: str = "video/mp4"
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Cache pre-fetched media bytes (workflow-mode base64 MP4) and mark the
|
|
127
|
+
Asset as locally available."""
|
|
128
|
+
if not is_valid_media_id(media_id) or not data:
|
|
129
|
+
return False
|
|
130
|
+
ext = _EXT_BY_MIME.get(mime, ".mp4")
|
|
131
|
+
path = MEDIA_CACHE_DIR / f"{media_id}{ext}"
|
|
132
|
+
try:
|
|
133
|
+
path.write_bytes(data)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
logger.error("failed to write inline cache %s: %s", path, exc)
|
|
136
|
+
return False
|
|
137
|
+
with _lock:
|
|
138
|
+
row = _registry.get(media_id) or Asset(media_id=media_id, kind=kind)
|
|
139
|
+
row.local_path = str(path)
|
|
140
|
+
row.mime = mime
|
|
141
|
+
if not row.kind:
|
|
142
|
+
row.kind = kind
|
|
143
|
+
_registry[media_id] = row
|
|
144
|
+
logger.info("media: ingested %d inline bytes for %s", len(data), media_id)
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def fetch_and_cache(media_id: str) -> Optional[tuple[bytes, str, Path]]:
|
|
149
|
+
"""If the Asset has a URL and no cached file, fetch bytes, cache, return
|
|
150
|
+
``(bytes, mime, path)``; else ``None``."""
|
|
151
|
+
if not is_valid_media_id(media_id):
|
|
152
|
+
return None
|
|
153
|
+
row = _get(media_id)
|
|
154
|
+
if row is None or not row.url:
|
|
155
|
+
return None
|
|
156
|
+
url = row.url
|
|
157
|
+
if not _url_allowed(url):
|
|
158
|
+
logger.warning("refusing to fetch non-allowed URL: %s", url[:60])
|
|
159
|
+
return None
|
|
160
|
+
try:
|
|
161
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
162
|
+
resp = await client.get(url)
|
|
163
|
+
except Exception as exc: # noqa: BLE001
|
|
164
|
+
logger.warning("GCS fetch failed for %s: %s", media_id, exc)
|
|
165
|
+
return None
|
|
166
|
+
if resp.status_code != 200:
|
|
167
|
+
logger.warning("GCS fetch %s returned %d", media_id, resp.status_code)
|
|
168
|
+
return None
|
|
169
|
+
mime = resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
|
|
170
|
+
if not (mime.startswith("image/") or mime.startswith("video/")):
|
|
171
|
+
logger.warning("refusing non-media mime from GCS: %s (media_id=%s)", mime, media_id)
|
|
172
|
+
return None
|
|
173
|
+
ext = _EXT_BY_MIME.get(mime, ".bin")
|
|
174
|
+
path = MEDIA_CACHE_DIR / f"{media_id}{ext}"
|
|
175
|
+
try:
|
|
176
|
+
path.write_bytes(resp.content)
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
logger.error("failed to write cache %s: %s", path, exc)
|
|
179
|
+
return None
|
|
180
|
+
with _lock:
|
|
181
|
+
r = _registry.get(media_id)
|
|
182
|
+
if r is not None:
|
|
183
|
+
r.local_path = str(path)
|
|
184
|
+
r.mime = mime
|
|
185
|
+
return resp.content, mime, path
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def ensure_cached(media_id: str) -> Optional[Path]:
|
|
189
|
+
"""Return a local file path for the media, fetching from the signed URL
|
|
190
|
+
on first access. ``None`` if unknown or the fetch fails."""
|
|
191
|
+
media_id = normalize_media_id(media_id)
|
|
192
|
+
cached = _cache_glob(media_id)
|
|
193
|
+
if cached is not None:
|
|
194
|
+
return cached
|
|
195
|
+
got = await fetch_and_cache(media_id)
|
|
196
|
+
return got[2] if got else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def status(media_id: str) -> dict:
|
|
200
|
+
media_id = normalize_media_id(media_id)
|
|
201
|
+
if not is_valid_media_id(media_id):
|
|
202
|
+
return {"available": False, "has_url": False, "reason": "invalid_id"}
|
|
203
|
+
cached = _cache_glob(media_id)
|
|
204
|
+
if cached is not None:
|
|
205
|
+
return {"available": True, "has_url": True, "mime": _mime_from_ext(cached.suffix)}
|
|
206
|
+
row = _get(media_id)
|
|
207
|
+
if row is None:
|
|
208
|
+
return {"available": False, "has_url": False, "reason": "unknown_media"}
|
|
209
|
+
if row.url:
|
|
210
|
+
return {"available": False, "has_url": True, "reason": "not_cached_yet"}
|
|
211
|
+
return {"available": False, "has_url": False, "reason": "no_url_yet"}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def media_mime(media_id: str) -> str:
|
|
215
|
+
media_id = normalize_media_id(media_id)
|
|
216
|
+
cached = _cache_glob(media_id)
|
|
217
|
+
if cached is not None:
|
|
218
|
+
return _mime_from_ext(cached.suffix)
|
|
219
|
+
row = _get(media_id)
|
|
220
|
+
return (row.mime if row and row.mime else "application/octet-stream")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _mime_from_ext(ext: str) -> str:
|
|
224
|
+
for mime, e in _EXT_BY_MIME.items():
|
|
225
|
+
if e == ext:
|
|
226
|
+
return mime
|
|
227
|
+
return "application/octet-stream"
|