Merge snake_case media tool inputs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:45:52 +00:00
commit 613cc2ba6a
No known key found for this signature in database
12 changed files with 80 additions and 72 deletions

View File

@ -206,6 +206,6 @@ test("renders a media job card while it runs and when it is done", async ({ page
botId, botId,
messageId: expect.stringMatching(/^media-message-\d+$/), messageId: expect.stringMatching(/^media-message-\d+$/),
operation: "transcribe", operation: "transcribe",
input: { artifactId: voice.id, title: "voice-note.wav" }, input: { artifact_id: voice.id, title: "voice-note.wav" },
}); });
}); });

View File

@ -378,7 +378,7 @@ export function MediaCard({
: // The new card is titled with the file it transcribes. : // The new card is titled with the file it transcribes.
{ {
operation: "transcribe", operation: "transcribe",
input: { artifactId: action.artifactId, title: action.name }, input: { artifact_id: action.artifactId, title: action.name },
}), }),
}); });
}; };

View File

@ -120,7 +120,7 @@ describePostgres("ComfyUI operations in the media runtime (PostgreSQL + ComfyUI
const edited = await runtime.start({ const edited = await runtime.start({
...scope, ...scope,
operation: "edit_image", operation: "edit_image",
input: { artifactId: source, resize: { aspect: "vertical", fit: "blur" }, format: "jpeg" }, input: { artifact_id: source, resize: { aspect: "vertical", fit: "blur" }, format: "jpeg" },
}); });
await runtime.runJob(edited.jobId); await runtime.runJob(edited.jobId);
const edit = await runtime.get(edited.jobId); const edit = await runtime.get(edited.jobId);
@ -145,14 +145,14 @@ describePostgres("ComfyUI operations in the media runtime (PostgreSQL + ComfyUI
runtime.start({ runtime.start({
...scope, ...scope,
operation: "generate_video", operation: "generate_video",
input: { prompt: "animate it", sourceArtifactId: foreign }, input: { prompt: "animate it", source_artifact_id: foreign },
}), }),
).rejects.toThrow(MediaJobRejectedError); ).rejects.toThrow(MediaJobRejectedError);
await expect( await expect(
runtime.start({ runtime.start({
...scope, ...scope,
operation: "edit_image", operation: "edit_image",
input: { artifactId: foreign, rotate: 90 }, input: { artifact_id: foreign, rotate: 90 },
}), }),
).rejects.toThrow(MediaJobRejectedError); ).rejects.toThrow(MediaJobRejectedError);
}); });

View File

