Skip to content

Commit 38a8c2e

Browse files
committed
Bridge reference-to-video requests to Python providers
1 parent 2157049 commit 38a8c2e

10 files changed

Lines changed: 374 additions & 9 deletions

packages/protocol/src/bridge-protocol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
* 3. Update `MIN_NODETOOL_CORE_VERSION` to that new release.
4747
*/
4848

49-
export const BRIDGE_PROTOCOL_VERSION = 5;
49+
export const BRIDGE_PROTOCOL_VERSION = 6;
5050

5151
/**
5252
* Hard floor: the JS runtime rejects (at `discover`) any worker reporting a

packages/protocol/tests/error-and-protocol-coverage.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe("error-helpers.isTRPCErrorWithCode", () => {
7979

8080
describe("bridge-protocol constants", () => {
8181
it("BRIDGE_PROTOCOL_VERSION is the current speaking version", () => {
82-
expect(BRIDGE_PROTOCOL_VERSION).toBe(5);
82+
expect(BRIDGE_PROTOCOL_VERSION).toBe(6);
8383
});
8484

8585
it("MIN_BRIDGE_PROTOCOL_VERSION is the hard floor at 1", () => {

packages/runtime/src/providers/python-provider.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import type {
2525
TextToSpeechParams,
2626
TextToVideoParams,
2727
ImageToVideoParams,
28+
ReferenceToVideoInputs,
29+
ReferenceToVideoParams,
2830
TextToMusicParams,
2931
EncodedAudioResult
3032
} from "./types.js";
@@ -79,6 +81,7 @@ export class PythonProvider extends BaseProvider {
7981
private _secrets: Record<string, string>;
8082
private _supportsStreamingTTS = true;
8183
private _supportsEncodedTTS = true;
84+
private _workerCapabilities = new Set<string>();
8285

8386
constructor(
8487
providerId: string,
@@ -99,19 +102,26 @@ export class PythonProvider extends BaseProvider {
99102
this._bridge = bridge;
100103
this._pythonProviderId = providerIdOrOptions;
101104
this._secrets = secrets;
105+
this.referenceToVideo = BaseProvider.prototype.referenceToVideo;
102106
return;
103107
}
104108

105109
const { _id, _bridge, _bridgeProviderId, _capabilities, ...rawSecrets } =
106110
providerIdOrOptions;
107111
super(_id);
112+
const wrappedReferenceToVideo = this.referenceToVideo;
108113
this._bridge = _bridge;
109114
this._pythonProviderId = _bridgeProviderId ?? _id;
115+
this.referenceToVideo = BaseProvider.prototype.referenceToVideo;
110116
if (Array.isArray(_capabilities)) {
117+
this._workerCapabilities = new Set(_capabilities.map(String));
111118
this._supportsStreamingTTS = _capabilities.includes("text_to_speech");
112119
this._supportsEncodedTTS = _capabilities.includes(
113120
"text_to_speech_encoded"
114121
);
122+
if (this._workerCapabilities.has("reference_to_video")) {
123+
this.referenceToVideo = wrappedReferenceToVideo;
124+
}
115125
}
116126
this._secrets = Object.fromEntries(
117127
Object.entries(rawSecrets).filter(
@@ -187,10 +197,25 @@ export class PythonProvider extends BaseProvider {
187197
// The public provider id may be an alias (notably `huggingface-local`) so
188198
// selections route back through this bridge adapter instead of colliding
189199
// with a built-in remote provider that uses the worker's original id.
190-
return models.map((model) => ({
191-
...model,
192-
provider: this.provider
193-
}));
200+
return models.map((model) => {
201+
const supportedTasks = Array.isArray(model.supportedTasks)
202+
? model.supportedTasks.map(String)
203+
: Array.isArray(model.supported_tasks)
204+
? model.supported_tasks.map(String)
205+
: undefined;
206+
const normalizedModel: Record<string, unknown> = {
207+
...model,
208+
provider: this.provider
209+
};
210+
if (modelType === "video" && supportedTasks) {
211+
normalizedModel.supportedTasks = this._workerCapabilities.has(
212+
"reference_to_video"
213+
)
214+
? supportedTasks
215+
: supportedTasks.filter((task) => task !== "reference_to_video");
216+
}
217+
return normalizedModel;
218+
});
194219
}
195220

196221
// ── Chat completion ───────────────────────────────────────────────
@@ -327,6 +352,47 @@ export class PythonProvider extends BaseProvider {
327352
);
328353
}
329354

355+
async referenceToVideo(
356+
inputs: ReferenceToVideoInputs,
357+
params: ReferenceToVideoParams
358+
): Promise<Uint8Array> {
359+
if (!this._workerCapabilities.has("reference_to_video")) {
360+
throw new Error("Python worker does not support reference_to_video");
361+
}
362+
if (
363+
Array.isArray(params.model.supportedTasks) &&
364+
!params.model.supportedTasks.includes("reference_to_video")
365+
) {
366+
throw new Error(
367+
`Video model ${params.model.id} does not support reference_to_video`
368+
);
369+
}
370+
if (!Array.isArray(params.model.supportedTasks)) {
371+
const models = await this._getModels("video");
372+
const model = models.find(
373+
(candidate) =>
374+
isRecord(candidate) && String(candidate.id ?? "") === params.model.id
375+
);
376+
if (
377+
!isRecord(model) ||
378+
!Array.isArray(model.supportedTasks) ||
379+
!model.supportedTasks.includes("reference_to_video")
380+
) {
381+
throw new Error(
382+
`Video model ${params.model.id} does not support reference_to_video`
383+
);
384+
}
385+
}
386+
const { signal, ...wireParams } = params;
387+
return this._bridge.providerReferenceToVideo(
388+
this._pythonProviderId,
389+
inputs,
390+
{ ...wireParams, model: params.model.id },
391+
this._secrets,
392+
signal
393+
);
394+
}
395+
330396
async *textToSpeech(
331397
args: TextToSpeechParams
332398
): AsyncGenerator<StreamingAudioChunk> {

packages/runtime/src/python-bridge-base.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ import type {
113113
BlenderEvent,
114114
BlenderExecuteOptions,
115115
BlenderExecuteResult,
116-
PythonBridge
116+
PythonBridge,
117+
ReferenceToVideoInputs
117118
} from "./python-bridge-types.js";
118119
import {
119120
comfyStatusInfoSchema,
@@ -1160,6 +1161,56 @@ export abstract class PythonBridgeBase
11601161
return result.blobs.video;
11611162
}
11621163

1164+
async providerReferenceToVideo(
1165+
providerId: string,
1166+
inputs: ReferenceToVideoInputs,
1167+
params: Record<string, unknown>,
1168+
secrets?: Record<string, string>,
1169+
signal?: AbortSignal
1170+
): Promise<Uint8Array> {
1171+
const { images, videos } = inputs;
1172+
if (
1173+
!Array.isArray(images) ||
1174+
!Array.isArray(videos) ||
1175+
(images.length === 0 && videos.length === 0)
1176+
) {
1177+
throw new Error(
1178+
"reference_to_video requires at least one reference image or video"
1179+
);
1180+
}
1181+
if (
1182+
[...images, ...videos].some((bytes) => !(bytes instanceof Uint8Array))
1183+
) {
1184+
throw new Error(
1185+
"reference_to_video reference media must be binary buffers"
1186+
);
1187+
}
1188+
if ([...images, ...videos].some((bytes) => bytes.byteLength === 0)) {
1189+
throw new Error("reference_to_video reference media must be non-empty");
1190+
}
1191+
const totalBytes = [...images, ...videos].reduce(
1192+
(total, bytes) => total + bytes.byteLength,
1193+
0
1194+
);
1195+
if (totalBytes > 192 * 1024 * 1024) {
1196+
throw new Error(
1197+
`reference_to_video reference media is ${totalBytes} bytes, exceeding the 201326592 byte limit`
1198+
);
1199+
}
1200+
const result = await this._providerBlobCall(
1201+
"provider.reference_to_video",
1202+
{
1203+
provider: providerId,
1204+
reference_images: images,
1205+
reference_videos: videos,
1206+
params,
1207+
secrets: secrets ?? {}
1208+
},
1209+
signal
1210+
);
1211+
return result.blobs.video;
1212+
}
1213+
11631214
async providerTextToAudio(
11641215
providerId: string,
11651216
params: Record<string, unknown>,

packages/runtime/src/python-bridge-types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { EventEmitter } from "node:events";
22
import { z } from "zod";
33

44
import type { ASRResult } from "./providers/types.js";
5+
import type { ReferenceToVideoInputs } from "./providers/types.js";
6+
export type { ReferenceToVideoInputs } from "./providers/types.js";
57

68
interface NodeMetadataProperty {
79
name: string;
@@ -590,6 +592,13 @@ export interface PythonBridge extends EventEmitter {
590592
secrets?: Record<string, string>,
591593
signal?: AbortSignal
592594
): Promise<Uint8Array>;
595+
providerReferenceToVideo(
596+
providerId: string,
597+
inputs: ReferenceToVideoInputs,
598+
params: Record<string, unknown>,
599+
secrets?: Record<string, string>,
600+
signal?: AbortSignal
601+
): Promise<Uint8Array>;
593602
providerTextToAudio(
594603
providerId: string,
595604
params: Record<string, unknown>,

packages/runtime/src/swappable-python-bridge.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,22 @@ export class SwappableBridge extends EventEmitter implements PythonBridge {
278278
);
279279
}
280280

281+
providerReferenceToVideo(
282+
providerId: string,
283+
inputs: import("./providers/types.js").ReferenceToVideoInputs,
284+
params: Record<string, unknown>,
285+
secrets?: Record<string, string>,
286+
signal?: AbortSignal
287+
): Promise<Uint8Array> {
288+
return this._target.providerReferenceToVideo(
289+
providerId,
290+
inputs,
291+
params,
292+
secrets,
293+
signal
294+
);
295+
}
296+
281297
providerTextToAudio(
282298
providerId: string,
283299
params: Record<string, unknown>,

packages/runtime/tests/providers/provider-registry-extended.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,110 @@ describe("provider-registry — extended coverage", () => {
214214
);
215215
});
216216

217+
it("gates reference video on worker capability and model support", async () => {
218+
const oldWorkerModels = vi.fn(async () => [
219+
{
220+
id: "misreported",
221+
name: "Misreported",
222+
supported_tasks: ["image_to_video", "reference_to_video"]
223+
}
224+
]);
225+
const oldWorker = new PythonProvider({
226+
_id: "wangp-old",
227+
_bridge: { getProviderModels: oldWorkerModels }
228+
} as any);
229+
expect(oldWorker.getCapabilities()).not.toContain("reference_to_video");
230+
await expect(oldWorker.getAvailableVideoModels()).resolves.toEqual([
231+
expect.objectContaining({
232+
supportedTasks: ["image_to_video"],
233+
provider: "wangp-old"
234+
})
235+
]);
236+
await expect(
237+
oldWorker.referenceToVideo(
238+
{ images: [new Uint8Array([1])], videos: [] },
239+
{
240+
model: {
241+
id: "misreported",
242+
name: "Misreported",
243+
provider: "wangp-old"
244+
}
245+
}
246+
)
247+
).rejects.toThrow("does not support referenceToVideo");
248+
249+
const referenceToVideo = vi.fn(async () => new Uint8Array([9]));
250+
const models = vi.fn(async () => [
251+
{
252+
id: "start-only",
253+
name: "Start only",
254+
supportedTasks: ["image_to_video"]
255+
},
256+
{
257+
id: "reference-model",
258+
name: "Reference",
259+
supportedTasks: ["reference_to_video"]
260+
}
261+
]);
262+
const worker = new PythonProvider({
263+
_id: "wangp",
264+
_capabilities: ["reference_to_video"],
265+
_bridge: {
266+
getProviderModels: models,
267+
providerReferenceToVideo: referenceToVideo
268+
},
269+
API_KEY: "secret"
270+
} as any);
271+
expect(worker.getCapabilities()).toContain("reference_to_video");
272+
await expect(
273+
worker.referenceToVideo(
274+
{
275+
images: [new Uint8Array([1]), new Uint8Array([2])],
276+
videos: [new Uint8Array([3])]
277+
},
278+
{
279+
model: { id: "start-only", name: "Start only", provider: "wangp" },
280+
prompt: "reject"
281+
}
282+
)
283+
).rejects.toThrow("start-only does not support reference_to_video");
284+
expect(referenceToVideo).not.toHaveBeenCalled();
285+
286+
const signal = new AbortController().signal;
287+
await expect(
288+
worker.referenceToVideo(
289+
{
290+
images: [new Uint8Array([1]), new Uint8Array([2])],
291+
videos: [new Uint8Array([3])]
292+
},
293+
{
294+
model: {
295+
id: "reference-model",
296+
name: "Reference",
297+
provider: "wangp"
298+
},
299+
prompt: "forward",
300+
useReferenceVideoAudio: true,
301+
signal
302+
}
303+
)
304+
).resolves.toEqual(new Uint8Array([9]));
305+
expect(referenceToVideo).toHaveBeenCalledWith(
306+
"wangp",
307+
{
308+
images: [new Uint8Array([1]), new Uint8Array([2])],
309+
videos: [new Uint8Array([3])]
310+
},
311+
{
312+
model: "reference-model",
313+
prompt: "forward",
314+
useReferenceVideoAudio: true
315+
},
316+
{ API_KEY: "secret" },
317+
signal
318+
);
319+
});
320+
217321
it("discovers music and routes encoded audio through the Python bridge", async () => {
218322
const wav = new Uint8Array([
219323
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45

0 commit comments

Comments
 (0)