@omg-dev/media 0.4.24
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.
- package/dist/index.mjs +91 -0
- package/dist/react.mjs +53 -0
- package/package.json +37 -0
- package/src/index.ts +167 -0
- package/src/react.ts +92 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
const endUserStore = new AsyncLocalStorage();
|
|
4
|
+
/**
|
|
5
|
+
* Run `fn` with the given end-user id in scope. Any generation submitted
|
|
6
|
+
* (transitively) inside `fn` stamps `X-OMG-User: <userId>` on the proxy call
|
|
7
|
+
* unless an explicit `endUser` option overrides it. Pass a falsy id to opt out.
|
|
8
|
+
*/
|
|
9
|
+
function runWithEndUser(userId, fn) {
|
|
10
|
+
if (!userId) return fn();
|
|
11
|
+
return endUserStore.run(userId, fn);
|
|
12
|
+
}
|
|
13
|
+
/** The end-user id currently in scope, or undefined. */
|
|
14
|
+
function getEndUser() {
|
|
15
|
+
return endUserStore.getStore();
|
|
16
|
+
}
|
|
17
|
+
function mediaBase() {
|
|
18
|
+
return process.env.OMG_MEDIA_URL || "http://localhost:9090/media";
|
|
19
|
+
}
|
|
20
|
+
function attributedHeaders(explicit) {
|
|
21
|
+
const h = { "Content-Type": "application/json" };
|
|
22
|
+
const user = explicit ?? endUserStore.getStore();
|
|
23
|
+
if (user) h["X-OMG-User"] = user;
|
|
24
|
+
return h;
|
|
25
|
+
}
|
|
26
|
+
/** Enqueue a generation. Returns immediately with a queued job. */
|
|
27
|
+
async function submit(opts) {
|
|
28
|
+
const res = await fetch(`${mediaBase()}/submit`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: attributedHeaders(opts.endUser),
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
provider: opts.provider,
|
|
33
|
+
model: opts.model,
|
|
34
|
+
input: opts.input ?? {}
|
|
35
|
+
})
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) throw new Error(`media submit failed: ${res.status} ${await res.text()}`);
|
|
38
|
+
return await res.json();
|
|
39
|
+
}
|
|
40
|
+
/** Fetch the current state of a job. */
|
|
41
|
+
async function getJob(jobId) {
|
|
42
|
+
const res = await fetch(`${mediaBase()}/jobs/${encodeURIComponent(jobId)}`);
|
|
43
|
+
if (!res.ok) throw new Error(`media getJob failed: ${res.status} ${await res.text()}`);
|
|
44
|
+
return await res.json();
|
|
45
|
+
}
|
|
46
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
/** Submit then poll until the job reaches a terminal state (or times out). */
|
|
48
|
+
async function generate(opts) {
|
|
49
|
+
const interval = opts.pollIntervalMs ?? 1500;
|
|
50
|
+
const timeout = opts.timeoutMs ?? 3e5;
|
|
51
|
+
const endUser = opts.endUser ?? endUserStore.getStore();
|
|
52
|
+
const job = await submit({
|
|
53
|
+
...opts,
|
|
54
|
+
endUser
|
|
55
|
+
});
|
|
56
|
+
const deadline = Date.now() + timeout;
|
|
57
|
+
let current = job;
|
|
58
|
+
while (current.status === "queued" || current.status === "running") {
|
|
59
|
+
if (Date.now() > deadline) throw new Error(`media generate timed out after ${timeout}ms (job ${job.jobId})`);
|
|
60
|
+
await sleep(interval);
|
|
61
|
+
current = await getJob(job.jobId);
|
|
62
|
+
}
|
|
63
|
+
if (current.status === "failed") throw new Error(`media generate failed: ${current.error || "unknown error"}`);
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
/** Generate one image and return its first result URL plus the full job. */
|
|
67
|
+
async function generateImage(opts) {
|
|
68
|
+
const job = await generate(opts);
|
|
69
|
+
const url = job.results?.[0]?.url;
|
|
70
|
+
if (!url) throw new Error(`media generateImage: job ${job.jobId} produced no result`);
|
|
71
|
+
return {
|
|
72
|
+
url,
|
|
73
|
+
job
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Generate one video and return its first result URL plus the full job. */
|
|
77
|
+
async function generateVideo(opts) {
|
|
78
|
+
const job = await generate({
|
|
79
|
+
timeoutMs: 6e5,
|
|
80
|
+
pollIntervalMs: 3e3,
|
|
81
|
+
...opts
|
|
82
|
+
});
|
|
83
|
+
const url = job.results?.[0]?.url;
|
|
84
|
+
if (!url) throw new Error(`media generateVideo: job ${job.jobId} produced no result`);
|
|
85
|
+
return {
|
|
86
|
+
url,
|
|
87
|
+
job
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
export { generate, generateImage, generateVideo, getEndUser, getJob, runWithEndUser, submit };
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
//#region src/react.ts
|
|
3
|
+
/**
|
|
4
|
+
* Poll `pollUrl` (a route on your own app that proxies getJob) until the job
|
|
5
|
+
* is terminal. Pass `null` to stay idle (e.g. before a job exists). The route
|
|
6
|
+
* is expected to return a `Job` shape: `{ status, results?, error? }`.
|
|
7
|
+
*/
|
|
8
|
+
function useMediaJob(pollUrl, opts = {}) {
|
|
9
|
+
const interval = opts.intervalMs ?? 1500;
|
|
10
|
+
const [state, setState] = useState({
|
|
11
|
+
status: "idle",
|
|
12
|
+
results: []
|
|
13
|
+
});
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!pollUrl) {
|
|
16
|
+
setState({
|
|
17
|
+
status: "idle",
|
|
18
|
+
results: []
|
|
19
|
+
});
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
let timer;
|
|
24
|
+
const tick = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const job = await (await fetch(pollUrl)).json();
|
|
27
|
+
if (cancelled) return;
|
|
28
|
+
setState({
|
|
29
|
+
status: job.status,
|
|
30
|
+
results: job.results ?? [],
|
|
31
|
+
error: job.error,
|
|
32
|
+
job
|
|
33
|
+
});
|
|
34
|
+
if (job.status === "queued" || job.status === "running") timer = setTimeout(tick, interval);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (cancelled) return;
|
|
37
|
+
setState((s) => ({
|
|
38
|
+
...s,
|
|
39
|
+
error: String(e)
|
|
40
|
+
}));
|
|
41
|
+
timer = setTimeout(tick, interval);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
tick();
|
|
45
|
+
return () => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
};
|
|
49
|
+
}, [pollUrl, interval]);
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { useMediaJob };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omg-dev/media",
|
|
3
|
+
"version": "0.4.24",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./dist/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"./react": {
|
|
11
|
+
"types": "./src/react.ts",
|
|
12
|
+
"default": "./dist/react.mjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"react": "^18 || ^19"
|
|
17
|
+
},
|
|
18
|
+
"peerDependenciesMeta": {
|
|
19
|
+
"react": {
|
|
20
|
+
"optional": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/BennyKok/vibes.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://docs.omg.dev",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @omg-dev/media — zero-config media generation (image / video / audio) for
|
|
2
|
+
// omg-deployed apps. Server-only entry.
|
|
3
|
+
//
|
|
4
|
+
// What this gives you:
|
|
5
|
+
// - `submit` / `getJob` — the raw async job API: enqueue a generation on a
|
|
6
|
+
// curated model, then poll for the result. Video can take minutes, so the
|
|
7
|
+
// model is fundamentally async (submit returns a jobId immediately).
|
|
8
|
+
// - `generateImage` / `generateVideo` — sugar that submits then polls to
|
|
9
|
+
// completion, so the simple case reads synchronously.
|
|
10
|
+
// - `runWithEndUser(userId, fn)` — wraps a handler so generations inside are
|
|
11
|
+
// attributed to the end-user (`X-OMG-User`), same pattern as @omg-dev/ai.
|
|
12
|
+
//
|
|
13
|
+
// No baseURL or key from your code: the omg infra orchestrator injects
|
|
14
|
+
// `OMG_MEDIA_URL` into the sandbox, the host-side media proxy holds the
|
|
15
|
+
// provider key, bills your omg credits (pass-through, no margin), and uploads
|
|
16
|
+
// the result to a durable CDN. You just pick a model and pass params.
|
|
17
|
+
//
|
|
18
|
+
// import { generateImage } from "@omg-dev/media"
|
|
19
|
+
// const { url } = await generateImage({
|
|
20
|
+
// model: "wavespeed-ai/flux-dev",
|
|
21
|
+
// input: { prompt: "a corgi astronaut, studio lighting" },
|
|
22
|
+
// })
|
|
23
|
+
|
|
24
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
25
|
+
|
|
26
|
+
// ── End-user attribution ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const endUserStore = new AsyncLocalStorage<string>()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `fn` with the given end-user id in scope. Any generation submitted
|
|
32
|
+
* (transitively) inside `fn` stamps `X-OMG-User: <userId>` on the proxy call
|
|
33
|
+
* unless an explicit `endUser` option overrides it. Pass a falsy id to opt out.
|
|
34
|
+
*/
|
|
35
|
+
export function runWithEndUser<T>(
|
|
36
|
+
userId: string | undefined | null,
|
|
37
|
+
fn: () => T | Promise<T>,
|
|
38
|
+
): T | Promise<T> {
|
|
39
|
+
if (!userId) return fn()
|
|
40
|
+
return endUserStore.run(userId, fn)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The end-user id currently in scope, or undefined. */
|
|
44
|
+
export function getEndUser(): string | undefined {
|
|
45
|
+
return endUserStore.getStore()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export type JobStatus = "queued" | "running" | "succeeded" | "failed"
|
|
51
|
+
|
|
52
|
+
export interface ResultRef {
|
|
53
|
+
url: string
|
|
54
|
+
contentType?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Job {
|
|
58
|
+
jobId: string
|
|
59
|
+
status: JobStatus
|
|
60
|
+
model?: string
|
|
61
|
+
results?: ResultRef[]
|
|
62
|
+
error?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SubmitOptions {
|
|
66
|
+
/** Curated model id, e.g. "wavespeed-ai/flux-dev". */
|
|
67
|
+
model: string
|
|
68
|
+
/** Model-specific params (prompt, image, duration, num_images, …). */
|
|
69
|
+
input?: Record<string, unknown>
|
|
70
|
+
/** Provider override; inferred from the model when omitted. */
|
|
71
|
+
provider?: string
|
|
72
|
+
/** Attribution override; defaults to the runWithEndUser context. */
|
|
73
|
+
endUser?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface GenerateOptions extends SubmitOptions {
|
|
77
|
+
/** Poll interval in ms (default 1500). */
|
|
78
|
+
pollIntervalMs?: number
|
|
79
|
+
/** Give up after this many ms (default 300_000 = 5 min). */
|
|
80
|
+
timeoutMs?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Transport ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
// Base injected by the orchestrator. Falls back to a localhost guess so the
|
|
86
|
+
// package is importable outside a sandbox (calls will just fail to connect).
|
|
87
|
+
function mediaBase(): string {
|
|
88
|
+
return process.env.OMG_MEDIA_URL || "http://localhost:9090/media"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function attributedHeaders(explicit?: string): Record<string, string> {
|
|
92
|
+
const h: Record<string, string> = { "Content-Type": "application/json" }
|
|
93
|
+
const user = explicit ?? endUserStore.getStore()
|
|
94
|
+
if (user) h["X-OMG-User"] = user
|
|
95
|
+
return h
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Enqueue a generation. Returns immediately with a queued job. */
|
|
99
|
+
export async function submit(opts: SubmitOptions): Promise<Job> {
|
|
100
|
+
const res = await fetch(`${mediaBase()}/submit`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: attributedHeaders(opts.endUser),
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
provider: opts.provider,
|
|
105
|
+
model: opts.model,
|
|
106
|
+
input: opts.input ?? {},
|
|
107
|
+
}),
|
|
108
|
+
})
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
throw new Error(`media submit failed: ${res.status} ${await res.text()}`)
|
|
111
|
+
}
|
|
112
|
+
return (await res.json()) as Job
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Fetch the current state of a job. */
|
|
116
|
+
export async function getJob(jobId: string): Promise<Job> {
|
|
117
|
+
const res = await fetch(`${mediaBase()}/jobs/${encodeURIComponent(jobId)}`)
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
throw new Error(`media getJob failed: ${res.status} ${await res.text()}`)
|
|
120
|
+
}
|
|
121
|
+
return (await res.json()) as Job
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
125
|
+
|
|
126
|
+
/** Submit then poll until the job reaches a terminal state (or times out). */
|
|
127
|
+
export async function generate(opts: GenerateOptions): Promise<Job> {
|
|
128
|
+
const interval = opts.pollIntervalMs ?? 1500
|
|
129
|
+
const timeout = opts.timeoutMs ?? 300_000
|
|
130
|
+
// Capture attribution now so polling outside the ALS scope still attributes.
|
|
131
|
+
const endUser = opts.endUser ?? endUserStore.getStore()
|
|
132
|
+
const job = await submit({ ...opts, endUser })
|
|
133
|
+
const deadline = Date.now() + timeout
|
|
134
|
+
let current = job
|
|
135
|
+
while (current.status === "queued" || current.status === "running") {
|
|
136
|
+
if (Date.now() > deadline) {
|
|
137
|
+
throw new Error(`media generate timed out after ${timeout}ms (job ${job.jobId})`)
|
|
138
|
+
}
|
|
139
|
+
await sleep(interval)
|
|
140
|
+
current = await getJob(job.jobId)
|
|
141
|
+
}
|
|
142
|
+
if (current.status === "failed") {
|
|
143
|
+
throw new Error(`media generate failed: ${current.error || "unknown error"}`)
|
|
144
|
+
}
|
|
145
|
+
return current
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Generate one image and return its first result URL plus the full job. */
|
|
149
|
+
export async function generateImage(
|
|
150
|
+
opts: GenerateOptions,
|
|
151
|
+
): Promise<{ url: string; job: Job }> {
|
|
152
|
+
const job = await generate(opts)
|
|
153
|
+
const url = job.results?.[0]?.url
|
|
154
|
+
if (!url) throw new Error(`media generateImage: job ${job.jobId} produced no result`)
|
|
155
|
+
return { url, job }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Generate one video and return its first result URL plus the full job. */
|
|
159
|
+
export async function generateVideo(
|
|
160
|
+
opts: GenerateOptions,
|
|
161
|
+
): Promise<{ url: string; job: Job }> {
|
|
162
|
+
// Video is slower; default the timeout up if the caller didn't set one.
|
|
163
|
+
const job = await generate({ timeoutMs: 600_000, pollIntervalMs: 3000, ...opts })
|
|
164
|
+
const url = job.results?.[0]?.url
|
|
165
|
+
if (!url) throw new Error(`media generateVideo: job ${job.jobId} produced no result`)
|
|
166
|
+
return { url, job }
|
|
167
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Browser-safe entry — a polling hook for media jobs.
|
|
2
|
+
//
|
|
3
|
+
// `@omg-dev/media` (the root entry) is server-only: it imports
|
|
4
|
+
// `node:async_hooks` and talks to the in-VM media proxy (OMG_MEDIA_URL), which
|
|
5
|
+
// is not reachable from the browser. So the client flow is:
|
|
6
|
+
//
|
|
7
|
+
// 1. Your `functions/api/<name>.ts` route calls `submit()` (server) and
|
|
8
|
+
// returns the `{ jobId }` to the browser.
|
|
9
|
+
// 2. The browser hook below polls YOUR route (which calls `getJob()` server
|
|
10
|
+
// side) until the job is terminal, then hands you the result URLs.
|
|
11
|
+
//
|
|
12
|
+
// import { useMediaJob } from "@omg-dev/media/react"
|
|
13
|
+
// const { status, results } = useMediaJob(jobId ? `/api/media-status?id=${jobId}` : null)
|
|
14
|
+
|
|
15
|
+
import { useEffect, useState } from "react"
|
|
16
|
+
|
|
17
|
+
export type JobStatus = "queued" | "running" | "succeeded" | "failed"
|
|
18
|
+
|
|
19
|
+
export interface ResultRef {
|
|
20
|
+
url: string
|
|
21
|
+
contentType?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Job {
|
|
25
|
+
jobId: string
|
|
26
|
+
status: JobStatus
|
|
27
|
+
results?: ResultRef[]
|
|
28
|
+
error?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseMediaJobOptions {
|
|
32
|
+
/** Poll interval in ms (default 1500). */
|
|
33
|
+
intervalMs?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseMediaJobState {
|
|
37
|
+
status: JobStatus | "idle"
|
|
38
|
+
results: ResultRef[]
|
|
39
|
+
error?: string
|
|
40
|
+
job?: Job
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Poll `pollUrl` (a route on your own app that proxies getJob) until the job
|
|
45
|
+
* is terminal. Pass `null` to stay idle (e.g. before a job exists). The route
|
|
46
|
+
* is expected to return a `Job` shape: `{ status, results?, error? }`.
|
|
47
|
+
*/
|
|
48
|
+
export function useMediaJob(
|
|
49
|
+
pollUrl: string | null,
|
|
50
|
+
opts: UseMediaJobOptions = {},
|
|
51
|
+
): UseMediaJobState {
|
|
52
|
+
const interval = opts.intervalMs ?? 1500
|
|
53
|
+
const [state, setState] = useState<UseMediaJobState>({ status: "idle", results: [] })
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!pollUrl) {
|
|
57
|
+
setState({ status: "idle", results: [] })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
let cancelled = false
|
|
61
|
+
let timer: ReturnType<typeof setTimeout>
|
|
62
|
+
|
|
63
|
+
const tick = async () => {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(pollUrl)
|
|
66
|
+
const job = (await res.json()) as Job
|
|
67
|
+
if (cancelled) return
|
|
68
|
+
setState({
|
|
69
|
+
status: job.status,
|
|
70
|
+
results: job.results ?? [],
|
|
71
|
+
error: job.error,
|
|
72
|
+
job,
|
|
73
|
+
})
|
|
74
|
+
if (job.status === "queued" || job.status === "running") {
|
|
75
|
+
timer = setTimeout(tick, interval)
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (cancelled) return
|
|
79
|
+
setState((s) => ({ ...s, error: String(e) }))
|
|
80
|
+
timer = setTimeout(tick, interval)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
tick()
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}, [pollUrl, interval])
|
|
90
|
+
|
|
91
|
+
return state
|
|
92
|
+
}
|