@portalshq/capability-queue-broadcast 0.1.2
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/README.md +131 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +312 -0
- package/dist/generated/api.d.ts +500 -0
- package/dist/generated/api.js +246 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/monitoring/metrics.d.ts +90 -0
- package/dist/monitoring/metrics.js +190 -0
- package/dist/streaming/frame-buffer.d.ts +34 -0
- package/dist/streaming/frame-buffer.js +61 -0
- package/dist/streaming/index.d.ts +2 -0
- package/dist/streaming/index.js +2 -0
- package/dist/streaming/rtmp/rtmp-streamer.d.ts +75 -0
- package/dist/streaming/rtmp/rtmp-streamer.js +299 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# `@portalshq/capability-queue-broadcast`
|
|
2
|
+
|
|
3
|
+
Server-only producer API for one isolated Queue Broadcast Server endpoint.
|
|
4
|
+
|
|
5
|
+
The queue is deliberately separate from playback: a trusted application backend
|
|
6
|
+
uses this package to enqueue and observe jobs; its browser player gets only the
|
|
7
|
+
public HLS manifest returned by `getPlayback()`. Use
|
|
8
|
+
`@portalshq/capability-video-delivery`'s `HlsPlaybackSession` in a player or
|
|
9
|
+
another stream-delivery integration.
|
|
10
|
+
|
|
11
|
+
## Backend flow
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { QueueBroadcastClient } from "@portalshq/capability-queue-broadcast";
|
|
15
|
+
|
|
16
|
+
const broadcast = new QueueBroadcastClient({
|
|
17
|
+
endpoint: process.env.STREAMER_ENDPOINT!, // no token/query/fragment
|
|
18
|
+
token: process.env.STREAMER_API_TOKEN!, // backend secret only
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const slotKey = `${generationRunId}:turn:1`;
|
|
22
|
+
const image = await broadcast.stageUpload({
|
|
23
|
+
mediaType: "image",
|
|
24
|
+
asset: { data: finishedImageBlob, filename: "scene.jpg", sha256: imageSha256 },
|
|
25
|
+
imageDuration: 18,
|
|
26
|
+
idempotencyKey: `${slotKey}:image`,
|
|
27
|
+
slotKey,
|
|
28
|
+
});
|
|
29
|
+
await broadcast.stageUpload({
|
|
30
|
+
mediaType: "audio",
|
|
31
|
+
asset: { data: finishedAudioBlob, filename: "narration.wav", sha256: audioSha256 },
|
|
32
|
+
idempotencyKey: `${slotKey}:audio`,
|
|
33
|
+
slotKey,
|
|
34
|
+
});
|
|
35
|
+
await broadcast.releaseSlot(slotKey);
|
|
36
|
+
|
|
37
|
+
for await (const update of broadcast.watchJob(image.id)) {
|
|
38
|
+
if (update.status === "failed") throw new Error(update.error ?? "queue job failed");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Pass this descriptor to the application player; do not send `token` to it.
|
|
42
|
+
const playback = await broadcast.getPlayback();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`enqueueUpload` is the preferred distributed ingestion path. A trusted backend
|
|
46
|
+
sends each completed `image`, `audio`, or `video` asset over authenticated
|
|
47
|
+
multipart HTTP to its separately running Queue Broadcast Server. The server
|
|
48
|
+
owns the bytes after receipt, checks the supplied SHA-256, and plays eligible
|
|
49
|
+
items in FIFO order. Standalone audio is valid for capability consumers; an
|
|
50
|
+
application may choose to discard it by policy.
|
|
51
|
+
|
|
52
|
+
For a multi-asset generation turn or scheduled pre-roll, call `stageUpload` for
|
|
53
|
+
each successful asset with the same `slotKey`, then call `releaseSlot(slotKey)`.
|
|
54
|
+
Release is idempotent and atomically makes all staged items FIFO-eligible, so a
|
|
55
|
+
partially generated turn cannot air early. Adjacent image/audio items can then
|
|
56
|
+
be composited by the Streamer; image-only and video items play independently.
|
|
57
|
+
The `QueueUploadAsset` SHA-256 field lets the server verify bytes without an
|
|
58
|
+
extra producer-side copy. Identical idempotency retries return the original job
|
|
59
|
+
receipt; conflicting reuse maps to `QueueBroadcastError` status `409`.
|
|
60
|
+
|
|
61
|
+
`enqueuePairUpload` and `stagePair` remain supported for legacy producers, but
|
|
62
|
+
new applications should use individual uploads and slots.
|
|
63
|
+
|
|
64
|
+
`enqueueUrl` remains for backwards-compatible server-to-server URL ingestion.
|
|
65
|
+
It is not the recommended path for applications that already have the finished
|
|
66
|
+
media bytes.
|
|
67
|
+
|
|
68
|
+
## Availability preflight
|
|
69
|
+
|
|
70
|
+
Run this immediately before costly generation, archive recovery, or a direct
|
|
71
|
+
upload. `health` confirms that the Streamer's media service is ready;
|
|
72
|
+
`getPlayback` is authenticated and therefore also detects a bad control URL or
|
|
73
|
+
queue bearer token. Both accept an `AbortSignal`, so an operator stop or process
|
|
74
|
+
shutdown can cancel a pending availability check promptly.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const health = await broadcast.health({ signal: controller.signal });
|
|
79
|
+
if (!health.ok) throw new Error("Streamer media service is unavailable");
|
|
80
|
+
|
|
81
|
+
const playback = await broadcast.getPlayback({ signal: controller.signal });
|
|
82
|
+
// Generate or upload only after both probes have succeeded.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Do not treat a queue-capacity response from a later upload as a preflight
|
|
86
|
+
failure: it means the service is reachable but has applied normal backpressure.
|
|
87
|
+
|
|
88
|
+
## `massively-social-ebook` example
|
|
89
|
+
|
|
90
|
+
Its generation worker owns a per-channel `STREAMER_ENDPOINT` and secret. It
|
|
91
|
+
generates image and narration independently in memory, computes SHA-256,
|
|
92
|
+
archives canonical media independently, then stages the successful image and
|
|
93
|
+
optional narration under one slot before releasing it. If narration fails, it
|
|
94
|
+
releases the image alone; if the image fails, it discards lone narration. The chapter page obtains
|
|
95
|
+
`getPlayback()` from its backend and gives the resulting HLS manifest to its
|
|
96
|
+
existing HLS-capable player. The page never calls queue endpoints or receives
|
|
97
|
+
the bearer token.
|
|
98
|
+
|
|
99
|
+
For chat from another broadcast platform, pass normalized provider events to
|
|
100
|
+
`ExternalChatIngress` in `@portalshq/capability-realtime-fanout` with the same
|
|
101
|
+
endpoint. That produces the `chat:<normalized endpoint>` topic; platform OAuth,
|
|
102
|
+
webhooks, storage, and relays remain outside this capability.
|
|
103
|
+
|
|
104
|
+
## Generated API
|
|
105
|
+
|
|
106
|
+
`src/generated/api.ts` is generated from the Streamer repository's checked-in
|
|
107
|
+
`openapi.json` contract using Orval:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm run generate -w @portalshq/capability-queue-broadcast
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Commit generated changes with the matching Streamer OpenAPI artifact. The
|
|
114
|
+
hand-written `QueueBroadcastClient` owns authentication, error mapping,
|
|
115
|
+
endpoint validation, and polling semantics.
|
|
116
|
+
|
|
117
|
+
## RTMP frame delivery
|
|
118
|
+
|
|
119
|
+
`RTMPStreamer` accepts Base64-encoded JPEG frames (a `data:image/jpeg;base64,`
|
|
120
|
+
prefix is also accepted) and pipes decoded JPEGs to FFmpeg. Set `ffmpegPath`
|
|
121
|
+
when the executable is not available as `ffmpeg` on `PATH`. The streamer owns
|
|
122
|
+
one RTMP destination, bounded buffering, process shutdown, and health status;
|
|
123
|
+
the consuming application owns generation and starts a separate instance for
|
|
124
|
+
each destination.
|
|
125
|
+
|
|
126
|
+
`GenerationContext.textOverlay` configures FFmpeg's `drawtext` filter. Pass the
|
|
127
|
+
same configuration to `RTMPStreamer` and set `audioDurationSeconds` from the
|
|
128
|
+
generated audio: the overlay is visible for the audio duration plus 1.5 seconds
|
|
129
|
+
at both the head and tail. With no audio it remains visible for five seconds.
|
|
130
|
+
The deployment FFmpeg build must include the `drawtext` filter (libfreetype);
|
|
131
|
+
set `fontFile` when it has no usable default font.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { HlsPlaybackSession } from "@portalshq/capability-video-delivery";
|
|
2
|
+
import type { ChatMessage } from "@portalshq/capability-realtime-fanout";
|
|
3
|
+
import type { HealthResponse, JobResponse, JobResponseMediaType, JobResponseStatus, QueuePairResponse, QueueSlotResponse } from "./generated/api.js";
|
|
4
|
+
export type QueueMediaType = JobResponseMediaType;
|
|
5
|
+
export type QueueJobStatus = JobResponseStatus;
|
|
6
|
+
export type QueueBroadcastJob = JobResponse;
|
|
7
|
+
export type QueueBroadcastHealth = HealthResponse;
|
|
8
|
+
export type QueueBroadcastPair = QueuePairResponse;
|
|
9
|
+
export type QueueBroadcastSlot = QueueSlotResponse;
|
|
10
|
+
export interface QueueBroadcastClientOptions {
|
|
11
|
+
/** The control-plane URL for one isolated Queue Broadcast Server instance. */
|
|
12
|
+
endpoint: string | URL;
|
|
13
|
+
/** Server-only bearer token. Never expose this value to a browser. */
|
|
14
|
+
token: string;
|
|
15
|
+
/** Injection point for tests or a server runtime with a custom fetch implementation. */
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** Optional longer timeout policy for multipart media upload requests. */
|
|
18
|
+
uploadFetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
export interface EnqueueUrlInput {
|
|
21
|
+
mediaType: QueueMediaType;
|
|
22
|
+
/** A completed, permitted media URL. Signed query strings are allowed here. */
|
|
23
|
+
url: string;
|
|
24
|
+
imageDuration?: number;
|
|
25
|
+
/** Required so producer retries are safe. */
|
|
26
|
+
idempotencyKey: string;
|
|
27
|
+
}
|
|
28
|
+
export interface WatchJobOptions {
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
onUpdate?: (job: QueueBroadcastJob) => void | Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/** A finished file held by the trusted producer process. */
|
|
34
|
+
export interface QueueUploadAsset {
|
|
35
|
+
/** Native Blob keeps the multipart request streamable across processes. */
|
|
36
|
+
data: Blob;
|
|
37
|
+
/** Metadata only; the streamer validates the actual bytes with sha256. */
|
|
38
|
+
filename: string;
|
|
39
|
+
/** SHA-256 of `data`, as lowercase hexadecimal. */
|
|
40
|
+
sha256: string;
|
|
41
|
+
}
|
|
42
|
+
export interface EnqueuePairUploadInput {
|
|
43
|
+
image: QueueUploadAsset;
|
|
44
|
+
audio: QueueUploadAsset;
|
|
45
|
+
/** The image's real composite duration, normally the narration duration. */
|
|
46
|
+
imageDuration: number;
|
|
47
|
+
/** Required, stable producer key. Retries return the original pair. */
|
|
48
|
+
idempotencyKey: string;
|
|
49
|
+
/** Persist the pair, but hold it from playout until `releasePair`. */
|
|
50
|
+
staged?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** One independently playable finished asset uploaded to the Streamer. */
|
|
53
|
+
export interface EnqueueUploadInput {
|
|
54
|
+
mediaType: QueueMediaType;
|
|
55
|
+
asset: QueueUploadAsset;
|
|
56
|
+
/** Optional image duration. Omit it to use the Streamer's configured default. */
|
|
57
|
+
imageDuration?: number;
|
|
58
|
+
/** Required, stable producer key. Retries return the original job. */
|
|
59
|
+
idempotencyKey: string;
|
|
60
|
+
/** Hold this item until its slot is atomically released. */
|
|
61
|
+
staged?: boolean;
|
|
62
|
+
/** Required when `staged` is true; identifies the producer's logical turn. */
|
|
63
|
+
slotKey?: string;
|
|
64
|
+
}
|
|
65
|
+
export type StageUploadInput = Omit<EnqueueUploadInput, "staged" | "slotKey"> & {
|
|
66
|
+
slotKey: string;
|
|
67
|
+
};
|
|
68
|
+
/** Text rendered by the streamer's FFmpeg `drawtext` filter. */
|
|
69
|
+
export interface TextOverlayConfig {
|
|
70
|
+
text: string;
|
|
71
|
+
position?: "top" | "center" | "bottom";
|
|
72
|
+
fontSize?: number;
|
|
73
|
+
fontColor?: string;
|
|
74
|
+
/** Optional absolute path for deployments that do not have a default font. */
|
|
75
|
+
fontFile?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface GenerationContext {
|
|
78
|
+
/** Base64 encoded last frame from previous generation for visual continuity */
|
|
79
|
+
previousFrame?: string;
|
|
80
|
+
/** History of prompts used in previous generations */
|
|
81
|
+
previousPrompts: string[];
|
|
82
|
+
/** Chat messages available for context (if chat integration enabled) */
|
|
83
|
+
chatMessages?: ChatMessage[];
|
|
84
|
+
/** Optional text overlay to render in the RTMP stream. */
|
|
85
|
+
textOverlay?: TextOverlayConfig;
|
|
86
|
+
/** Generation parameters for the current request */
|
|
87
|
+
generationParams: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
export interface GeneratedFrames {
|
|
90
|
+
/** Base64 encoded video frames */
|
|
91
|
+
frames: string[];
|
|
92
|
+
/** Optional audio data in PCM format */
|
|
93
|
+
audio?: ArrayBuffer;
|
|
94
|
+
/** Duration of the attached audio, used to time a text overlay. */
|
|
95
|
+
audioDurationSeconds?: number;
|
|
96
|
+
/** Duration of the generated content in seconds */
|
|
97
|
+
duration: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Callback interface for real-time video generation.
|
|
101
|
+
* The consuming application provides the AI implementation while queue-broadcast
|
|
102
|
+
* handles streaming infrastructure, frame buffering, and RTMP output.
|
|
103
|
+
*/
|
|
104
|
+
export interface GenerationCallback {
|
|
105
|
+
(context: GenerationContext): Promise<GeneratedFrames>;
|
|
106
|
+
}
|
|
107
|
+
/** Error returned by the queue control plane, including its HTTP status. */
|
|
108
|
+
export declare class QueueBroadcastError extends Error {
|
|
109
|
+
readonly status: number;
|
|
110
|
+
readonly detail?: string | undefined;
|
|
111
|
+
constructor(message: string, status: number, detail?: string | undefined);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Canonicalize a Queue Broadcast Server endpoint before it becomes a queue or
|
|
115
|
+
* chat namespace. Credentials, query parameters, and fragments are not part
|
|
116
|
+
* of an endpoint identity and could otherwise leak secret or routing data.
|
|
117
|
+
*/
|
|
118
|
+
export declare function normalizeBroadcastEndpoint(endpoint: string | URL): string;
|
|
119
|
+
/**
|
|
120
|
+
* Server-only producer client. It controls one broadcast endpoint while
|
|
121
|
+
* browser clients receive only `getPlayback().playbackManifestUrl`.
|
|
122
|
+
*/
|
|
123
|
+
export declare class QueueBroadcastClient {
|
|
124
|
+
private readonly endpoint;
|
|
125
|
+
private readonly token;
|
|
126
|
+
private readonly requestFetch;
|
|
127
|
+
private readonly uploadRequestFetch;
|
|
128
|
+
constructor(options: QueueBroadcastClientOptions);
|
|
129
|
+
enqueueUrl(input: EnqueueUrlInput): Promise<QueueBroadcastJob>;
|
|
130
|
+
/**
|
|
131
|
+
* Upload one finished image, audio, or video asset. The Streamer owns its
|
|
132
|
+
* bytes and schedules it in FIFO order; adjacent image/audio jobs may be
|
|
133
|
+
* composited there when both are ready.
|
|
134
|
+
*/
|
|
135
|
+
enqueueUpload(input: EnqueueUploadInput): Promise<QueueBroadcastJob>;
|
|
136
|
+
/** Persist one item under a turn slot; use `releaseSlot` after all successes. */
|
|
137
|
+
stageUpload(input: StageUploadInput): Promise<QueueBroadcastJob>;
|
|
138
|
+
/**
|
|
139
|
+
* Send finished media bytes directly to the remote Streamer process.
|
|
140
|
+
* This is the preferred ingestion path: the streamer does not download the
|
|
141
|
+
* asset from an application-owned URL, and it only exposes both jobs after
|
|
142
|
+
* its durable pair receipt has committed.
|
|
143
|
+
*/
|
|
144
|
+
enqueuePairUpload(input: EnqueuePairUploadInput): Promise<QueueBroadcastPair>;
|
|
145
|
+
/** Persist a canonical pair during pre-roll, then activate it later. */
|
|
146
|
+
stagePair(input: Omit<EnqueuePairUploadInput, "staged">): Promise<QueueBroadcastPair>;
|
|
147
|
+
getPair(pairId: string, options?: {
|
|
148
|
+
signal?: AbortSignal;
|
|
149
|
+
}): Promise<QueueBroadcastPair>;
|
|
150
|
+
/** Idempotently make a previously staged pair eligible for FIFO playout. */
|
|
151
|
+
releasePair(pairId: string, options?: {
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
}): Promise<QueueBroadcastPair>;
|
|
154
|
+
getSlot(slotKey: string, options?: {
|
|
155
|
+
signal?: AbortSignal;
|
|
156
|
+
}): Promise<QueueBroadcastSlot>;
|
|
157
|
+
/** Idempotently make every staged item in a logical turn FIFO-eligible. */
|
|
158
|
+
releaseSlot(slotKey: string, options?: {
|
|
159
|
+
signal?: AbortSignal;
|
|
160
|
+
}): Promise<QueueBroadcastSlot>;
|
|
161
|
+
getJob(jobId: string, options?: {
|
|
162
|
+
signal?: AbortSignal;
|
|
163
|
+
}): Promise<QueueBroadcastJob>;
|
|
164
|
+
/** Watch a job without retaining a subscription or exposing queue tokens to a browser. */
|
|
165
|
+
watchJob(jobId: string, options?: WatchJobOptions): AsyncGenerator<QueueBroadcastJob>;
|
|
166
|
+
getPlayback(options?: {
|
|
167
|
+
signal?: AbortSignal;
|
|
168
|
+
}): Promise<HlsPlaybackSession>;
|
|
169
|
+
health(options?: {
|
|
170
|
+
signal?: AbortSignal;
|
|
171
|
+
}): Promise<QueueBroadcastHealth>;
|
|
172
|
+
private request;
|
|
173
|
+
private requestMultipart;
|
|
174
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/** Error returned by the queue control plane, including its HTTP status. */
|
|
2
|
+
export class QueueBroadcastError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
detail;
|
|
5
|
+
constructor(message, status, detail) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.detail = detail;
|
|
9
|
+
this.name = "QueueBroadcastError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Canonicalize a Queue Broadcast Server endpoint before it becomes a queue or
|
|
14
|
+
* chat namespace. Credentials, query parameters, and fragments are not part
|
|
15
|
+
* of an endpoint identity and could otherwise leak secret or routing data.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeBroadcastEndpoint(endpoint) {
|
|
18
|
+
const url = new URL(endpoint.toString());
|
|
19
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
20
|
+
throw new TypeError("Queue Broadcast endpoint must use http or https");
|
|
21
|
+
}
|
|
22
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
23
|
+
throw new TypeError("Queue Broadcast endpoint cannot contain credentials, query parameters, or a fragment");
|
|
24
|
+
}
|
|
25
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
26
|
+
return url.toString().replace(/\/$/, "");
|
|
27
|
+
}
|
|
28
|
+
function validateSourceUrl(value) {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
31
|
+
throw new TypeError("media url must use http or https");
|
|
32
|
+
}
|
|
33
|
+
if (url.username || url.password || url.hash) {
|
|
34
|
+
throw new TypeError("media url cannot contain credentials or a fragment");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isTerminal(status) {
|
|
38
|
+
return status === "done" || status === "failed";
|
|
39
|
+
}
|
|
40
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
41
|
+
function validateUploadAsset(asset, field) {
|
|
42
|
+
if (!(asset.data instanceof Blob) || asset.data.size <= 0) {
|
|
43
|
+
throw new TypeError(`${field}.data must be a non-empty Blob`);
|
|
44
|
+
}
|
|
45
|
+
if (!asset.filename.trim())
|
|
46
|
+
throw new TypeError(`${field}.filename is required`);
|
|
47
|
+
if (!SHA256_HEX.test(asset.sha256)) {
|
|
48
|
+
throw new TypeError(`${field}.sha256 must be a lowercase SHA-256 hex digest`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function validateImageDuration(mediaType, imageDuration) {
|
|
52
|
+
if (mediaType === "image") {
|
|
53
|
+
if (imageDuration !== undefined && (!Number.isFinite(imageDuration) || imageDuration < 1 || imageDuration > 30)) {
|
|
54
|
+
throw new TypeError("imageDuration must be a finite number between 1 and 30 seconds");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (imageDuration !== undefined) {
|
|
58
|
+
throw new TypeError("imageDuration is only valid for image media");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function validateSlotKey(slotKey, staged) {
|
|
62
|
+
const normalized = slotKey?.trim();
|
|
63
|
+
if (staged && !normalized)
|
|
64
|
+
throw new TypeError("slotKey is required for staged uploads");
|
|
65
|
+
if (!staged && normalized)
|
|
66
|
+
throw new TypeError("slotKey is only valid for staged uploads");
|
|
67
|
+
if (normalized && normalized.length > 255)
|
|
68
|
+
throw new TypeError("slotKey must be at most 255 characters");
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
function waitForPoll(intervalMs, signal) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
if (signal?.aborted) {
|
|
74
|
+
resolve();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const onAbort = () => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
signal?.removeEventListener("abort", onAbort);
|
|
83
|
+
resolve();
|
|
84
|
+
}, intervalMs);
|
|
85
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Server-only producer client. It controls one broadcast endpoint while
|
|
90
|
+
* browser clients receive only `getPlayback().playbackManifestUrl`.
|
|
91
|
+
*/
|
|
92
|
+
export class QueueBroadcastClient {
|
|
93
|
+
endpoint;
|
|
94
|
+
token;
|
|
95
|
+
requestFetch;
|
|
96
|
+
uploadRequestFetch;
|
|
97
|
+
constructor(options) {
|
|
98
|
+
this.endpoint = normalizeBroadcastEndpoint(options.endpoint);
|
|
99
|
+
if (!options.token.trim()) {
|
|
100
|
+
throw new TypeError("Queue Broadcast bearer token is required");
|
|
101
|
+
}
|
|
102
|
+
this.token = options.token;
|
|
103
|
+
this.requestFetch = options.fetch ?? fetch;
|
|
104
|
+
this.uploadRequestFetch = options.uploadFetch ?? this.requestFetch;
|
|
105
|
+
}
|
|
106
|
+
async enqueueUrl(input) {
|
|
107
|
+
validateSourceUrl(input.url);
|
|
108
|
+
const idempotencyKey = input.idempotencyKey.trim();
|
|
109
|
+
if (!idempotencyKey) {
|
|
110
|
+
throw new TypeError("idempotencyKey is required");
|
|
111
|
+
}
|
|
112
|
+
validateImageDuration(input.mediaType, input.imageDuration);
|
|
113
|
+
return this.request("/v1/queue", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
media_type: input.mediaType,
|
|
118
|
+
url: input.url,
|
|
119
|
+
...(input.imageDuration === undefined ? {} : { duration: input.imageDuration }),
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Upload one finished image, audio, or video asset. The Streamer owns its
|
|
125
|
+
* bytes and schedules it in FIFO order; adjacent image/audio jobs may be
|
|
126
|
+
* composited there when both are ready.
|
|
127
|
+
*/
|
|
128
|
+
async enqueueUpload(input) {
|
|
129
|
+
validateUploadAsset(input.asset, input.mediaType);
|
|
130
|
+
validateImageDuration(input.mediaType, input.imageDuration);
|
|
131
|
+
const idempotencyKey = input.idempotencyKey.trim();
|
|
132
|
+
if (!idempotencyKey)
|
|
133
|
+
throw new TypeError("idempotencyKey is required");
|
|
134
|
+
const slotKey = validateSlotKey(input.slotKey, input.staged);
|
|
135
|
+
const form = new FormData();
|
|
136
|
+
form.append("file", input.asset.data, input.asset.filename);
|
|
137
|
+
form.append("media_type", input.mediaType);
|
|
138
|
+
form.append("sha256", input.asset.sha256);
|
|
139
|
+
if (input.imageDuration !== undefined)
|
|
140
|
+
form.append("duration", String(input.imageDuration));
|
|
141
|
+
if (input.staged)
|
|
142
|
+
form.append("staged", "true");
|
|
143
|
+
if (slotKey)
|
|
144
|
+
form.append("slot_key", slotKey);
|
|
145
|
+
return this.requestMultipart("/v1/queue/upload", form, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** Persist one item under a turn slot; use `releaseSlot` after all successes. */
|
|
151
|
+
async stageUpload(input) {
|
|
152
|
+
return this.enqueueUpload({ ...input, staged: true });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Send finished media bytes directly to the remote Streamer process.
|
|
156
|
+
* This is the preferred ingestion path: the streamer does not download the
|
|
157
|
+
* asset from an application-owned URL, and it only exposes both jobs after
|
|
158
|
+
* its durable pair receipt has committed.
|
|
159
|
+
*/
|
|
160
|
+
async enqueuePairUpload(input) {
|
|
161
|
+
validateUploadAsset(input.image, "image");
|
|
162
|
+
validateUploadAsset(input.audio, "audio");
|
|
163
|
+
const idempotencyKey = input.idempotencyKey.trim();
|
|
164
|
+
if (!idempotencyKey)
|
|
165
|
+
throw new TypeError("idempotencyKey is required");
|
|
166
|
+
if (!Number.isFinite(input.imageDuration) || input.imageDuration < 1 || input.imageDuration > 30) {
|
|
167
|
+
throw new TypeError("imageDuration must be a finite number between 1 and 30 seconds");
|
|
168
|
+
}
|
|
169
|
+
const form = new FormData();
|
|
170
|
+
form.append("image", input.image.data, input.image.filename);
|
|
171
|
+
form.append("audio", input.audio.data, input.audio.filename);
|
|
172
|
+
form.append("image_duration", String(input.imageDuration));
|
|
173
|
+
form.append("image_sha256", input.image.sha256);
|
|
174
|
+
form.append("audio_sha256", input.audio.sha256);
|
|
175
|
+
if (input.staged)
|
|
176
|
+
form.append("staged", "true");
|
|
177
|
+
return this.requestMultipart("/v1/queue/pairs/upload", form, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Persist a canonical pair during pre-roll, then activate it later. */
|
|
183
|
+
async stagePair(input) {
|
|
184
|
+
return this.enqueuePairUpload({ ...input, staged: true });
|
|
185
|
+
}
|
|
186
|
+
async getPair(pairId, options = {}) {
|
|
187
|
+
if (!pairId.trim())
|
|
188
|
+
throw new TypeError("pairId is required");
|
|
189
|
+
return this.request(`/v1/queue/pairs/${encodeURIComponent(pairId)}`, options);
|
|
190
|
+
}
|
|
191
|
+
/** Idempotently make a previously staged pair eligible for FIFO playout. */
|
|
192
|
+
async releasePair(pairId, options = {}) {
|
|
193
|
+
if (!pairId.trim())
|
|
194
|
+
throw new TypeError("pairId is required");
|
|
195
|
+
return this.request(`/v1/queue/pairs/${encodeURIComponent(pairId)}/release`, {
|
|
196
|
+
...options,
|
|
197
|
+
method: "POST",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async getSlot(slotKey, options = {}) {
|
|
201
|
+
const normalized = validateSlotKey(slotKey, true);
|
|
202
|
+
return this.request(`/v1/queue/slots/${encodeURIComponent(normalized)}`, options);
|
|
203
|
+
}
|
|
204
|
+
/** Idempotently make every staged item in a logical turn FIFO-eligible. */
|
|
205
|
+
async releaseSlot(slotKey, options = {}) {
|
|
206
|
+
const normalized = validateSlotKey(slotKey, true);
|
|
207
|
+
return this.request(`/v1/queue/slots/${encodeURIComponent(normalized)}/release`, {
|
|
208
|
+
...options,
|
|
209
|
+
method: "POST",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async getJob(jobId, options = {}) {
|
|
213
|
+
if (!jobId.trim())
|
|
214
|
+
throw new TypeError("jobId is required");
|
|
215
|
+
return this.request(`/v1/queue/${encodeURIComponent(jobId)}`, options);
|
|
216
|
+
}
|
|
217
|
+
/** Watch a job without retaining a subscription or exposing queue tokens to a browser. */
|
|
218
|
+
async *watchJob(jobId, options = {}) {
|
|
219
|
+
const intervalMs = Math.max(50, Math.floor(options.intervalMs ?? 1_000));
|
|
220
|
+
let previousRevision;
|
|
221
|
+
while (!options.signal?.aborted) {
|
|
222
|
+
let job;
|
|
223
|
+
try {
|
|
224
|
+
job = await this.getJob(jobId, { signal: options.signal });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (options.signal?.aborted)
|
|
228
|
+
return;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
const revision = `${job.status}:${job.updated_at}:${job.error ?? ""}`;
|
|
232
|
+
if (revision !== previousRevision) {
|
|
233
|
+
previousRevision = revision;
|
|
234
|
+
await options.onUpdate?.(job);
|
|
235
|
+
yield job;
|
|
236
|
+
}
|
|
237
|
+
if (isTerminal(job.status) || options.signal?.aborted)
|
|
238
|
+
return;
|
|
239
|
+
await waitForPoll(intervalMs, options.signal);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async getPlayback(options = {}) {
|
|
243
|
+
const stream = await this.request("/v1/stream", options);
|
|
244
|
+
const hls = new URL(stream.hls);
|
|
245
|
+
if (hls.protocol !== "http:" && hls.protocol !== "https:") {
|
|
246
|
+
throw new QueueBroadcastError("queue server returned a non-http HLS URL", 502);
|
|
247
|
+
}
|
|
248
|
+
return { sessionId: this.endpoint, playbackManifestUrl: hls.toString() };
|
|
249
|
+
}
|
|
250
|
+
async health(options = {}) {
|
|
251
|
+
return this.request("/health", options, false);
|
|
252
|
+
}
|
|
253
|
+
async request(path, init = {}, authenticated = true) {
|
|
254
|
+
const headers = new Headers(init.headers);
|
|
255
|
+
headers.set("Accept", "application/json");
|
|
256
|
+
if (init.body !== undefined && !headers.has("Content-Type")) {
|
|
257
|
+
headers.set("Content-Type", "application/json");
|
|
258
|
+
}
|
|
259
|
+
if (authenticated)
|
|
260
|
+
headers.set("Authorization", `Bearer ${this.token}`);
|
|
261
|
+
let response;
|
|
262
|
+
try {
|
|
263
|
+
response = await this.requestFetch(`${this.endpoint}${path}`, { ...init, headers });
|
|
264
|
+
}
|
|
265
|
+
catch (cause) {
|
|
266
|
+
throw new QueueBroadcastError(`Queue Broadcast request failed: ${cause instanceof Error ? cause.message : String(cause)}`, 0);
|
|
267
|
+
}
|
|
268
|
+
const text = await response.text();
|
|
269
|
+
let data;
|
|
270
|
+
try {
|
|
271
|
+
data = text ? JSON.parse(text) : undefined;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
data = undefined;
|
|
275
|
+
}
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
const detail = typeof data === "object" && data !== null && "detail" in data && typeof data.detail === "string"
|
|
278
|
+
? data.detail
|
|
279
|
+
: text || response.statusText;
|
|
280
|
+
throw new QueueBroadcastError(`Queue Broadcast request failed (${response.status}): ${detail}`, response.status, detail);
|
|
281
|
+
}
|
|
282
|
+
return data;
|
|
283
|
+
}
|
|
284
|
+
async requestMultipart(path, body, init) {
|
|
285
|
+
const headers = new Headers(init.headers);
|
|
286
|
+
headers.set("Accept", "application/json");
|
|
287
|
+
headers.set("Authorization", `Bearer ${this.token}`);
|
|
288
|
+
// Do not set Content-Type: fetch supplies the multipart boundary.
|
|
289
|
+
let response;
|
|
290
|
+
try {
|
|
291
|
+
response = await this.uploadRequestFetch(`${this.endpoint}${path}`, { ...init, headers, body });
|
|
292
|
+
}
|
|
293
|
+
catch (cause) {
|
|
294
|
+
throw new QueueBroadcastError(`Queue Broadcast request failed: ${cause instanceof Error ? cause.message : String(cause)}`, 0);
|
|
295
|
+
}
|
|
296
|
+
const text = await response.text();
|
|
297
|
+
let data;
|
|
298
|
+
try {
|
|
299
|
+
data = text ? JSON.parse(text) : undefined;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
data = undefined;
|
|
303
|
+
}
|
|
304
|
+
if (!response.ok) {
|
|
305
|
+
const detail = typeof data === "object" && data !== null && "detail" in data && typeof data.detail === "string"
|
|
306
|
+
? data.detail
|
|
307
|
+
: text || response.statusText;
|
|
308
|
+
throw new QueueBroadcastError(`Queue Broadcast request failed (${response.status}): ${detail}`, response.status, detail);
|
|
309
|
+
}
|
|
310
|
+
return data;
|
|
311
|
+
}
|
|
312
|
+
}
|