@ -165,7 +165,7 @@ describe("image operations", () => {
const { outputs } = await run( const { outputs } = await run(
operation, operation,
"generate_image", "generate_image",
{ prompt: "night", sourceArtifactId: "photo-1" }, { prompt: "night", source_artifact_id: "photo-1" },
{ "photo-1": photo }, { "photo-1": photo },
); );
const [upload] = emulator.uploads.values(); const [upload] = emulator.uploads.values();
@ -173,7 +173,7 @@ describe("image operations", () => {
expect(outputs[0]).toMatchObject({ width: 1280, height: 832, parentArtifactId: "photo-1" }); expect(outputs[0]).toMatchObject({ width: 1280, height: 832, parentArtifactId: "photo-1" });
const clip = await fixture("clip.mp4", 8, 8); const clip = await fixture("clip.mp4", 8, 8);
await expect( await expect(
run(operation, "generate_image", { prompt: "x", sourceArtifactId: "c" }, { c: clip }), run(operation, "generate_image", { prompt: "x", source_artifact_id: "c" }, { c: clip }),
).rejects.toThrow("The source must be an image"); ).rejects.toThrow("The source must be an image");
}); });
@ -203,7 +203,7 @@ describe("image operations", () => {
operation, operation,
"edit_image", "edit_image",
{ {
artifactId: "photo-1", artifact_id: "photo-1",
crop: { left: 0, top: 0, width: 300, height: 300 }, crop: { left: 0, top: 0, width: 300, height: 300 },
rotate: 90, rotate: 90,
resize: { aspect: "vertical", fit: "blur" }, resize: { aspect: "vertical", fit: "blur" },
@ -226,12 +226,17 @@ describe("image operations", () => {
const titled = await run( const titled = await run(
operation, operation,
"edit_image", "edit_image",
{ artifactId: "photo-1", title: "Titelbild: Sommer", rotate: 180 }, { artifact_id: "photo-1", title: "Titelbild: Sommer", rotate: 180 },
{ "photo-1": photo }, { "photo-1": photo },
); );
expect(titled.outputs[0]?.name).toBe("Titelbild Sommer.jpg"); expect(titled.outputs[0]?.name).toBe("Titelbild Sommer.jpg");
await expect( await expect(
run(operation, "edit_image", { artifactId: "photo-1", prompt: "snow" }, { "photo-1": photo }), run(
operation,
"edit_image",
{ artifact_id: "photo-1", prompt: "snow" },
{ "photo-1": photo },
),
).rejects.toThrow("ComfyUI is not configured"); ).rejects.toThrow("ComfyUI is not configured");
}); });
@ -239,7 +244,7 @@ describe("image operations", () => {
const { operation, emulator } = setup(); const { operation, emulator } = setup();
const photo = await fixture("photo.png", 300, 200); const photo = await fixture("photo.png", 300, 200);
const input = { const input = {
artifactId: "photo-1", artifact_id: "photo-1",
crop: { left: 0, top: 0, width: 250, height: 200 }, crop: { left: 0, top: 0, width: 250, height: 200 },
prompt: "snow", prompt: "snow",
}; };
@ -268,7 +273,7 @@ describe("helpers", () => {
expect(fileStem("夜の海", "video")).toBe("video"); expect(fileStem("夜の海", "video")).toBe("video");
expect( expect(
editImageTitle({ editImageTitle({
artifactId: "a", artifact_id: "a",
crop: { left: 0, top: 0, width: 1, height: 1 }, crop: { left: 0, top: 0, width: 1, height: 1 },
rotate: 90, rotate: 90,
resize: { aspect: "vertical", fit: "crop" }, resize: { aspect: "vertical", fit: "crop" },
@ -277,15 +282,15 @@ describe("helpers", () => {
).toBe("1×1 · 90° · 9:16 · PNG"); ).toBe("1×1 · 90° · 9:16 · PNG");
expect( expect(
editImageTitle({ editImageTitle({
artifactId: "a", artifact_id: "a",
flip: "vertical", flip: "vertical",
extend: { top: 4, right: 0, bottom: 0, left: 0 }, extend: { top: 4, right: 0, bottom: 0, left: 0 },
}), }),
).toBe("↕"); ).toBe("↕");
expect( expect(
editImageTitle({ artifactId: "a", title: "Titelbild", prompt: "snow", rotate: 90 }), editImageTitle({ artifact_id: "a", title: "Titelbild", prompt: "snow", rotate: 90 }),
).toBe("Titelbild"); ).toBe("Titelbild");
expect(editImageTitle({ artifactId: "a", resize: { width: 800, fit: "crop" } })).toBe("800×…"); expect(editImageTitle({ artifact_id: "a", resize: { width: 800, fit: "crop" } })).toBe("800×…");
expect(editImageTitle({ artifactId: "a", prompt: "make it snow" })).toBe("make it snow"); expect(editImageTitle({ artifact_id: "a", prompt: "make it snow" })).toBe("make it snow");
}); });
}); });

View File

@ -162,8 +162,8 @@ async function generateImage(
): Promise<MediaOperationResult> { ): Promise<MediaOperationResult> {
const input = GenerateImageInputSchema.parse(context.input); const input = GenerateImageInputSchema.parse(context.input);
const provider = await requireProvider(providers); const provider = await requireProvider(providers);
const source = input.sourceArtifactId const source = input.source_artifact_id
? await fetchImage(context, input.sourceArtifactId) ? await fetchImage(context, input.source_artifact_id)
: undefined; : undefined;
const aspect = input.aspect const aspect = input.aspect
? presetAspect(input.aspect) ? presetAspect(input.aspect)
@ -177,7 +177,7 @@ async function generateImage(
const seed = input.seed ?? randomInt(2 ** 48 - 1); const seed = input.seed ?? randomInt(2 ** 48 - 1);
const images = await provider.generateImage({ const images = await provider.generateImage({
prompt: input.prompt, prompt: input.prompt,
negativePrompt: input.negativePrompt, negativePrompt: input.negative_prompt,
...size, ...size,
count: input.count, count: input.count,
seed, seed,
@ -200,8 +200,8 @@ async function generateVideo(
): Promise<MediaOperationResult> { ): Promise<MediaOperationResult> {
const input = GenerateVideoInputSchema.parse(context.input); const input = GenerateVideoInputSchema.parse(context.input);
const provider = await requireProvider(providers); const provider = await requireProvider(providers);
const source = input.sourceArtifactId const source = input.source_artifact_id
? await fetchImage(context, input.sourceArtifactId) ? await fetchImage(context, input.source_artifact_id)
: undefined; : undefined;
const aspect = input.aspect const aspect = input.aspect
? presetAspect(input.aspect) ? presetAspect(input.aspect)
@ -212,9 +212,9 @@ async function generateVideo(
const seed = input.seed ?? randomInt(2 ** 48 - 1); const seed = input.seed ?? randomInt(2 ** 48 - 1);
const clips = await provider.generateVideo({ const clips = await provider.generateVideo({
prompt: input.prompt, prompt: input.prompt,
negativePrompt: input.negativePrompt, negativePrompt: input.negative_prompt,
...size, ...size,
durationSeconds: input.durationSeconds, durationSeconds: input.duration_seconds,
seed, seed,
source: source && { source: source && {
bytes: await coverImage(source.path, size.width, size.height), bytes: await coverImage(source.path, size.width, size.height),
@ -224,7 +224,7 @@ async function generateVideo(
onProgress: reportProgress(context), onProgress: reportProgress(context),
}); });
await addGeneratedOutputs(context, clips, fileStem(input.prompt, "video"), source?.artifactId); await addGeneratedOutputs(context, clips, fileStem(input.prompt, "video"), source?.artifactId);
return { summary: `Generated a ${input.durationSeconds} s video with seed ${seed}.` }; return { summary: `Generated a ${input.duration_seconds} s video with seed ${seed}.` };
} }
function defaultFormat(mimeType: string): ImageFormat { function defaultFormat(mimeType: string): ImageFormat {
@ -238,7 +238,7 @@ async function editImage(
providers: ProviderSource, providers: ProviderSource,
): Promise<MediaOperationResult> { ): Promise<MediaOperationResult> {
const input = EditImageInputSchema.parse(context.input); const input = EditImageInputSchema.parse(context.input);
const source = await fetchImage(context, input.artifactId); const source = await fetchImage(context, input.artifact_id);
let png: Uint8Array = await editImageGeometry(source.path, input); let png: Uint8Array = await editImageGeometry(source.path, input);
if (input.prompt) { if (input.prompt) {
const provider = await requireProvider(providers); const provider = await requireProvider(providers);
@ -289,8 +289,8 @@ export function createImageOperations(deps: ImageOperationsDeps): MediaOperation
}; };
const comfyUiConfigured = async () => Boolean((await deps.loadSettings()).comfyui); const comfyUiConfigured = async () => Boolean((await deps.loadSettings()).comfyui);
const status = async (signal?: AbortSignal) => (await providers())?.gpuStatus(signal); const status = async (signal?: AbortSignal) => (await providers())?.gpuStatus(signal);
const sourceIds = (input: { sourceArtifactId?: string }) => const sourceIds = (input: { source_artifact_id?: string }) =>
input.sourceArtifactId ? [input.sourceArtifactId] : []; input.source_artifact_id ? [input.source_artifact_id] : [];
const image: MediaOperationRegistration<GenerateImageInput> = { const image: MediaOperationRegistration<GenerateImageInput> = {
operation: "generate_image", operation: "generate_image",
inputSchema: GenerateImageInputSchema, inputSchema: GenerateImageInputSchema,
@ -315,7 +315,7 @@ export function createImageOperations(deps: ImageOperationsDeps): MediaOperation
title: editImageTitle, title: editImageTitle,
available: sharpAvailable, available: sharpAvailable,
handler: { operation: "edit_image", run: (context) => editImage(context, providers) }, handler: { operation: "edit_image", run: (context) => editImage(context, providers) },
artifactIds: (input) => [input.artifactId], artifactIds: (input) => [input.artifact_id],
}; };
return [image, video, edit] as MediaOperationRegistration[]; return [image, video, edit] as MediaOperationRegistration[];
} }

View File

@ -130,7 +130,7 @@ describePostgres("transcribe through the media runtime (PostgreSQL)", () => {
it("turns an audio artifact into subtitle files on the media card", async () => { it("turns an audio artifact into subtitle files on the media card", async () => {
const ctx = await setup(); const ctx = await setup();
expect((await ctx.runtime.capabilities(ctx.id)).transcribe).toBe(true); expect((await ctx.runtime.capabilities(ctx.id)).transcribe).toBe(true);
const started = await ctx.start({ artifactId: ctx.source.id }); const started = await ctx.start({ artifact_id: ctx.source.id });
expect(await ctx.card(started.jobId)).toMatchObject({ expect(await ctx.card(started.jobId)).toMatchObject({
operation: "transcribe", operation: "transcribe",
status: "queued", status: "queued",
@ -163,7 +163,9 @@ describePostgres("transcribe through the media runtime (PostgreSQL)", () => {
it("refuses an artifact of another user before a job exists", async () => { it("refuses an artifact of another user before a job exists", async () => {
const owner = await setup(); const owner = await setup();
const other = await setup(); const other = await setup();
await expect(other.start({ artifactId: owner.source.id })).rejects.toThrow("Unknown artifact."); await expect(other.start({ artifact_id: owner.source.id })).rejects.toThrow(
"Unknown artifact.",
);
expect(await prisma.mediaJob.count({ where: { userId: other.id } })).toBe(0); expect(await prisma.mediaJob.count({ where: { userId: other.id } })).toBe(0);
}); });
}); });

View File

@ -198,18 +198,18 @@ describe("transcribe operation", () => {
}), }),
).toBe(false); ).toBe(false);
const { registration } = operation(); const { registration } = operation();
const input = registration.inputSchema.parse({ artifactId: "audio-1", language: "de" }); const input = registration.inputSchema.parse({ artifact_id: "audio-1", language: "de" });
expect(registration.artifactIds?.(input)).toEqual(["audio-1"]); expect(registration.artifactIds?.(input)).toEqual(["audio-1"]);
expect(registration.title(input)).toBe("Transcript"); expect(registration.title(input)).toBe("Transcript");
expect(registration.title({ ...input, title: "voice-note.wav" })).toBe("voice-note.wav"); expect(registration.title({ ...input, title: "voice-note.wav" })).toBe("voice-note.wav");
expect( expect(
registration.inputSchema.safeParse({ artifactId: "audio-1", language: "German!" }).success, registration.inputSchema.safeParse({ artifact_id: "audio-1", language: "German!" }).success,
).toBe(false); ).toBe(false);
}); });
it("turns an audio artifact into SRT and VTT files next to it", async () => { it("turns an audio artifact into SRT and VTT files next to it", async () => {
const { registration, server } = operation(); const { registration, server } = operation();
const job = await jobContext(audioSource, { artifactId: "audio-1", language: "de" }); const job = await jobContext(audioSource, { artifact_id: "audio-1", language: "de" });
const result = await registration.handler.run(job.context); const result = await registration.handler.run(job.context);
expect(server.requests).toEqual([ expect(server.requests).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -234,7 +234,7 @@ describe("transcribe operation", () => {
it("writes a text file when the server returns no timings", async () => { it("writes a text file when the server returns no timings", async () => {
const { registration } = operation(TRANSCRIPTION, { flavor: "text" }); const { registration } = operation(TRANSCRIPTION, { flavor: "text" });
const job = await jobContext(audioSource, { artifactId: "audio-1" }); const job = await jobContext(audioSource, { artifact_id: "audio-1" });
const result = await registration.handler.run(job.context); const result = await registration.handler.run(job.context);
expect(job.outputs.map((output) => [output.name, output.mimeType])).toEqual([ expect(job.outputs.map((output) => [output.name, output.mimeType])).toEqual([
["Interview take 2.txt", "text/plain"], ["Interview take 2.txt", "text/plain"],
@ -252,7 +252,7 @@ describe("transcribe operation", () => {
}; };
const { registration } = operation(); const { registration } = operation();
await expect( await expect(
registration.handler.run((await jobContext(video, { artifactId: "video-1" })).context), registration.handler.run((await jobContext(video, { artifact_id: "video-1" })).context),
).rejects.toThrow("Transcribing video needs ffmpeg on the server."); ).rejects.toThrow("Transcribing video needs ffmpeg on the server.");
const image = { const image = {
...audioSource, ...audioSource,
@ -261,12 +261,12 @@ describe("transcribe operation", () => {
kind: "image" as const, kind: "image" as const,
}; };
await expect( await expect(
registration.handler.run((await jobContext(image, { artifactId: "image-1" })).context), registration.handler.run((await jobContext(image, { artifact_id: "image-1" })).context),
).rejects.toThrow("Only audio and video can be transcribed."); ).rejects.toThrow("Only audio and video can be transcribed.");
const long = operation(TRANSCRIPTION, { maxBytes: 4 }); const long = operation(TRANSCRIPTION, { maxBytes: 4 });
await expect( await expect(
long.registration.handler.run( long.registration.handler.run(
(await jobContext(audioSource, { artifactId: "audio-1" })).context, (await jobContext(audioSource, { artifact_id: "audio-1" })).context,
), ),
).rejects.toThrow("too long to transcribe without ffmpeg"); ).rejects.toThrow("too long to transcribe without ffmpeg");
}); });
@ -294,7 +294,7 @@ describe("transcribe operation", () => {
mimeType: "video/mp4", mimeType: "video/mp4",
kind: "video" as const, kind: "video" as const,
}; };
const job = await jobContext(video, { artifactId: "video-1" }); const job = await jobContext(video, { artifact_id: "video-1" });
await registration.handler.run(job.context); await registration.handler.run(job.context);
expect(extracted).toEqual([ expect(extracted).toEqual([
{ startMs: 0, endMs: 62_000, mimeType: "video/mp4" }, { startMs: 0, endMs: 62_000, mimeType: "video/mp4" },
@ -312,7 +312,7 @@ describe("transcribe operation", () => {
it("waits for a transcription server that is down instead of failing", async () => { it("waits for a transcription server that is down instead of failing", async () => {
const { registration, server } = operation(); const { registration, server } = operation();
server.failNextStatus = 503; server.failNextStatus = 503;
const job = await jobContext(audioSource, { artifactId: "audio-1" }); const job = await jobContext(audioSource, { artifact_id: "audio-1" });
await expect(registration.handler.run(job.context)).rejects.toBeInstanceOf( await expect(registration.handler.run(job.context)).rejects.toBeInstanceOf(
MediaJobDeferredError, MediaJobDeferredError,
); );
@ -358,7 +358,7 @@ describe.skipIf(!ffmpeg)("transcribe with ffmpeg", () => {
it("transcribes a video's audio as 16 kHz mono WAV", async () => { it("transcribes a video's audio as 16 kHz mono WAV", async () => {
const { registration, server } = ffmpegOperation(); const { registration, server } = ffmpegOperation();
const job = await fakeMediaJobContext( const job = await fakeMediaJobContext(
{ artifactId: "video-1" }, { artifact_id: "video-1" },
{ "video-1": { path: clips.landscape, name: "landscape.mp4", mimeType: "video/mp4" } }, { "video-1": { path: clips.landscape, name: "landscape.mp4", mimeType: "video/mp4" } },
); );
dirs.push(job.tempDir); dirs.push(job.tempDir);
@ -374,7 +374,7 @@ describe.skipIf(!ffmpeg)("transcribe with ffmpeg", () => {
it("uploads a long recording in ranges below the server's limit", async () => { it("uploads a long recording in ranges below the server's limit", async () => {
const { registration, server } = ffmpegOperation(1024); const { registration, server } = ffmpegOperation(1024);
const job = await fakeMediaJobContext( const job = await fakeMediaJobContext(
{ artifactId: "audio-1" }, { artifact_id: "audio-1" },
{ "audio-1": { path: longTone, name: "long-tone.m4a", mimeType: "audio/mp4" } }, { "audio-1": { path: longTone, name: "long-tone.m4a", mimeType: "audio/mp4" } },
); );
dirs.push(job.tempDir); dirs.push(job.tempDir);
@ -387,7 +387,7 @@ describe.skipIf(!ffmpeg)("transcribe with ffmpeg", () => {
it("refuses a video without sound", async () => { it("refuses a video without sound", async () => {
const { registration, server } = ffmpegOperation(); const { registration, server } = ffmpegOperation();
const job = await fakeMediaJobContext( const job = await fakeMediaJobContext(
{ artifactId: "video-2" }, { artifact_id: "video-2" },
{ "video-2": { path: clips.silentPortrait, name: "silent.mp4", mimeType: "video/mp4" } }, { "video-2": { path: clips.silentPortrait, name: "silent.mp4", mimeType: "video/mp4" } },
); );
dirs.push(job.tempDir); dirs.push(job.tempDir);

View File

@ -148,7 +148,7 @@ export function createTranscriptionOperations(
inputSchema: TranscribeInputSchema, inputSchema: TranscribeInputSchema,
title: (input) => input.title ?? "Transcript", title: (input) => input.title ?? "Transcript",
available: async () => Boolean((await deps.loadSettings()).transcription), available: async () => Boolean((await deps.loadSettings()).transcription),
artifactIds: (input) => [input.artifactId], artifactIds: (input) => [input.artifact_id],
handler: { handler: {
operation: "transcribe", operation: "transcribe",
async run(context) { async run(context) {
@ -156,7 +156,7 @@ export function createTranscriptionOperations(
const settings = (await deps.loadSettings()).transcription; const settings = (await deps.loadSettings()).transcription;
if (!settings) throw new MediaJobRejectedError("Transcription is not set up."); if (!settings) throw new MediaJobRejectedError("Transcription is not set up.");
const provider = createProvider(settings); const provider = createProvider(settings);
const source = await context.fetchArtifact(input.artifactId); const source = await context.fetchArtifact(input.artifact_id);
if (source.kind !== "audio" && source.kind !== "video") { if (source.kind !== "audio" && source.kind !== "video") {
throw new MediaJobRejectedError("Only audio and video can be transcribed."); throw new MediaJobRejectedError("Only audio and video can be transcribed.");
} }

View File

@ -13,7 +13,7 @@ describe("media generation inputs", () => {
}); });
expect(GenerateVideoInputSchema.parse({ prompt: "waves" })).toEqual({ expect(GenerateVideoInputSchema.parse({ prompt: "waves" })).toEqual({
prompt: "waves", prompt: "waves",
durationSeconds: 5, duration_seconds: 5,
}); });
}); });
@ -23,20 +23,21 @@ describe("media generation inputs", () => {
expect( expect(
GenerateImageInputSchema.safeParse({ prompt: "x", width: 512, height: 4096 }).success, GenerateImageInputSchema.safeParse({ prompt: "x", width: 512, height: 4096 }).success,
).toBe(false); ).toBe(false);
expect(GenerateVideoInputSchema.safeParse({ prompt: "x", durationSeconds: 11 }).success).toBe( expect(GenerateVideoInputSchema.safeParse({ prompt: "x", duration_seconds: 11 }).success).toBe(
false, false,
); );
}); });
it("needs at least one edit and valid edit values", () => { it("needs at least one edit and valid edit values", () => {
expect(EditImageInputSchema.safeParse({ artifactId: "a" }).success).toBe(false); expect(EditImageInputSchema.safeParse({ artifact_id: "a" }).success).toBe(false);
expect(EditImageInputSchema.safeParse({ artifactId: "a", rotate: 45 }).success).toBe(false); expect(EditImageInputSchema.safeParse({ artifact_id: "a", rotate: 45 }).success).toBe(false);
expect(EditImageInputSchema.safeParse({ artifactId: "a", resize: {} }).success).toBe(false); expect(EditImageInputSchema.safeParse({ artifact_id: "a", resize: {} }).success).toBe(false);
expect( expect(
EditImageInputSchema.safeParse({ artifactId: "a", extend: { top: 4, color: "red" } }).success, EditImageInputSchema.safeParse({ artifact_id: "a", extend: { top: 4, color: "red" } })
.success,
).toBe(false); ).toBe(false);
expect( expect(
EditImageInputSchema.parse({ artifactId: "a", resize: { aspect: "vertical" }, rotate: 90 }), EditImageInputSchema.parse({ artifact_id: "a", resize: { aspect: "vertical" }, rotate: 90 }),
).toEqual({ artifactId: "a", resize: { aspect: "vertical", fit: "crop" }, rotate: 90 }); ).toEqual({ artifact_id: "a", resize: { aspect: "vertical", fit: "crop" }, rotate: 90 });
}); });
}); });

View File

@ -24,13 +24,13 @@ const Pixels = z.number().int().min(0).max(16_384);
export const GenerateImageInputSchema = z export const GenerateImageInputSchema = z
.object({ .object({
prompt: Prompt.describe("What the image shows."), prompt: Prompt.describe("What the image shows."),
negativePrompt: NegativePrompt, negative_prompt: NegativePrompt,
aspect: aspect("Default square, or the source image's aspect ratio.").optional(), aspect: aspect("Default square, or the source image's aspect ratio.").optional(),
width: Side.optional().describe("Exact width in pixels, with height; overrides aspect."), width: Side.optional().describe("Exact width in pixels, with height; overrides aspect."),
height: Side.optional(), height: Side.optional(),
count: z.number().int().min(1).max(MEDIA_IMAGE_MAX_COUNT).default(1), count: z.number().int().min(1).max(MEDIA_IMAGE_MAX_COUNT).default(1),
seed: Seed, seed: Seed,
sourceArtifactId: Id.optional().describe("Image artifact to start from (image-to-image)."), source_artifact_id: Id.optional().describe("Image artifact to start from (image-to-image)."),
strength: Strength.describe("With a source: how far to move away from it (default 0.6)."), strength: Strength.describe("With a source: how far to move away from it (default 0.6)."),
}) })
.refine((input) => (input.width === undefined) === (input.height === undefined), { .refine((input) => (input.width === undefined) === (input.height === undefined), {
@ -41,11 +41,11 @@ export type GenerateImageInput = z.infer<typeof GenerateImageInputSchema>;
export const GenerateVideoInputSchema = z.object({ export const GenerateVideoInputSchema = z.object({
prompt: Prompt.describe("What happens in the clip: subject, motion, camera."), prompt: Prompt.describe("What happens in the clip: subject, motion, camera."),
negativePrompt: NegativePrompt, negative_prompt: NegativePrompt,
aspect: aspect("Default landscape, or the start frame's aspect ratio.").optional(), aspect: aspect("Default landscape, or the start frame's aspect ratio.").optional(),
durationSeconds: z.number().min(1).max(MEDIA_VIDEO_MAX_SECONDS).default(5), duration_seconds: z.number().min(1).max(MEDIA_VIDEO_MAX_SECONDS).default(5),
seed: Seed, seed: Seed,
sourceArtifactId: Id.optional().describe("Image artifact to use as the first frame."), source_artifact_id: Id.optional().describe("Image artifact to use as the first frame."),
}); });
export type GenerateVideoInput = z.infer<typeof GenerateVideoInputSchema>; export type GenerateVideoInput = z.infer<typeof GenerateVideoInputSchema>;
@ -55,7 +55,7 @@ export type ImageFormat = z.infer<typeof ImageFormatSchema>;
/** Edits apply in this order: crop, rotate, flip, resize, extend, repaint, format. */ /** Edits apply in this order: crop, rotate, flip, resize, extend, repaint, format. */
export const EditImageInputSchema = z export const EditImageInputSchema = z
.object({ .object({
artifactId: Id.describe("Image artifact to edit."), artifact_id: Id.describe("Image artifact to edit."),
title: MediaTitleSchema.optional().describe( title: MediaTitleSchema.optional().describe(
"A few words in the user's language for the card and file name.", "A few words in the user's language for the card and file name.",
), ),

View File

@ -2,7 +2,7 @@ import * as z from "zod";
import { Id } from "./ids.js"; import { Id } from "./ids.js";
export const TranscribeInputSchema = z.object({ export const TranscribeInputSchema = z.object({
artifactId: Id.describe("Audio or video artifact to transcribe."), artifact_id: Id.describe("Audio or video artifact to transcribe."),
language: z language: z
.string() .string()
.trim() .trim()

View File

@ -5,7 +5,7 @@ import path from "node:path";
import type { MediaOperationRegistration } from "@rakazo/adapters"; import type { MediaOperationRegistration } from "@rakazo/adapters";
import { ComposioEmulator } from "@rakazo/adapters"; import { ComposioEmulator } from "@rakazo/adapters";
import type { MediaJobBlock, MediaSettingsView, MessageBlock } from "@rakazo/contracts"; import type { MediaJobBlock, MediaSettingsView, MessageBlock } from "@rakazo/contracts";
import { MediaOutputSchema } from "@rakazo/contracts"; import { TranscribeInputSchema } from "@rakazo/contracts";
import { createThreadMessage } from "@rakazo/db"; import { createThreadMessage } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts"; import type { createApp } from "../../../apps/api/src/app.ts";
@ -26,21 +26,21 @@ const PNG_BASE64 = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
); );
// testkit has no zod of its own; the contracts' schemas carry it. // testkit has no zod of its own; the contracts' schemas carry it.
const SourceInputSchema = MediaOutputSchema.pick({ artifactId: true }); const SourceInputSchema = TranscribeInputSchema.pick({ artifact_id: true });
/** Fake card operations: an export that copies its source, and a transcription that waits. */ /** Fake card operations: an export that copies its source, and a transcription that waits. */
function fakeOperations(): MediaOperationRegistration[] { function fakeOperations(): MediaOperationRegistration[] {
const exportVideo: MediaOperationRegistration<{ artifactId: string }> = { const exportVideo: MediaOperationRegistration<{ artifact_id: string }> = {
operation: "export_video", operation: "export_video",
inputSchema: SourceInputSchema, inputSchema: SourceInputSchema,
title: () => "Export", title: () => "Export",
available: async () => true, available: async () => true,
artifactIds: (input) => [input.artifactId], artifactIds: (input) => [input.artifact_id],
handler: { handler: {
operation: "export_video", operation: "export_video",
run: async (context) => { run: async (context) => {
const { artifactId } = context.input as { artifactId: string }; const { artifact_id } = context.input as { artifact_id: string };
const source = await context.fetchArtifact(artifactId); const source = await context.fetchArtifact(artifact_id);
const out = path.join(context.tempDir, "export.png"); const out = path.join(context.tempDir, "export.png");
await writeFile(out, Buffer.from(PNG_BASE64, "base64")); await writeFile(out, Buffer.from(PNG_BASE64, "base64"));
await context.addOutput({ await context.addOutput({
@ -54,12 +54,12 @@ function fakeOperations(): MediaOperationRegistration[] {
}, },
}, },
}; };
const transcribe: MediaOperationRegistration<{ artifactId: string }> = { const transcribe: MediaOperationRegistration<{ artifact_id: string }> = {
operation: "transcribe", operation: "transcribe",
inputSchema: SourceInputSchema, inputSchema: SourceInputSchema,
title: () => "Transcript", title: () => "Transcript",
available: async () => true, available: async () => true,
artifactIds: (input) => [input.artifactId], artifactIds: (input) => [input.artifact_id],
handler: { handler: {
operation: "transcribe", operation: "transcribe",
run: async (context) => { run: async (context) => {
@ -260,7 +260,7 @@ describeWithDatabase("media RPCs (PostgreSQL)", () => {
botId: alice.bot.id, botId: alice.bot.id,
messageId: alice.card.id, messageId: alice.card.id,
operation: "export_video", operation: "export_video",
input: { artifactId: alice.artifact.id }, input: { artifact_id: alice.artifact.id },
}, },
); );
const job = await waitForStatus(started.jobId, "done"); const job = await waitForStatus(started.jobId, "done");
@ -305,19 +305,19 @@ describeWithDatabase("media RPCs (PostgreSQL)", () => {
botId: alice.bot.id, botId: alice.bot.id,
messageId: alice.card.id, messageId: alice.card.id,
operation: "export_video", operation: "export_video",
input: { artifactId: mallory.artifact.id }, input: { artifact_id: mallory.artifact.id },
}, },
{ {
botId: mallory.bot.id, botId: mallory.bot.id,
messageId: alice.card.id, messageId: alice.card.id,
operation: "export_video", operation: "export_video",
input: { artifactId: mallory.artifact.id }, input: { artifact_id: mallory.artifact.id },
}, },
{ {
botId: mallory.bot.id, botId: mallory.bot.id,
messageId: mallory.card.id, messageId: mallory.card.id,
operation: "export_video", operation: "export_video",
input: { artifactId: alice.artifact.id }, input: { artifact_id: alice.artifact.id },
}, },
{ {
botId: mallory.bot.id, botId: mallory.bot.id,
@ -335,7 +335,7 @@ describeWithDatabase("media RPCs (PostgreSQL)", () => {
botId: alice.bot.id, botId: alice.bot.id,
messageId: alice.card.id, messageId: alice.card.id,
operation: "transcribe", operation: "transcribe",
input: { artifactId: alice.artifact.id }, input: { artifact_id: alice.artifact.id },
}); });
await waitForStatus(slow.jobId, "running"); await waitForStatus(slow.jobId, "running");
const cancelled = await rpc<{ status: string }>(app, alice.cookie, "media/jobs/cancel", { const cancelled = await rpc<{ status: string }>(app, alice.cookie, "media/jobs/cancel", {