feat(media): add media contracts, schema and provider interfaces
Groundwork for image and video generation, video editing and transcription. Nothing calls it yet; the media runner, providers, tools and UI build on it. - Contracts: video, audio and subtitle MIME types, per-kind size caps for streamed and generated artifacts, video/audio message blocks and the live media card block, media operations, job statuses and output presets (9:16, 16:9, 1:1, 4:5, 4:3, 21:9). - Core: attachment kinds include video and audio, artifacts become video or audio blocks, and agent history keeps media artifact ids so later turns can refer to them. - Schema: artifacts record width, height, duration and the artifact they were made from; media_jobs tracks generations, edits and transcriptions. - Adapter kit: provider-neutral image generation, video generation and transcription interfaces, the media job context operations run in, and the MediaRuntime that tools use to start jobs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
3d180dc2dd
commit
7ff93f08bb
@ -1,5 +1,6 @@
|
|||||||
export * from "./background-jobs.js";
|
export * from "./background-jobs.js";
|
||||||
export * from "./cloud-agents.js";
|
export * from "./cloud-agents.js";
|
||||||
export * from "./interfaces.js";
|
export * from "./interfaces.js";
|
||||||
|
export * from "./media.js";
|
||||||
export * from "./registry.js";
|
export * from "./registry.js";
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
|
|||||||
229
packages/adapter-kit/src/media.ts
Normal file
229
packages/adapter-kit/src/media.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import type {
|
||||||
|
MediaJobStatus,
|
||||||
|
MediaOperation,
|
||||||
|
MediaOutput,
|
||||||
|
MediaOutputKind,
|
||||||
|
MediaPresetId,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { AdapterDescriptor } from "./types.js";
|
||||||
|
|
||||||
|
/** Bytes handed to or returned by a media backend. */
|
||||||
|
export interface MediaFile {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mimeType: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedMedia extends MediaFile {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reports progress as a fraction from 0 to 1. */
|
||||||
|
export type MediaProgressListener = (fraction: number) => void;
|
||||||
|
|
||||||
|
/** Free and total memory of the backend's GPU, when the backend can tell. */
|
||||||
|
export interface GpuStatus {
|
||||||
|
vramFreeMb: number;
|
||||||
|
vramTotalMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationRequest {
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Number of images, default 1. */
|
||||||
|
count?: number;
|
||||||
|
seed?: number;
|
||||||
|
/** Source image for image-to-image and inpainting. */
|
||||||
|
source?: MediaFile;
|
||||||
|
/** 0–1: how far the result may move away from the source. */
|
||||||
|
strength?: number;
|
||||||
|
/** Inpainting mask, same size as the source; white areas are repainted. */
|
||||||
|
mask?: MediaFile;
|
||||||
|
/** Workflow template id; the provider's default when omitted. */
|
||||||
|
workflow?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: MediaProgressListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoGenerationRequest {
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
fps?: number;
|
||||||
|
seed?: number;
|
||||||
|
/** Start frame for image-to-video. */
|
||||||
|
source?: MediaFile;
|
||||||
|
workflow?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: MediaProgressListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationCapabilities {
|
||||||
|
textToImage: boolean;
|
||||||
|
imageToImage: boolean;
|
||||||
|
inpaint: boolean;
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoGenerationCapabilities {
|
||||||
|
textToVideo: boolean;
|
||||||
|
imageToVideo: boolean;
|
||||||
|
maxDurationSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationProvider {
|
||||||
|
describe(): AdapterDescriptor<ImageGenerationCapabilities>;
|
||||||
|
generateImage(request: ImageGenerationRequest): Promise<GeneratedMedia[]>;
|
||||||
|
/** Lets the media runner wait for free GPU memory instead of failing mid-generation. */
|
||||||
|
gpuStatus?(signal?: AbortSignal): Promise<GpuStatus | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoGenerationProvider {
|
||||||
|
describe(): AdapterDescriptor<VideoGenerationCapabilities>;
|
||||||
|
generateVideo(request: VideoGenerationRequest): Promise<GeneratedMedia[]>;
|
||||||
|
gpuStatus?(signal?: AbortSignal): Promise<GpuStatus | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptSegment {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transcript {
|
||||||
|
text: string;
|
||||||
|
/** Detected or requested language (BCP 47), when the backend reports it. */
|
||||||
|
language?: string;
|
||||||
|
/** Timed segments; empty when the backend returns plain text only. */
|
||||||
|
segments: TranscriptSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionRequest {
|
||||||
|
audio: Uint8Array;
|
||||||
|
mimeType: string;
|
||||||
|
fileName?: string;
|
||||||
|
/** Language hint (BCP 47); detected when omitted. */
|
||||||
|
language?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionCapabilities {
|
||||||
|
segments: boolean;
|
||||||
|
languageDetection: boolean;
|
||||||
|
maxBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionProvider {
|
||||||
|
describe(): AdapterDescriptor<TranscriptionCapabilities>;
|
||||||
|
transcribe(request: TranscriptionRequest): Promise<Transcript>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file an operation produced in its temp directory, to be stored as an artifact. */
|
||||||
|
export interface MediaJobOutputFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
/** Poster frame for a video, as a file in the temp directory. */
|
||||||
|
posterPath?: string;
|
||||||
|
parentArtifactId?: string;
|
||||||
|
preset?: MediaPresetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An artifact the job may read, copied into the job's temp directory. */
|
||||||
|
export interface MediaJobInputFile {
|
||||||
|
artifactId: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
kind: MediaOutputKind;
|
||||||
|
size: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the media runner hands an operation while a job runs. */
|
||||||
|
export interface MediaJobContext {
|
||||||
|
jobId: string;
|
||||||
|
spaceId: string;
|
||||||
|
userId: string;
|
||||||
|
botId: string | null;
|
||||||
|
/** The input stored with the job; each operation validates it with its own schema. */
|
||||||
|
input: unknown;
|
||||||
|
/** Private scratch directory, removed when the job ends. */
|
||||||
|
tempDir: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
progress(fraction: number): Promise<void>;
|
||||||
|
/** Copies an artifact of the job's space that the job's user may read into tempDir. */
|
||||||
|
fetchArtifact(artifactId: string): Promise<MediaJobInputFile>;
|
||||||
|
/** Stores a produced file as an artifact and adds it to the media card. */
|
||||||
|
addOutput(file: MediaJobOutputFile): Promise<MediaOutput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaOperationResult {
|
||||||
|
/** One or two sentences for the bot that started the job. */
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaOperationHandler {
|
||||||
|
operation: MediaOperation;
|
||||||
|
run(context: MediaJobContext): Promise<MediaOperationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown by an operation that cannot start yet, e.g. while the GPU lacks free memory. */
|
||||||
|
export class MediaJobDeferredError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly retryAfterMs: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "MediaJobDeferredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartMediaJobInput {
|
||||||
|
operation: MediaOperation;
|
||||||
|
/** Validated by the operation's input schema before the job is created. */
|
||||||
|
input: unknown;
|
||||||
|
title: string;
|
||||||
|
spaceId: string;
|
||||||
|
userId: string;
|
||||||
|
botId: string | null;
|
||||||
|
groupId: string | null;
|
||||||
|
threadId: string;
|
||||||
|
runId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaJobSnapshot {
|
||||||
|
id: string;
|
||||||
|
operation: MediaOperation;
|
||||||
|
status: MediaJobStatus;
|
||||||
|
title: string;
|
||||||
|
progress: number | null;
|
||||||
|
error: string | null;
|
||||||
|
outputs: MediaOutput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which media operations the space can run right now. */
|
||||||
|
export type MediaCapabilities = Record<MediaOperation, boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts media jobs and answers which operations are available. Tools call this; the job runs
|
||||||
|
* in a background worker and keeps its media card current.
|
||||||
|
*/
|
||||||
|
export interface MediaRuntime {
|
||||||
|
capabilities(spaceId: string): Promise<MediaCapabilities>;
|
||||||
|
start(input: StartMediaJobInput): Promise<{ jobId: string; messageId: string }>;
|
||||||
|
get(jobId: string): Promise<MediaJobSnapshot | null>;
|
||||||
|
cancel(jobId: string): Promise<void>;
|
||||||
|
}
|
||||||
@ -39,7 +39,10 @@ export async function attachWorkspaceFileToThread(
|
|||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
operationId: string;
|
operationId: string;
|
||||||
},
|
},
|
||||||
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
|
): Promise<{
|
||||||
|
artifactId: string;
|
||||||
|
block: Extract<MessageBlock, { kind: "image" | "video" | "audio" | "file" }>;
|
||||||
|
}> {
|
||||||
const name = path.basename(input.filePath) || input.filePath;
|
const name = path.basename(input.filePath) || input.filePath;
|
||||||
const mimeType = inferAttachmentMimeType(name);
|
const mimeType = inferAttachmentMimeType(name);
|
||||||
if (!mimeType) {
|
if (!mimeType) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MessageBlock, validateThreadsSendInput } from "./index.js";
|
import { attachmentKindOf, MessageBlock, validateThreadsSendInput } from "./index.js";
|
||||||
|
|
||||||
describe("attachment contracts", () => {
|
describe("attachment contracts", () => {
|
||||||
it("parses image and file message blocks", () => {
|
it("parses image and file message blocks", () => {
|
||||||
@ -28,4 +28,45 @@ describe("attachment contracts", () => {
|
|||||||
expect(validateThreadsSendInput({})).toBe(false);
|
expect(validateThreadsSendInput({})).toBe(false);
|
||||||
expect(validateThreadsSendInput({ artifactIds: ["a", "b", "c", "d", "e"] })).toBe(true);
|
expect(validateThreadsSendInput({ artifactIds: ["a", "b", "c", "d", "e"] })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies media types and parses media blocks", () => {
|
||||||
|
expect(attachmentKindOf("image/webp")).toBe("image");
|
||||||
|
expect(attachmentKindOf("video/mp4")).toBe("video");
|
||||||
|
expect(attachmentKindOf("audio/mpeg")).toBe("audio");
|
||||||
|
expect(attachmentKindOf("text/vtt")).toBe("file");
|
||||||
|
expect(
|
||||||
|
MessageBlock.parse({
|
||||||
|
kind: "video",
|
||||||
|
artifactId: "art_3",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
name: "cut.mp4",
|
||||||
|
size: 2048,
|
||||||
|
durationMs: 15000,
|
||||||
|
width: 1080,
|
||||||
|
height: 1920,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ kind: "video", width: 1080 });
|
||||||
|
expect(
|
||||||
|
MessageBlock.parse({
|
||||||
|
kind: "media",
|
||||||
|
jobId: "job_1",
|
||||||
|
operation: "edit_video",
|
||||||
|
status: "running",
|
||||||
|
title: "Cut to 9:16",
|
||||||
|
progress: 0.4,
|
||||||
|
outputs: [],
|
||||||
|
}),
|
||||||
|
).toMatchObject({ kind: "media", status: "running" });
|
||||||
|
expect(() =>
|
||||||
|
MessageBlock.parse({
|
||||||
|
kind: "media",
|
||||||
|
jobId: "job_1",
|
||||||
|
operation: "edit_video",
|
||||||
|
status: "running",
|
||||||
|
title: "Cut",
|
||||||
|
progress: 1.5,
|
||||||
|
outputs: [],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,25 +10,67 @@ export const ATTACHMENT_IMAGE_MIME_TYPES = [
|
|||||||
"image/gif",
|
"image/gif",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const ATTACHMENT_VIDEO_MIME_TYPES = ["video/mp4", "video/webm", "video/quicktime"] as const;
|
||||||
|
|
||||||
|
export const ATTACHMENT_AUDIO_MIME_TYPES = [
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const ATTACHMENT_FILE_MIME_TYPES = [
|
export const ATTACHMENT_FILE_MIME_TYPES = [
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"text/markdown",
|
"text/markdown",
|
||||||
"text/csv",
|
"text/csv",
|
||||||
"application/json",
|
"application/json",
|
||||||
|
"text/vtt",
|
||||||
|
"application/x-subrip",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const ATTACHMENT_ALLOWED_MIME_TYPES = [
|
export const ATTACHMENT_ALLOWED_MIME_TYPES = [
|
||||||
...ATTACHMENT_IMAGE_MIME_TYPES,
|
...ATTACHMENT_IMAGE_MIME_TYPES,
|
||||||
|
...ATTACHMENT_VIDEO_MIME_TYPES,
|
||||||
|
...ATTACHMENT_AUDIO_MIME_TYPES,
|
||||||
...ATTACHMENT_FILE_MIME_TYPES,
|
...ATTACHMENT_FILE_MIME_TYPES,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type AttachmentKind = "image" | "video" | "audio" | "file";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size caps for artifacts that do not travel inline as base64 (streamed uploads, generated and
|
||||||
|
* edited media). Inline RPC uploads stay capped by ATTACHMENT_MAX_BYTES.
|
||||||
|
*/
|
||||||
|
export const ARTIFACT_MAX_BYTES_BY_KIND: Record<AttachmentKind, number> = {
|
||||||
|
image: 25 * 1024 * 1024,
|
||||||
|
video: 500 * 1024 * 1024,
|
||||||
|
audio: 200 * 1024 * 1024,
|
||||||
|
file: 25 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
export type AttachmentMimeType = (typeof ATTACHMENT_ALLOWED_MIME_TYPES)[number];
|
export type AttachmentMimeType = (typeof ATTACHMENT_ALLOWED_MIME_TYPES)[number];
|
||||||
|
|
||||||
export function isAttachmentImageMimeType(mimeType: string): boolean {
|
export function isAttachmentImageMimeType(mimeType: string): boolean {
|
||||||
return (ATTACHMENT_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
|
return (ATTACHMENT_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAttachmentVideoMimeType(mimeType: string): boolean {
|
||||||
|
return (ATTACHMENT_VIDEO_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttachmentAudioMimeType(mimeType: string): boolean {
|
||||||
|
return (ATTACHMENT_AUDIO_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachmentKindOf(mimeType: string): AttachmentKind {
|
||||||
|
if (isAttachmentImageMimeType(mimeType)) return "image";
|
||||||
|
if (isAttachmentVideoMimeType(mimeType)) return "video";
|
||||||
|
if (isAttachmentAudioMimeType(mimeType)) return "audio";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
export function isAllowedAttachmentMimeType(mimeType: string): mimeType is AttachmentMimeType {
|
export function isAllowedAttachmentMimeType(mimeType: string): mimeType is AttachmentMimeType {
|
||||||
return (ATTACHMENT_ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType);
|
return (ATTACHMENT_ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import * as z from "zod";
|
|||||||
import { BotSecretDestination } from "./bot-secrets.js";
|
import { BotSecretDestination } from "./bot-secrets.js";
|
||||||
import { Id } from "./ids.js";
|
import { Id } from "./ids.js";
|
||||||
import { McpTransportSchema } from "./mcp.js";
|
import { McpTransportSchema } from "./mcp.js";
|
||||||
|
import { AudioBlockSchema, MediaJobBlockSchema, VideoBlockSchema } from "./media.js";
|
||||||
|
|
||||||
export const ProductEventType = z.enum([
|
export const ProductEventType = z.enum([
|
||||||
"thread.message.created",
|
"thread.message.created",
|
||||||
@ -248,6 +249,9 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
size: z.number().int().nonnegative(),
|
size: z.number().int().nonnegative(),
|
||||||
}),
|
}),
|
||||||
|
VideoBlockSchema,
|
||||||
|
AudioBlockSchema,
|
||||||
|
MediaJobBlockSchema,
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("handoff"),
|
kind: z.literal("handoff"),
|
||||||
fromBotId: Id,
|
fromBotId: Id,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export * from "./inbox.js";
|
|||||||
export * from "./integration-settings.js";
|
export * from "./integration-settings.js";
|
||||||
export * from "./local-settings.js";
|
export * from "./local-settings.js";
|
||||||
export * from "./mcp.js";
|
export * from "./mcp.js";
|
||||||
|
export * from "./media.js";
|
||||||
export * from "./model-tiers.js";
|
export * from "./model-tiers.js";
|
||||||
export * from "./openai-compatible-ui.js";
|
export * from "./openai-compatible-ui.js";
|
||||||
export * from "./project-goals.js";
|
export * from "./project-goals.js";
|
||||||
|
|||||||
113
packages/contracts/src/media.ts
Normal file
113
packages/contracts/src/media.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
import { Id } from "./ids.js";
|
||||||
|
|
||||||
|
/** What a media job does. Generation runs on the configured image/video backend, edits run locally. */
|
||||||
|
export const MediaOperationSchema = z.enum([
|
||||||
|
"generate_image",
|
||||||
|
"generate_video",
|
||||||
|
"edit_image",
|
||||||
|
"edit_video",
|
||||||
|
"export_video",
|
||||||
|
"transcribe",
|
||||||
|
]);
|
||||||
|
export type MediaOperation = z.infer<typeof MediaOperationSchema>;
|
||||||
|
|
||||||
|
/** `waiting` means the job is queued behind another GPU job or for free GPU memory. */
|
||||||
|
export const MediaJobStatusSchema = z.enum([
|
||||||
|
"queued",
|
||||||
|
"waiting",
|
||||||
|
"running",
|
||||||
|
"done",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
]);
|
||||||
|
export type MediaJobStatus = z.infer<typeof MediaJobStatusSchema>;
|
||||||
|
|
||||||
|
export const MEDIA_JOB_ACTIVE_STATUSES = [
|
||||||
|
"queued",
|
||||||
|
"waiting",
|
||||||
|
"running",
|
||||||
|
] as const satisfies readonly MediaJobStatus[];
|
||||||
|
|
||||||
|
export const MediaOutputKindSchema = z.enum(["image", "video", "audio", "file"]);
|
||||||
|
export type MediaOutputKind = z.infer<typeof MediaOutputKindSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output formats for video cuts and exports. Width and height are the encoded frame; every
|
||||||
|
* preset encodes H.264 + AAC in MP4 so the result plays everywhere it is posted.
|
||||||
|
*/
|
||||||
|
export const MEDIA_PRESETS = {
|
||||||
|
vertical: { aspect: "9:16", width: 1080, height: 1920 },
|
||||||
|
landscape: { aspect: "16:9", width: 1920, height: 1080 },
|
||||||
|
square: { aspect: "1:1", width: 1080, height: 1080 },
|
||||||
|
portrait: { aspect: "4:5", width: 1080, height: 1350 },
|
||||||
|
classic: { aspect: "4:3", width: 1440, height: 1080 },
|
||||||
|
cinema: { aspect: "21:9", width: 2560, height: 1080 },
|
||||||
|
} as const;
|
||||||
|
export const MediaPresetIdSchema = z.enum([
|
||||||
|
"vertical",
|
||||||
|
"landscape",
|
||||||
|
"square",
|
||||||
|
"portrait",
|
||||||
|
"classic",
|
||||||
|
"cinema",
|
||||||
|
]);
|
||||||
|
export type MediaPresetId = z.infer<typeof MediaPresetIdSchema>;
|
||||||
|
export type MediaPreset = (typeof MEDIA_PRESETS)[MediaPresetId];
|
||||||
|
|
||||||
|
/** How a frame of another aspect ratio fills a preset. */
|
||||||
|
export const MediaFitSchema = z.enum(["crop", "pad", "blur"]);
|
||||||
|
export type MediaFit = z.infer<typeof MediaFitSchema>;
|
||||||
|
|
||||||
|
export const MediaOutputSchema = z.object({
|
||||||
|
artifactId: Id,
|
||||||
|
kind: MediaOutputKindSchema,
|
||||||
|
mimeType: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
durationMs: z.number().int().nonnegative().optional(),
|
||||||
|
posterArtifactId: Id.optional(),
|
||||||
|
/** The artifact this output was made from, for version history. */
|
||||||
|
parentArtifactId: Id.optional(),
|
||||||
|
preset: MediaPresetIdSchema.optional(),
|
||||||
|
});
|
||||||
|
export type MediaOutput = z.infer<typeof MediaOutputSchema>;
|
||||||
|
|
||||||
|
/** The live media card a job keeps up to date in the conversation. */
|
||||||
|
export const MediaJobBlockSchema = z.object({
|
||||||
|
kind: z.literal("media"),
|
||||||
|
jobId: Id,
|
||||||
|
operation: MediaOperationSchema,
|
||||||
|
status: MediaJobStatusSchema,
|
||||||
|
/** Short label: the prompt, or what the edit does. */
|
||||||
|
title: z.string(),
|
||||||
|
progress: z.number().min(0).max(1).optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
outputs: z.array(MediaOutputSchema),
|
||||||
|
});
|
||||||
|
export type MediaJobBlock = z.infer<typeof MediaJobBlockSchema>;
|
||||||
|
|
||||||
|
export const VideoBlockSchema = z.object({
|
||||||
|
kind: z.literal("video"),
|
||||||
|
artifactId: Id,
|
||||||
|
mimeType: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
durationMs: z.number().int().nonnegative().optional(),
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
posterArtifactId: Id.optional(),
|
||||||
|
});
|
||||||
|
export type VideoBlock = z.infer<typeof VideoBlockSchema>;
|
||||||
|
|
||||||
|
export const AudioBlockSchema = z.object({
|
||||||
|
kind: z.literal("audio"),
|
||||||
|
artifactId: Id,
|
||||||
|
mimeType: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
durationMs: z.number().int().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
export type AudioBlock = z.infer<typeof AudioBlockSchema>;
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
blocksToAgentHistoryText,
|
blocksToAgentHistoryText,
|
||||||
decodeAttachmentBase64,
|
decodeAttachmentBase64,
|
||||||
inferAttachmentMimeType,
|
inferAttachmentMimeType,
|
||||||
|
messageBlockForArtifact,
|
||||||
promptTextForAttachments,
|
promptTextForAttachments,
|
||||||
userTurnBlocksForRun,
|
userTurnBlocksForRun,
|
||||||
validateAttachmentMimeType,
|
validateAttachmentMimeType,
|
||||||
@ -108,4 +109,61 @@ describe("peer message history", () => {
|
|||||||
]),
|
]),
|
||||||
).toBe("[to Analyst] chart it");
|
).toBe("[to Analyst] chart it");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds video and audio blocks and keeps media artifact ids in history", () => {
|
||||||
|
const video = messageBlockForArtifact({
|
||||||
|
id: "art_v",
|
||||||
|
name: "clip.mp4",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
size: 10,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
durationMs: 3000,
|
||||||
|
});
|
||||||
|
expect(video).toEqual({
|
||||||
|
kind: "video",
|
||||||
|
artifactId: "art_v",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
name: "clip.mp4",
|
||||||
|
size: 10,
|
||||||
|
durationMs: 3000,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
messageBlockForArtifact({ id: "art_a", name: "a.mp3", mimeType: "audio/mpeg", size: 5 }),
|
||||||
|
).toEqual({
|
||||||
|
kind: "audio",
|
||||||
|
artifactId: "art_a",
|
||||||
|
mimeType: "audio/mpeg",
|
||||||
|
name: "a.mp3",
|
||||||
|
size: 5,
|
||||||
|
});
|
||||||
|
expect(inferAttachmentMimeType("talk.MOV")).toBe("video/quicktime");
|
||||||
|
expect(inferAttachmentMimeType("subs.srt")).toBe("application/x-subrip");
|
||||||
|
expect(
|
||||||
|
blocksToAgentHistoryText([
|
||||||
|
video,
|
||||||
|
{
|
||||||
|
kind: "media",
|
||||||
|
jobId: "job_1",
|
||||||
|
operation: "edit_video",
|
||||||
|
status: "done",
|
||||||
|
title: "Cut to 9:16",
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
artifactId: "art_out",
|
||||||
|
kind: "video",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
name: "clip-vertical.mp4",
|
||||||
|
size: 9,
|
||||||
|
preset: "vertical",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toBe(
|
||||||
|
"[video: clip.mp4 (artifact art_v)]\n[media edit_video done: Cut to 9:16 -> video clip-vertical.mp4 (artifact art_out)]",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ATTACHMENT_MAX_BASE64_LENGTH,
|
ATTACHMENT_MAX_BASE64_LENGTH,
|
||||||
ATTACHMENT_MAX_BYTES,
|
ATTACHMENT_MAX_BYTES,
|
||||||
|
type AttachmentKind,
|
||||||
type AttachmentMimeType,
|
type AttachmentMimeType,
|
||||||
|
attachmentKindOf,
|
||||||
isAllowedAttachmentMimeType,
|
isAllowedAttachmentMimeType,
|
||||||
isAttachmentImageMimeType,
|
isAttachmentImageMimeType,
|
||||||
type MessageBlock,
|
type MessageBlock,
|
||||||
@ -45,8 +47,8 @@ export function validateAttachmentMimeType(mimeType: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachmentKindForMimeType(mimeType: string): "image" | "file" {
|
export function attachmentKindForMimeType(mimeType: string): AttachmentKind {
|
||||||
return isAttachmentImageMimeType(mimeType) ? "image" : "file";
|
return attachmentKindOf(mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageBlockForArtifact(artifact: {
|
export function messageBlockForArtifact(artifact: {
|
||||||
@ -54,8 +56,12 @@ export function messageBlockForArtifact(artifact: {
|
|||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
}): Extract<MessageBlock, { kind: "image" | "file" }> {
|
width?: number | null;
|
||||||
if (attachmentKindForMimeType(artifact.mimeType) === "image") {
|
height?: number | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
}): Extract<MessageBlock, { kind: "image" | "video" | "audio" | "file" }> {
|
||||||
|
const kind = attachmentKindForMimeType(artifact.mimeType);
|
||||||
|
if (kind === "image") {
|
||||||
return {
|
return {
|
||||||
kind: "image",
|
kind: "image",
|
||||||
artifactId: artifact.id,
|
artifactId: artifact.id,
|
||||||
@ -63,6 +69,28 @@ export function messageBlockForArtifact(artifact: {
|
|||||||
name: artifact.name,
|
name: artifact.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (kind === "video") {
|
||||||
|
return {
|
||||||
|
kind: "video",
|
||||||
|
artifactId: artifact.id,
|
||||||
|
mimeType: artifact.mimeType,
|
||||||
|
name: artifact.name,
|
||||||
|
size: artifact.size,
|
||||||
|
...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}),
|
||||||
|
...(artifact.width != null ? { width: artifact.width } : {}),
|
||||||
|
...(artifact.height != null ? { height: artifact.height } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === "audio") {
|
||||||
|
return {
|
||||||
|
kind: "audio",
|
||||||
|
artifactId: artifact.id,
|
||||||
|
mimeType: artifact.mimeType,
|
||||||
|
name: artifact.name,
|
||||||
|
size: artifact.size,
|
||||||
|
...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: "file",
|
kind: "file",
|
||||||
artifactId: artifact.id,
|
artifactId: artifact.id,
|
||||||
@ -95,6 +123,16 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
|
|||||||
if (block.kind === "file") {
|
if (block.kind === "file") {
|
||||||
return `[file: ${block.name} (${block.mimeType}, ${block.size} bytes)]`;
|
return `[file: ${block.name} (${block.mimeType}, ${block.size} bytes)]`;
|
||||||
}
|
}
|
||||||
|
// Media keeps the artifact id so a later turn can edit or export what it refers to.
|
||||||
|
if (block.kind === "video" || block.kind === "audio") {
|
||||||
|
return `[${block.kind}: ${block.name} (artifact ${block.artifactId})]`;
|
||||||
|
}
|
||||||
|
if (block.kind === "media") {
|
||||||
|
const outputs = block.outputs
|
||||||
|
.map((output) => `${output.kind} ${output.name} (artifact ${output.artifactId})`)
|
||||||
|
.join(", ");
|
||||||
|
return `[media ${block.operation} ${block.status}: ${block.title}${outputs ? ` -> ${outputs}` : ""}]`;
|
||||||
|
}
|
||||||
// Keep attribution on peer messages: without it a later turn cannot tell
|
// Keep attribution on peer messages: without it a later turn cannot tell
|
||||||
// which lines came from another bot rather than the user.
|
// which lines came from another bot rather than the user.
|
||||||
if (block.kind === "bot_message_received") return `[from ${block.fromBotName}] ${block.text}`;
|
if (block.kind === "bot_message_received") return `[from ${block.fromBotName}] ${block.text}`;
|
||||||
@ -124,6 +162,17 @@ const EXTENSION_MIME_TYPES: Record<string, AttachmentMimeType> = {
|
|||||||
".markdown": "text/markdown",
|
".markdown": "text/markdown",
|
||||||
".csv": "text/csv",
|
".csv": "text/csv",
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".m4v": "video/mp4",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".m4a": "audio/mp4",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".oga": "audio/ogg",
|
||||||
|
".vtt": "text/vtt",
|
||||||
|
".srt": "application/x-subrip",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIME_TYPE_EXTENSIONS: Record<AttachmentMimeType, string> = {
|
const MIME_TYPE_EXTENSIONS: Record<AttachmentMimeType, string> = {
|
||||||
@ -136,6 +185,16 @@ const MIME_TYPE_EXTENSIONS: Record<AttachmentMimeType, string> = {
|
|||||||
"text/markdown": ".md",
|
"text/markdown": ".md",
|
||||||
"text/csv": ".csv",
|
"text/csv": ".csv",
|
||||||
"application/json": ".json",
|
"application/json": ".json",
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"video/webm": ".webm",
|
||||||
|
"video/quicktime": ".mov",
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/mp4": ".m4a",
|
||||||
|
"audio/wav": ".wav",
|
||||||
|
"audio/webm": ".weba",
|
||||||
|
"audio/ogg": ".ogg",
|
||||||
|
"text/vtt": ".vtt",
|
||||||
|
"application/x-subrip": ".srt",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function inferAttachmentMimeType(
|
export function inferAttachmentMimeType(
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
-- Media: artifacts remember their pixel size, play length and the artifact they were made from,
|
||||||
|
-- and generations, edits and transcriptions run as media jobs outside the agent turn.
|
||||||
|
ALTER TABLE "artifacts" ADD COLUMN "width" INTEGER;
|
||||||
|
ALTER TABLE "artifacts" ADD COLUMN "height" INTEGER;
|
||||||
|
ALTER TABLE "artifacts" ADD COLUMN "durationMs" INTEGER;
|
||||||
|
ALTER TABLE "artifacts" ADD COLUMN "parentArtifactId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "artifacts_parentArtifactId_idx" ON "artifacts"("parentArtifactId");
|
||||||
|
|
||||||
|
ALTER TABLE "artifacts" ADD CONSTRAINT "artifacts_parentArtifactId_fkey"
|
||||||
|
FOREIGN KEY ("parentArtifactId") REFERENCES "artifacts"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
NOT VALID;
|
||||||
|
ALTER TABLE "artifacts" ADD CONSTRAINT "artifacts_media_metadata_chk"
|
||||||
|
CHECK (
|
||||||
|
("width" IS NULL OR "width" > 0)
|
||||||
|
AND ("height" IS NULL OR "height" > 0)
|
||||||
|
AND ("durationMs" IS NULL OR "durationMs" >= 0)
|
||||||
|
) NOT VALID;
|
||||||
|
|
||||||
|
CREATE TABLE "media_jobs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"spaceId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"botId" TEXT,
|
||||||
|
"groupId" TEXT,
|
||||||
|
"threadId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"runId" TEXT,
|
||||||
|
"operation" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
"title" TEXT NOT NULL DEFAULT '',
|
||||||
|
"input" JSONB NOT NULL,
|
||||||
|
"progress" DOUBLE PRECISION,
|
||||||
|
"error" TEXT,
|
||||||
|
"outputArtifactIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"cancelRequested" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "media_jobs_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "media_jobs_operation_chk" CHECK ("operation" IN (
|
||||||
|
'generate_image', 'generate_video', 'edit_image', 'edit_video', 'export_video', 'transcribe'
|
||||||
|
)),
|
||||||
|
CONSTRAINT "media_jobs_status_chk" CHECK ("status" IN (
|
||||||
|
'queued', 'waiting', 'running', 'done', 'failed', 'cancelled'
|
||||||
|
)),
|
||||||
|
CONSTRAINT "media_jobs_progress_chk" CHECK ("progress" IS NULL OR ("progress" >= 0 AND "progress" <= 1))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "media_jobs_spaceId_status_idx" ON "media_jobs"("spaceId", "status");
|
||||||
|
CREATE INDEX "media_jobs_threadId_idx" ON "media_jobs"("threadId");
|
||||||
|
|
||||||
|
ALTER TABLE "media_jobs" ADD CONSTRAINT "media_jobs_spaceId_fkey"
|
||||||
|
FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- Kept separate from creation so PostgreSQL validates existing rows with a SHARE UPDATE
|
||||||
|
-- EXCLUSIVE lock (reads and writes continue) after the NOT VALID constraints have committed.
|
||||||
|
ALTER TABLE "artifacts" VALIDATE CONSTRAINT "artifacts_parentArtifactId_fkey";
|
||||||
|
ALTER TABLE "artifacts" VALIDATE CONSTRAINT "artifacts_media_metadata_chk";
|
||||||
@ -197,6 +197,7 @@ model Space {
|
|||||||
agentHomes AgentHome[]
|
agentHomes AgentHome[]
|
||||||
browserProfiles BrowserProfile[]
|
browserProfiles BrowserProfile[]
|
||||||
artifacts Artifact[]
|
artifacts Artifact[]
|
||||||
|
mediaJobs MediaJob[]
|
||||||
usageRecords UsageRecord[]
|
usageRecords UsageRecord[]
|
||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
computers Computer[]
|
computers Computer[]
|
||||||
@ -1065,13 +1066,54 @@ model Artifact {
|
|||||||
size Int
|
size Int
|
||||||
hash String
|
hash String
|
||||||
storageKey String
|
storageKey String
|
||||||
|
/// Pixel size of images and videos and play length of audio and video, when known.
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
durationMs Int?
|
||||||
|
/// The artifact this one was made from (an edit, cut or export), for version history.
|
||||||
|
parentArtifactId String?
|
||||||
|
parent Artifact? @relation("ArtifactVersions", fields: [parentArtifactId], references: [id], onDelete: SetNull)
|
||||||
|
versions Artifact[] @relation("ArtifactVersions")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([spaceId, botId])
|
@@index([spaceId, botId])
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
|
@@index([parentArtifactId])
|
||||||
@@map("artifacts")
|
@@map("artifacts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A generation, edit or transcription that runs outside the agent turn and reports into a
|
||||||
|
/// media card in the conversation. Like CloudAgent, provenance ids carry no foreign keys.
|
||||||
|
model MediaJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
spaceId String
|
||||||
|
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
botId String?
|
||||||
|
groupId String?
|
||||||
|
threadId String
|
||||||
|
/// The bot message that carries the media card.
|
||||||
|
messageId String?
|
||||||
|
runId String?
|
||||||
|
operation String
|
||||||
|
status String @default("queued")
|
||||||
|
title String @default("")
|
||||||
|
input Json
|
||||||
|
progress Float?
|
||||||
|
error String?
|
||||||
|
outputArtifactIds String[] @default([])
|
||||||
|
attempts Int @default(0)
|
||||||
|
cancelRequested Boolean @default(false)
|
||||||
|
startedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([spaceId, status])
|
||||||
|
@@index([threadId])
|
||||||
|
@@map("media_jobs")
|
||||||
|
}
|
||||||
|
|
||||||
model UsageRecord {
|
model UsageRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
spaceId String
|
spaceId String
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user